lvwerra HF Staff Claude Fable 5 commited on
Commit
8e5d9a0
·
1 Parent(s): 436d14d

Overview: true running state, turn history, richer cards

Browse files

The 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 CHANGED
@@ -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
- d.lastAssistantText = clip(text);
 
 
 
 
 
 
 
 
 
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) {
web/src/App.tsx CHANGED
@@ -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' ? 'your turn' : f}
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>
web/src/api.ts CHANGED
@@ -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 }> =>
web/src/components/Overview.tsx CHANGED
@@ -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
- const awaiting = (sentAt && !digestCaughtUp) || (!!d && !!d.lastPromptText && d.lastPromptTs > d.lastAssistantTs);
 
 
 
 
 
 
 
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
- setLive(false);
 
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
- // One reply line for every state — same words, same accent voice.
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
- {d && d.lastPromptText ? (
88
- <div className="ov-prompt mono" title={d.lastPromptText}>{d.lastPromptText}</div>
89
  ) : (
90
- <div className="ov-prompt ov-prompt-none mono">no prompt yet</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  )}
92
- {metaBits.length > 0 && <div className="ov-meta mono">{metaBits.join(' · ')}</div>}
93
 
94
- {awaiting ? (
95
- <div className="ov-busy mono">working</div>
96
- ) : d && d.lastAssistantText ? (
 
 
 
 
 
97
  <div className="ov-answer-wrap">
98
  {expanded ? (
99
- <div className="markdown ov-md" dangerouslySetInnerHTML={{ __html: renderMarkdown(d.lastAssistantMd || d.lastAssistantText) }} />
100
  ) : (
101
- <div className="ov-answer">{d.lastAssistantText}</div>
102
  )}
103
  <button className="ov-more" onClick={() => setExpanded((e) => !e)}>{expanded ? 'less' : 'more'}</button>
104
  </div>
105
  ) : null}
106
 
107
- {live ? (
108
- <div className="ov-live">
109
- <span className="ov-p mono">❯</span>
110
- <input
111
- ref={inputRef}
112
- value={draft}
113
- disabled={sending}
114
- autoComplete="off"
115
- autoCorrect="off"
116
- autoCapitalize="off"
117
- spellCheck={false}
118
- enterKeyHint="send"
119
- onChange={(e) => setDraft(e.target.value)}
120
- // iOS doesn't resize the layout for the keyboard — scroll the
121
- // input into view once the keyboard has animated in.
122
- onFocus={(e) => { const el = e.currentTarget; setTimeout(() => el.scrollIntoView({ block: 'center', behavior: 'smooth' }), 300); }}
123
- onBlur={() => { if (!draft.trim()) setLive(false); }}
124
- onKeyDown={(e) => {
125
- if (e.key === 'Enter') send();
126
- if (e.key === 'Escape') { setDraft(''); setLive(false); }
127
- }}
128
- />
129
- <span className="ov-hint">{sending ? 'sending…' : '↵ send'}</span>
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
  );
web/src/styles.css CHANGED
@@ -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: 12.5px; color: var(--muted); 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-ghost, .ov-prompt + .ov-live { border-top: none; }
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; text-overflow: ellipsis; margin-top: -2px; }
 
 
 
 
 
 
 
 
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 ghost line that becomes a bareinput */
252
- .ov-ghost { border: none; background: none; padding: 7px 0 2px; font: inherit; font-size: 12.5px; color: var(--muted); text-align: left; cursor: text; border-top: 1px solid var(--border); width: 100%; }
253
- .ov-ghost::before { content: '❯ '; font-family: var(--font-mono); color: var(--border-strong); font-weight: 700; }
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
- .widget input, .widget select, .row .rename, .secret-desc, .fp-input, .pane-head .ph-title-input, .ov-live input { font-size: 16px; }
 
 
 
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 itsmirrors 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; }