Spaces:
Running
Overview: true running state, turn history, richer cards
Browse filesThe card called a Codex task "done" as soon as any assistant message
appeared, but Codex emits several intermediate messages per task. Parse
its task lifecycle instead: event_msg task_started/task_complete drive a
digest-level running flag (with the terminal-derived state as fallback
for other CLIs), the running card shows the latest model output with a
spinner below it, and task_complete's last_agent_message is the
authoritative answer. Web searches now count as tool calls (they were
tracked separately and showed "0 tools" on search-heavy tasks).
New per-request turn history: each assistant text pushes the previous
one into digest.turnsLog (cleared on every new prompt), and up/down
arrows on the meta line page through the model's intermediate turns
with a "turn k/N" indicator.
Card design: the user prompt is the loudest line (full text color,
accent arrow), the reply is a quiet always-visible input whose arrow
mirrors the prompt's, and the 16px anti-zoom input rule is scoped to
touch devices so narrow desktop windows keep card-sized text. Bottom
filter labels are now all | done | running | stopped.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- server/src/traces.js +40 -4
- web/src/App.tsx +1 -1
- web/src/api.ts +3 -0
- web/src/components/Overview.tsx +72 -42
- web/src/styles.css +24 -17
|
@@ -40,19 +40,33 @@ function mergeInto(a, b) {
|
|
| 40 |
// Built in the same parse pass: every real user prompt resets the segment, so
|
| 41 |
// whatever accumulated by EOF is the activity since the last thing you said.
|
| 42 |
function emptyDigest() {
|
| 43 |
-
return { lastPromptText: '', lastPromptTs: 0, lastAssistantText: '', lastAssistantMd: '', lastAssistantTs: 0, sinceTurns: 0, sinceToolCalls: 0, sinceTools: {}, sinceFiles: [], sinceTokens: 0 };
|
| 44 |
}
|
| 45 |
const clip = (s, n = 280) => { const t = (s || '').replace(/\s+/g, ' ').trim(); return t.length > n ? `${t.slice(0, n - 1)}…` : t; };
|
| 46 |
// Markdown-preserving variant (keeps newlines) for the expandable card view.
|
| 47 |
const clipRaw = (s, n = 6000) => { const t = (s || '').trim(); return t.length > n ? `${t.slice(0, n - 1)}…` : t; };
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
function digestPrompt(d, text, ts) {
|
| 49 |
d.lastPromptText = clip(text); d.lastPromptTs = Date.parse(ts) || 0;
|
| 50 |
d.sinceTurns = 0; d.sinceToolCalls = 0; d.sinceTools = {}; d.sinceFiles = []; d.sinceTokens = 0;
|
|
|
|
| 51 |
// The previous answer belongs to the previous prompt — never show it as "LAST".
|
| 52 |
d.lastAssistantText = ''; d.lastAssistantMd = ''; d.lastAssistantTs = 0;
|
| 53 |
}
|
| 54 |
function digestAssistant(d, text, ts) {
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
d.lastAssistantMd = clipRaw(text);
|
| 57 |
d.lastAssistantTs = Date.parse(ts) || d.lastAssistantTs;
|
| 58 |
}
|
|
@@ -178,11 +192,33 @@ function parseCodex(txt) {
|
|
| 178 |
}
|
| 179 |
case 'web_search_call':
|
| 180 |
st.web++;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
break;
|
| 182 |
default:
|
| 183 |
}
|
| 184 |
-
} else if (j.type === 'event_msg' && p.type === 'token_count' && p.info && p.info.total_token_usage) {
|
| 185 |
-
tok = p.info.total_token_usage;
|
| 186 |
}
|
| 187 |
}
|
| 188 |
if (tok) {
|
|
|
|
| 40 |
// Built in the same parse pass: every real user prompt resets the segment, so
|
| 41 |
// whatever accumulated by EOF is the activity since the last thing you said.
|
| 42 |
function emptyDigest() {
|
| 43 |
+
return { lastPromptText: '', lastPromptTs: 0, lastAssistantText: '', lastAssistantMd: '', lastAssistantTs: 0, sinceTurns: 0, sinceToolCalls: 0, sinceTools: {}, sinceFiles: [], sinceTokens: 0, running: false, turnsLog: [] };
|
| 44 |
}
|
| 45 |
const clip = (s, n = 280) => { const t = (s || '').replace(/\s+/g, ' ').trim(); return t.length > n ? `${t.slice(0, n - 1)}…` : t; };
|
| 46 |
// Markdown-preserving variant (keeps newlines) for the expandable card view.
|
| 47 |
const clipRaw = (s, n = 6000) => { const t = (s || '').trim(); return t.length > n ? `${t.slice(0, n - 1)}…` : t; };
|
| 48 |
+
// turnsLog: the model's intermediate turns WITHIN the current request (newest
|
| 49 |
+
// first) so the Overview can page through them. Each new assistant text pushes
|
| 50 |
+
// the previous one into the log; a new user prompt clears it.
|
| 51 |
+
const MAX_TURNS_LOG = 24;
|
| 52 |
function digestPrompt(d, text, ts) {
|
| 53 |
d.lastPromptText = clip(text); d.lastPromptTs = Date.parse(ts) || 0;
|
| 54 |
d.sinceTurns = 0; d.sinceToolCalls = 0; d.sinceTools = {}; d.sinceFiles = []; d.sinceTokens = 0;
|
| 55 |
+
d.turnsLog = []; // arrows only walk the current request's turns
|
| 56 |
// The previous answer belongs to the previous prompt — never show it as "LAST".
|
| 57 |
d.lastAssistantText = ''; d.lastAssistantMd = ''; d.lastAssistantTs = 0;
|
| 58 |
}
|
| 59 |
function digestAssistant(d, text, ts) {
|
| 60 |
+
const clipped = clip(text);
|
| 61 |
+
// Same text again (codex mirrors agent_message/response_item/task_complete):
|
| 62 |
+
// refresh metadata only, don't log a phantom turn.
|
| 63 |
+
if (clipped !== d.lastAssistantText) {
|
| 64 |
+
if (d.lastAssistantText) {
|
| 65 |
+
d.turnsLog.unshift({ answer: d.lastAssistantText, answerMd: d.lastAssistantMd, ts: d.lastAssistantTs });
|
| 66 |
+
if (d.turnsLog.length > MAX_TURNS_LOG) d.turnsLog.pop();
|
| 67 |
+
}
|
| 68 |
+
d.lastAssistantText = clipped;
|
| 69 |
+
}
|
| 70 |
d.lastAssistantMd = clipRaw(text);
|
| 71 |
d.lastAssistantTs = Date.parse(ts) || d.lastAssistantTs;
|
| 72 |
}
|
|
|
|
| 192 |
}
|
| 193 |
case 'web_search_call':
|
| 194 |
st.web++;
|
| 195 |
+
st.toolCalls++;
|
| 196 |
+
st.tools.web_search = (st.tools.web_search || 0) + 1;
|
| 197 |
+
digestTool(dg, 'web_search', null);
|
| 198 |
+
break;
|
| 199 |
+
default:
|
| 200 |
+
}
|
| 201 |
+
} else if (j.type === 'event_msg') {
|
| 202 |
+
// Task lifecycle: Codex runs one task per user prompt, made of several
|
| 203 |
+
// model turns. task_started/task_complete bracket it — that (not "an
|
| 204 |
+
// assistant message appeared") is the real running/done signal, and
|
| 205 |
+
// task_complete carries the authoritative final answer.
|
| 206 |
+
switch (p.type) {
|
| 207 |
+
case 'token_count':
|
| 208 |
+
if (p.info && p.info.total_token_usage) tok = p.info.total_token_usage;
|
| 209 |
+
break;
|
| 210 |
+
case 'task_started':
|
| 211 |
+
dg.running = true;
|
| 212 |
+
break;
|
| 213 |
+
case 'agent_message':
|
| 214 |
+
if (p.message) digestAssistant(dg, p.message, j.timestamp); // live progress text
|
| 215 |
+
break;
|
| 216 |
+
case 'task_complete':
|
| 217 |
+
dg.running = false;
|
| 218 |
+
if (p.last_agent_message) digestAssistant(dg, p.last_agent_message, j.timestamp);
|
| 219 |
break;
|
| 220 |
default:
|
| 221 |
}
|
|
|
|
|
|
|
| 222 |
}
|
| 223 |
}
|
| 224 |
if (tok) {
|
|
@@ -490,7 +490,7 @@ export default function App() {
|
|
| 490 |
<div className="seg ov-seg">
|
| 491 |
{(['all', 'waiting', 'working', 'quiet'] as OverviewFilter[]).map((f) => (
|
| 492 |
<button key={f} className={ovFilter === f ? 'on' : ''} onClick={() => setOvFilter(f)}>
|
| 493 |
-
{f === 'waiting' ? '
|
| 494 |
</button>
|
| 495 |
))}
|
| 496 |
</div>
|
|
|
|
| 490 |
<div className="seg ov-seg">
|
| 491 |
{(['all', 'waiting', 'working', 'quiet'] as OverviewFilter[]).map((f) => (
|
| 492 |
<button key={f} className={ovFilter === f ? 'on' : ''} onClick={() => setOvFilter(f)}>
|
| 493 |
+
{f === 'waiting' ? 'done' : f === 'working' ? 'running' : f === 'quiet' ? 'stopped' : 'all'}
|
| 494 |
</button>
|
| 495 |
))}
|
| 496 |
</div>
|
|
@@ -66,11 +66,14 @@ export interface Usage { providers: Record<string, ProviderUsage>; generatedAt:
|
|
| 66 |
export const getUsage = (): Promise<Usage> => fetch('/api/usage').then(json);
|
| 67 |
|
| 68 |
// ---- overview (meta) ----
|
|
|
|
| 69 |
export interface MetaDigest {
|
| 70 |
lastPromptText: string; lastPromptTs: number;
|
| 71 |
lastAssistantText: string; lastAssistantMd: string; lastAssistantTs: number;
|
| 72 |
sinceTurns: number; sinceToolCalls: number; sinceTools: Record<string, number>; sinceFiles: string[];
|
| 73 |
sinceTokens: number;
|
|
|
|
|
|
|
| 74 |
}
|
| 75 |
export interface MetaSession extends Session { digest: MetaDigest | null }
|
| 76 |
export const getMeta = (): Promise<{ sessions: MetaSession[]; generatedAt: string }> =>
|
|
|
|
| 66 |
export const getUsage = (): Promise<Usage> => fetch('/api/usage').then(json);
|
| 67 |
|
| 68 |
// ---- overview (meta) ----
|
| 69 |
+
export interface TurnEntry { answer: string; answerMd: string; ts: number; }
|
| 70 |
export interface MetaDigest {
|
| 71 |
lastPromptText: string; lastPromptTs: number;
|
| 72 |
lastAssistantText: string; lastAssistantMd: string; lastAssistantTs: number;
|
| 73 |
sinceTurns: number; sinceToolCalls: number; sinceTools: Record<string, number>; sinceFiles: string[];
|
| 74 |
sinceTokens: number;
|
| 75 |
+
running?: boolean; // task in flight (codex task_started/task_complete)
|
| 76 |
+
turnsLog?: TurnEntry[]; // newest-first history of completed exchanges
|
| 77 |
}
|
| 78 |
export interface MetaSession extends Session { digest: MetaDigest | null }
|
| 79 |
export const getMeta = (): Promise<{ sessions: MetaSession[]; generatedAt: string }> =>
|
|
@@ -32,19 +32,25 @@ function Card({ s, color, onOpen }: {
|
|
| 32 |
}) {
|
| 33 |
const d = s.digest;
|
| 34 |
const [draft, setDraft] = useState('');
|
| 35 |
-
const [live, setLive] = useState(false);
|
| 36 |
const [sending, setSending] = useState(false);
|
| 37 |
const [failed, setFailed] = useState(false);
|
| 38 |
const [sentAt, setSentAt] = useState(0);
|
| 39 |
const [expanded, setExpanded] = useState(false);
|
|
|
|
| 40 |
const inputRef = useRef<HTMLInputElement>(null);
|
| 41 |
-
useEffect(() => { if (live) inputRef.current?.focus(); }, [live]);
|
| 42 |
|
| 43 |
// After you send (or when the transcript shows a prompt newer than the last
|
| 44 |
// answer), the old answer is stale — a spinner takes its place.
|
| 45 |
const digestCaughtUp = !!d && d.lastPromptTs >= sentAt - 60_000;
|
| 46 |
if (sentAt && digestCaughtUp) setSentAt(0);
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
|
| 49 |
const send = async () => {
|
| 50 |
const text = draft.trim();
|
|
@@ -55,7 +61,8 @@ function Card({ s, color, onOpen }: {
|
|
| 55 |
await api.sendInput(s.id, text);
|
| 56 |
setDraft('');
|
| 57 |
setSentAt(Date.now());
|
| 58 |
-
|
|
|
|
| 59 |
} catch {
|
| 60 |
setFailed(true);
|
| 61 |
setTimeout(() => setFailed(false), 4000);
|
|
@@ -64,14 +71,18 @@ function Card({ s, color, onOpen }: {
|
|
| 64 |
};
|
| 65 |
|
| 66 |
const ago = fmtAgo(Math.max(d?.lastAssistantTs || 0, d?.lastPromptTs || 0) || Date.parse(s.createdAt) || 0);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
const metaBits: string[] = [];
|
| 68 |
if (d && (d.sinceTurns || d.sinceToolCalls)) {
|
| 69 |
metaBits.push(`${d.sinceTurns} turn${d.sinceTurns === 1 ? '' : 's'}`, `${d.sinceToolCalls} tool${d.sinceToolCalls === 1 ? '' : 's'}`);
|
| 70 |
if (d.sinceFiles.length) metaBits.push(d.sinceFiles.map(base).join(', '));
|
| 71 |
if (d.sinceTokens > 0) metaBits.push(`${fmtTok(d.sinceTokens)} tok`);
|
| 72 |
}
|
| 73 |
-
|
| 74 |
-
const ghostLabel = 'waiting for your input…';
|
| 75 |
|
| 76 |
return (
|
| 77 |
<div className="ov-card">
|
|
@@ -84,53 +95,72 @@ function Card({ s, color, onOpen }: {
|
|
| 84 |
<span className="ov-go">open ↗</span>
|
| 85 |
</div>
|
| 86 |
|
| 87 |
-
{
|
| 88 |
-
<div className="ov-prompt
|
| 89 |
) : (
|
| 90 |
-
<div className="ov-prompt ov-prompt-none
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
)}
|
| 92 |
-
{metaBits.length > 0 && <div className="ov-meta mono">{metaBits.join(' · ')}</div>}
|
| 93 |
|
| 94 |
-
{
|
| 95 |
-
<div className="ov-
|
| 96 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
<div className="ov-answer-wrap">
|
| 98 |
{expanded ? (
|
| 99 |
-
<div className="markdown ov-md" dangerouslySetInnerHTML={{ __html: renderMarkdown(
|
| 100 |
) : (
|
| 101 |
-
<div className="ov-answer">{
|
| 102 |
)}
|
| 103 |
<button className="ov-more" onClick={() => setExpanded((e) => !e)}>{expanded ? 'less' : 'more'}</button>
|
| 104 |
</div>
|
| 105 |
) : null}
|
| 106 |
|
| 107 |
-
|
| 108 |
-
<
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
</div>
|
| 131 |
-
) : (
|
| 132 |
-
<button className="ov-ghost attn" onClick={() => setLive(true)}>{ghostLabel}</button>
|
| 133 |
-
)}
|
| 134 |
{failed && <div className="ov-note">failed to reach the agent</div>}
|
| 135 |
</div>
|
| 136 |
);
|
|
|
|
| 32 |
}) {
|
| 33 |
const d = s.digest;
|
| 34 |
const [draft, setDraft] = useState('');
|
|
|
|
| 35 |
const [sending, setSending] = useState(false);
|
| 36 |
const [failed, setFailed] = useState(false);
|
| 37 |
const [sentAt, setSentAt] = useState(0);
|
| 38 |
const [expanded, setExpanded] = useState(false);
|
| 39 |
+
const [histIdx, setHistIdx] = useState(0); // 0 = live view, n = n-th exchange back
|
| 40 |
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
| 41 |
|
| 42 |
// After you send (or when the transcript shows a prompt newer than the last
|
| 43 |
// answer), the old answer is stale — a spinner takes its place.
|
| 44 |
const digestCaughtUp = !!d && d.lastPromptTs >= sentAt - 60_000;
|
| 45 |
if (sentAt && digestCaughtUp) setSentAt(0);
|
| 46 |
+
// Running: the agent's own task lifecycle when the transcript provides one
|
| 47 |
+
// (codex task_started/complete), else the terminal-derived state.
|
| 48 |
+
const running = !!d?.running || s.state === 'working';
|
| 49 |
+
const awaiting = (!!sentAt && !digestCaughtUp) || (!!d && !!d.lastPromptText && d.lastPromptTs > d.lastAssistantTs && running);
|
| 50 |
+
|
| 51 |
+
const hist = d?.turnsLog ?? [];
|
| 52 |
+
const idx = Math.min(histIdx, hist.length);
|
| 53 |
+
const entry = idx > 0 ? hist[idx - 1] : null;
|
| 54 |
|
| 55 |
const send = async () => {
|
| 56 |
const text = draft.trim();
|
|
|
|
| 61 |
await api.sendInput(s.id, text);
|
| 62 |
setDraft('');
|
| 63 |
setSentAt(Date.now());
|
| 64 |
+
setHistIdx(0);
|
| 65 |
+
inputRef.current?.blur();
|
| 66 |
} catch {
|
| 67 |
setFailed(true);
|
| 68 |
setTimeout(() => setFailed(false), 4000);
|
|
|
|
| 71 |
};
|
| 72 |
|
| 73 |
const ago = fmtAgo(Math.max(d?.lastAssistantTs || 0, d?.lastPromptTs || 0) || Date.parse(s.createdAt) || 0);
|
| 74 |
+
const promptText = d?.lastPromptText || '';
|
| 75 |
+
const answerText = entry ? entry.answer : d?.lastAssistantText || '';
|
| 76 |
+
const answerMd = entry ? entry.answerMd : d?.lastAssistantMd || '';
|
| 77 |
+
// Chronological position: hist is newest-first, live text is the newest turn.
|
| 78 |
+
const totalTurns = hist.length + (d?.lastAssistantText ? 1 : 0);
|
| 79 |
const metaBits: string[] = [];
|
| 80 |
if (d && (d.sinceTurns || d.sinceToolCalls)) {
|
| 81 |
metaBits.push(`${d.sinceTurns} turn${d.sinceTurns === 1 ? '' : 's'}`, `${d.sinceToolCalls} tool${d.sinceToolCalls === 1 ? '' : 's'}`);
|
| 82 |
if (d.sinceFiles.length) metaBits.push(d.sinceFiles.map(base).join(', '));
|
| 83 |
if (d.sinceTokens > 0) metaBits.push(`${fmtTok(d.sinceTokens)} tok`);
|
| 84 |
}
|
| 85 |
+
const showLiveProgress = !entry && (running || awaiting);
|
|
|
|
| 86 |
|
| 87 |
return (
|
| 88 |
<div className="ov-card">
|
|
|
|
| 95 |
<span className="ov-go">open ↗</span>
|
| 96 |
</div>
|
| 97 |
|
| 98 |
+
{promptText ? (
|
| 99 |
+
<div className="ov-prompt" title={promptText}>{promptText}</div>
|
| 100 |
) : (
|
| 101 |
+
<div className="ov-prompt ov-prompt-none">no prompt yet</div>
|
| 102 |
+
)}
|
| 103 |
+
{(metaBits.length > 0 || hist.length > 0) && (
|
| 104 |
+
<div className="ov-meta mono">
|
| 105 |
+
<span className="ov-meta-bits">{metaBits.join(' · ')}</span>
|
| 106 |
+
<span className="spacer" />
|
| 107 |
+
{hist.length > 0 && (
|
| 108 |
+
<span className="ov-nav">
|
| 109 |
+
{idx > 0 && <span className="ov-nav-pos">turn {totalTurns - idx}/{totalTurns}</span>}
|
| 110 |
+
<button
|
| 111 |
+
className="ov-nav-btn" title="Earlier turn" disabled={idx >= hist.length}
|
| 112 |
+
onClick={() => { setHistIdx(Math.min(idx + 1, hist.length)); setExpanded(false); }}
|
| 113 |
+
>↑</button>
|
| 114 |
+
<button
|
| 115 |
+
className="ov-nav-btn" title="Later turn" disabled={idx === 0}
|
| 116 |
+
onClick={() => { setHistIdx(Math.max(idx - 1, 0)); setExpanded(false); }}
|
| 117 |
+
>↓</button>
|
| 118 |
+
</span>
|
| 119 |
+
)}
|
| 120 |
+
</div>
|
| 121 |
)}
|
|
|
|
| 122 |
|
| 123 |
+
{showLiveProgress ? (
|
| 124 |
+
<div className="ov-answer-wrap">
|
| 125 |
+
{answerText && d && d.lastAssistantTs >= d.lastPromptTs && (
|
| 126 |
+
<div className="ov-answer ov-answer-dim">{answerText}</div>
|
| 127 |
+
)}
|
| 128 |
+
<div className="ov-busy mono">running</div>
|
| 129 |
+
</div>
|
| 130 |
+
) : answerText ? (
|
| 131 |
<div className="ov-answer-wrap">
|
| 132 |
{expanded ? (
|
| 133 |
+
<div className="markdown ov-md" dangerouslySetInnerHTML={{ __html: renderMarkdown(answerMd || answerText) }} />
|
| 134 |
) : (
|
| 135 |
+
<div className="ov-answer">{answerText}</div>
|
| 136 |
)}
|
| 137 |
<button className="ov-more" onClick={() => setExpanded((e) => !e)}>{expanded ? 'less' : 'more'}</button>
|
| 138 |
</div>
|
| 139 |
) : null}
|
| 140 |
|
| 141 |
+
<div className="ov-live">
|
| 142 |
+
<span className="ov-p mono">❯</span>
|
| 143 |
+
<input
|
| 144 |
+
ref={inputRef}
|
| 145 |
+
value={draft}
|
| 146 |
+
disabled={sending}
|
| 147 |
+
placeholder={sending ? 'sending…' : 'reply…'}
|
| 148 |
+
autoComplete="off"
|
| 149 |
+
autoCorrect="off"
|
| 150 |
+
autoCapitalize="off"
|
| 151 |
+
spellCheck={false}
|
| 152 |
+
enterKeyHint="send"
|
| 153 |
+
onChange={(e) => setDraft(e.target.value)}
|
| 154 |
+
// iOS doesn't resize the layout for the keyboard — scroll the
|
| 155 |
+
// input into view once the keyboard has animated in.
|
| 156 |
+
onFocus={(e) => { const el = e.currentTarget; setTimeout(() => el.scrollIntoView({ block: 'center', behavior: 'smooth' }), 300); }}
|
| 157 |
+
onKeyDown={(e) => {
|
| 158 |
+
if (e.key === 'Enter') send();
|
| 159 |
+
if (e.key === 'Escape') { setDraft(''); inputRef.current?.blur(); }
|
| 160 |
+
}}
|
| 161 |
+
/>
|
| 162 |
+
{draft.trim() && <span className="ov-hint">↵ send</span>}
|
| 163 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
{failed && <div className="ov-note">failed to reach the agent</div>}
|
| 165 |
</div>
|
| 166 |
);
|
|
@@ -224,16 +224,26 @@ body {
|
|
| 224 |
.ov-go { color: var(--muted); font-size: 12px; opacity: 0; transition: opacity 0.12s ease-out; flex: none; }
|
| 225 |
.ov-card:hover .ov-go, .ov-id:focus-visible .ov-go { opacity: 1; }
|
| 226 |
|
| 227 |
-
.ov-prompt { font-size:
|
| 228 |
/* nothing between prompt and reply → one hairline, not two */
|
| 229 |
-
.ov-prompt + .ov-
|
| 230 |
-
.ov-prompt::before { content: '❯ '; color: var(--accent); font-weight: 700; }
|
| 231 |
-
.ov-prompt-none { font-style: italic; }
|
| 232 |
.ov-prompt-none::before { color: var(--border-strong); }
|
| 233 |
-
.ov-meta { font-size: 11px; color: var(--muted); white-space: nowrap; overflow: hidden;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
|
| 235 |
.ov-answer-wrap { min-width: 0; }
|
| 236 |
.ov-answer { font-size: 13px; line-height: 1.55; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; }
|
|
|
|
|
|
|
| 237 |
.ov-more { background: none; border: none; padding: 3px 0 0; font: inherit; font-size: 11px; color: var(--muted); cursor: pointer; display: block; }
|
| 238 |
.ov-more:hover { color: var(--accent); }
|
| 239 |
/* expanded answer: flows in the card — no box-in-box, slightly smaller type */
|
|
@@ -248,17 +258,11 @@ body {
|
|
| 248 |
50% { content: '⠼'; } 62.5% { content: '⠴'; } 75% { content: '⠦'; } 87.5% { content: '⠧'; }
|
| 249 |
}
|
| 250 |
|
| 251 |
-
/* reply: a
|
| 252 |
-
.ov-
|
| 253 |
-
.ov-
|
| 254 |
-
.ov-ghost:hover { color: var(--text); }
|
| 255 |
-
/* waiting: full-accent arrow, accent-tinted text — matches the hollow dot */
|
| 256 |
-
.ov-ghost.attn { color: color-mix(in srgb, var(--accent) 45%, var(--muted)); }
|
| 257 |
-
.ov-ghost.attn::before { color: var(--accent); }
|
| 258 |
-
.ov-ghost.attn:hover { color: var(--text); }
|
| 259 |
-
.ov-live { display: flex; gap: 8px; align-items: center; border-top: 1px solid var(--border); padding-top: 7px; }
|
| 260 |
-
.ov-live .ov-p { color: var(--accent); font-weight: 700; }
|
| 261 |
.ov-live input { flex: 1; min-width: 0; border: none; background: none; font: inherit; font-size: 13px; color: var(--text); outline: none; padding: 2px 0; }
|
|
|
|
| 262 |
.ov-hint { font-size: 10.5px; color: var(--muted); flex: none; }
|
| 263 |
.ov-note { font-size: 11px; color: var(--danger); }
|
| 264 |
|
|
@@ -721,8 +725,11 @@ a.btn-ghost { text-decoration: none; }
|
|
| 721 |
.row .age { display: none; } /* actions are always visible on touch — no room */
|
| 722 |
.g-add { opacity: 1; }
|
| 723 |
.caret { width: 22px; font-size: 15px; }
|
| 724 |
-
/* 16px inputs stop iOS zoom-on-focus
|
| 725 |
-
|
|
|
|
|
|
|
|
|
|
| 726 |
.ov-headrow { flex-wrap: wrap; }
|
| 727 |
/* install page: the decorative sidebar makes no sense on a phone */
|
| 728 |
.locked-app .mock-side { display: none; }
|
|
|
|
| 224 |
.ov-go { color: var(--muted); font-size: 12px; opacity: 0; transition: opacity 0.12s ease-out; flex: none; }
|
| 225 |
.ov-card:hover .ov-go, .ov-id:focus-visible .ov-go { opacity: 1; }
|
| 226 |
|
| 227 |
+
.ov-prompt { font-size: 13px; font-weight: 550; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; padding-bottom: 8px; border-bottom: 1px solid var(--border); }
|
| 228 |
/* nothing between prompt and reply → one hairline, not two */
|
| 229 |
+
.ov-prompt + .ov-live { border-top: none; }
|
| 230 |
+
.ov-prompt::before { content: '❯ '; font-family: var(--font-mono); color: var(--accent); font-weight: 700; }
|
| 231 |
+
.ov-prompt-none { font-style: italic; font-weight: 400; color: var(--muted); }
|
| 232 |
.ov-prompt-none::before { color: var(--border-strong); }
|
| 233 |
+
.ov-meta { font-size: 11px; color: var(--muted); white-space: nowrap; overflow: hidden; margin-top: -2px; display: flex; align-items: center; gap: 6px; }
|
| 234 |
+
.ov-meta .spacer { flex: 1; }
|
| 235 |
+
.ov-meta-bits { overflow: hidden; text-overflow: ellipsis; }
|
| 236 |
+
/* turn history: step back through earlier exchanges */
|
| 237 |
+
.ov-nav { display: inline-flex; align-items: center; gap: 3px; flex: none; }
|
| 238 |
+
.ov-nav-pos { color: var(--accent); margin-right: 3px; }
|
| 239 |
+
.ov-nav-btn { background: none; border: 1px solid var(--border); border-radius: 5px; width: 20px; height: 17px; padding: 0; font: inherit; font-size: 10.5px; line-height: 1; color: var(--muted); cursor: pointer; }
|
| 240 |
+
.ov-nav-btn:hover:not(:disabled) { color: var(--text); border-color: var(--border-strong); }
|
| 241 |
+
.ov-nav-btn:disabled { opacity: 0.35; cursor: default; }
|
| 242 |
|
| 243 |
.ov-answer-wrap { min-width: 0; }
|
| 244 |
.ov-answer { font-size: 13px; line-height: 1.55; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; }
|
| 245 |
+
/* live progress while running: present but clearly not the final word */
|
| 246 |
+
.ov-answer-dim { color: var(--muted); margin-bottom: 5px; -webkit-line-clamp: 2; }
|
| 247 |
.ov-more { background: none; border: none; padding: 3px 0 0; font: inherit; font-size: 11px; color: var(--muted); cursor: pointer; display: block; }
|
| 248 |
.ov-more:hover { color: var(--accent); }
|
| 249 |
/* expanded answer: flows in the card — no box-in-box, slightly smaller type */
|
|
|
|
| 258 |
50% { content: '⠼'; } 62.5% { content: '⠴'; } 75% { content: '⠦'; } 87.5% { content: '⠧'; }
|
| 259 |
}
|
| 260 |
|
| 261 |
+
/* reply: a quiet, always-there input line — its ❯ mirrors the prompt's */
|
| 262 |
+
.ov-live { display: flex; gap: 8px; align-items: center; border-top: 1px solid var(--border); padding-top: 7px; font-size: 13px; }
|
| 263 |
+
.ov-live .ov-p { color: var(--accent); font-weight: 700; font-size: 13px; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
.ov-live input { flex: 1; min-width: 0; border: none; background: none; font: inherit; font-size: 13px; color: var(--text); outline: none; padding: 2px 0; }
|
| 265 |
+
.ov-live input::placeholder { color: var(--muted); opacity: 0.7; }
|
| 266 |
.ov-hint { font-size: 10.5px; color: var(--muted); flex: none; }
|
| 267 |
.ov-note { font-size: 11px; color: var(--danger); }
|
| 268 |
|
|
|
|
| 725 |
.row .age { display: none; } /* actions are always visible on touch — no room */
|
| 726 |
.g-add { opacity: 1; }
|
| 727 |
.caret { width: 22px; font-size: 15px; }
|
| 728 |
+
/* 16px inputs stop iOS zoom-on-focus — touch devices only, so a narrow
|
| 729 |
+
DESKTOP window keeps the reply line at card size */
|
| 730 |
+
@media (pointer: coarse) {
|
| 731 |
+
.widget input, .widget select, .row .rename, .secret-desc, .fp-input, .pane-head .ph-title-input, .ov-live input { font-size: 16px; }
|
| 732 |
+
}
|
| 733 |
.ov-headrow { flex-wrap: wrap; }
|
| 734 |
/* install page: the decorative sidebar makes no sense on a phone */
|
| 735 |
.locked-app .mock-side { display: none; }
|