From e45fde51e9e63b1cbda4a25a15f801a2cedc5e27 Mon Sep 17 00:00:00 2001 From: Junmo Kim Date: Sat, 11 Jul 2026 11:39:36 +0900 Subject: [PATCH 01/28] fix(claude): stop 1M/200k context-window flicker in the status bar (#992) * refactor(claude): thread session's selected model into SDKToLogConverter Adds an optional selectedModel field to the converter's context, wired from session.getModel() in the launcher, so a later commit can seed the turn-1 contextWindow estimate for presets whose system/init model arrives without the "[1m]" suffix. No behavior change yet. * fix(claude): key contextWindow cache by model to stop 1M/200k flicker The remote launcher re-emits system/init on every turn for the same converter instance. Its init-time estimate only checked whether the model string ended in "[1m]", but current claude CLI versions strip that suffix from system/init for some 1M presets (fable[1m] arrives as "claude-fable-5"), so the estimate guessed 200k for them. The one authoritative value is result.modelUsage[].contextWindow, which arrives after the heuristic has already injected 200k into that turn's assistant message and then gets clobbered back to 200k by the very next turn's init - producing the observed 200k<->1M oscillation in the web status bar. Cache the authoritative contextWindow per model id instead of a single session-wide number, and only let system/init seed a heuristic guess for a model that has no cached value yet, so a same-model re-init no longer downgrades an already-learned value. Two observed facts about the CLI's model ids drive the design: system/init.model and the result.modelUsage keys always agree with each other within a session (both bare for plain/fable[1m], both suffixed for opus[1m]/sonnet[1m]), while each per-turn assistant message reports its model bare and thus can't distinguish a 200k plain preset from its 1M "[1m]" variant on tiers where they share a base id. So the cache is keyed on the raw id (init/result agree, no normalization) and assistant injection looks the value up via resolvedModel (the last init id) rather than the lossy message.model. Keying raw keeps a plain preset and its [1m] variant on distinct entries; looking up via resolvedModel also means sidechain (Task subagent) messages carry the main session window rather than the subagent's own, since the web status bar picks the most recent usage message without filtering sidechains and would otherwise flicker to the subagent's smaller window while it runs. For presets whose init model arrives bare even though they are 1M (fable[1m]), the originally-selected preset - which preserves the "[1m]" suffix - seeds the turn-1 estimate, kept live across mid-session model switches via updateSelectedModel() (called from the launcher on every turn) so it never goes stale. * fix(web): recognize [1m] suffix on full Claude model ids in budget fallback getContextBudgetTokens already special-cased "[1m]" for short preset values (e.g. "opus[1m]") but fell through to the default 200k budget for full model ids (e.g. "claude-opus-4-8[1m]"), which is what the CLI now reports once context_window isn't available and this fallback is consulted. Check the suffix on that branch too so it stays a correct last-resort even without a session-provided context_window. * refactor(web): merge duplicate Claude context-budget branches isClaudeModelPreset(trimmedModel) and the startsWith('claude-') branch below it had become byte-for-byte identical bodies after the [1m] suffix check was added to both. Merge them into one condition; no behavior change. * fix(claude): distinguish fable from fable[1m] when the CLI reports both bare The per-model contextWindow cache keyed on the raw system/init model id, on the assumption that a 1M preset and its plain form always land on distinct ids. That holds for opus[1m]/sonnet[1m] (the CLI reports the "[1m]" suffix on their init and result ids) but not for fable: the CLI reports both "fable" and "fable[1m]" with the same bare id "claude-fable-5". So the "seed only if not already cached" guard would skip re-seeding when switching fable[1m] -> fable, leaving the stale 1M in place until fable's own result arrived - the same switch flicker this change set out to remove, just for fable specifically. Fold the selected preset's "[1m]" back into the cache key (computeContextWindowKey): when the init model arrives bare but the session selected an "[1m]" preset, key the entry as "[1m]" so the 1M and plain variants stay distinct; ids the CLI already suffixed are left as-is. Seeding, lookups, and the current-model result entry all use this resolved key. Subagent result entries (e.g. haiku) keep their own raw id so the session's "[1m]" is never folded onto a model that isn't the selected one. --- cli/src/claude/claudeRemoteLauncher.ts | 10 +- .../claude/utils/sdkToLogConverter.test.ts | 448 +++++++++++++++++- cli/src/claude/utils/sdkToLogConverter.ts | 127 ++++- web/src/chat/modelConfig.test.ts | 4 + web/src/chat/modelConfig.ts | 5 +- 5 files changed, 574 insertions(+), 20 deletions(-) diff --git a/cli/src/claude/claudeRemoteLauncher.ts b/cli/src/claude/claudeRemoteLauncher.ts index f3ca0eb702..fb009dfc6b 100644 --- a/cli/src/claude/claudeRemoteLauncher.ts +++ b/cli/src/claude/claudeRemoteLauncher.ts @@ -105,7 +105,8 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase { const sdkToLogConverter = new SDKToLogConverter({ sessionId: session.sessionId || 'unknown', cwd: session.path, - version: process.env.npm_package_version + version: process.env.npm_package_version, + selectedModel: session.getModel() }, permissionHandler.getResponses()); const handleSessionFound = (sessionId: string) => { @@ -318,6 +319,12 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase { let p = pending; pending = null; permissionHandler.handleModeChange(p.mode.permissionMode); + // Re-resolve the selected-model seed hint for every turn, not + // just the first: a single claudeRemote() call keeps accepting + // new turns (potentially with a different model, e.g. after a + // mid-session model switch), so a construction-time snapshot + // would go stale. See SDKToLogConverter.updateSelectedModel. + sdkToLogConverter.updateSelectedModel(p.mode.model ?? null); return p; } @@ -332,6 +339,7 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase { modeHash = msg.hash; mode = msg.mode; permissionHandler.handleModeChange(mode.permissionMode); + sdkToLogConverter.updateSelectedModel(mode.model ?? null); return { message: msg.message, mode: msg.mode diff --git a/cli/src/claude/utils/sdkToLogConverter.test.ts b/cli/src/claude/utils/sdkToLogConverter.test.ts index 5ea303276f..8c4e1cbe1f 100644 --- a/cli/src/claude/utils/sdkToLogConverter.test.ts +++ b/cli/src/claude/utils/sdkToLogConverter.test.ts @@ -236,7 +236,7 @@ describe('SDKToLogConverter', () => { type: 'system', subtype: 'init', session_id: 'session-2', - model: 'claude-sonnet-4-6' + model: 'claude-sonnet-5' } converter.convert(initMsg) @@ -308,6 +308,452 @@ describe('SDKToLogConverter', () => { const log = converter.convert(makeAssistantMessage()) as any expect(log?.message?.usage?.context_window).toBeUndefined() }) + + it('does not downgrade to the 200k heuristic on a same-model re-init after result refined it (sticky, per-model)', () => { + // Turn 1: new-CLI-style init with no [1m] suffix (the actual regression trigger). + converter.convert({ + type: 'system', + subtype: 'init', + session_id: 'session-5', + model: 'claude-opus-4-8' + } as SDKSystemMessage) + + const turn1 = converter.convert({ + type: 'assistant', + message: { + role: 'assistant', + model: 'claude-opus-4-8', + content: [{ type: 'text', text: 'hi' }], + usage: { input_tokens: 10, output_tokens: 20 } + } + } as any) as any + expect(turn1?.message?.usage?.context_window).toBe(200_000) + + // Result arrives with the authoritative window for this model. + converter.convert({ + type: 'result', + subtype: 'success', + num_turns: 1, + total_cost_usd: 0, + duration_ms: 1, + duration_api_ms: 1, + is_error: false, + session_id: 'session-5', + modelUsage: { + 'claude-opus-4-8': { contextWindow: 1_000_000 } + } + } as SDKResultMessage) + + // Turn 2: the CLI re-emits system/init for the *same* model (this happens on + // every turn in the remote launcher's while-loop). The stale 200k heuristic + // must NOT clobber the value we already learned from result.modelUsage. + converter.convert({ + type: 'system', + subtype: 'init', + session_id: 'session-5', + model: 'claude-opus-4-8' + } as SDKSystemMessage) + + const turn2 = converter.convert({ + type: 'assistant', + message: { + role: 'assistant', + model: 'claude-opus-4-8', + content: [{ type: 'text', text: 'hi again' }], + usage: { input_tokens: 10, output_tokens: 20 } + } + } as any) as any + expect(turn2?.message?.usage?.context_window).toBe(1_000_000) + }) + + it('gives a switched-to model its own seed instead of inheriting the previous model\'s cached window (per-model cache, not globally sticky)', () => { + // Learn opus's real 1M window first. + converter.convert({ + type: 'system', + subtype: 'init', + session_id: 'session-6', + model: 'claude-opus-4-8' + } as SDKSystemMessage) + converter.convert({ + type: 'result', + subtype: 'success', + num_turns: 1, + total_cost_usd: 0, + duration_ms: 1, + duration_api_ms: 1, + is_error: false, + session_id: 'session-6', + modelUsage: { + 'claude-opus-4-8': { contextWindow: 1_000_000 } + } + } as SDKResultMessage) + + // User switches to a different model mid-session. It has no cached value and no + // 1M seed signal here (bare init, no selectedModel), so it gets its own + // conservative 200k seed rather than inheriting opus's cached 1M. (Its real + // window would arrive with its own first result; this asserts cache isolation, + // i.e. the value is keyed per model rather than a single global sticky number.) + converter.convert({ + type: 'system', + subtype: 'init', + session_id: 'session-6', + model: 'claude-sonnet-5' + } as SDKSystemMessage) + + const afterSwitch = converter.convert({ + type: 'assistant', + message: { + role: 'assistant', + model: 'claude-sonnet-5', + content: [{ type: 'text', text: 'hi' }], + usage: { input_tokens: 10, output_tokens: 20 } + } + } as any) as any + + // The switched-to model gets its own seed, not opus's cached 1M value. + expect(afterSwitch?.message?.usage?.context_window).toBe(200_000) + }) + + it('seeds a 1M turn-1 estimate from selectedModel for an [1m] preset whose init model arrives bare (fable[1m] shape)', () => { + // Real claude 2.1.200 shape for "fable[1m]": init.model and result keys are + // BARE ("claude-fable-5", no suffix), unlike opus[1m]/sonnet[1m] which keep it. + // So systemMsg.model.endsWith('[1m]') is false here — the selectedModel hint is + // the only thing that lets turn 1 seed 1M instead of flashing 200k until the + // first result lands. This is why the selectedModel seed is load-bearing. + const seededConverter = new SDKToLogConverter({ + ...context, + selectedModel: 'fable[1m]' + } as any) + + seededConverter.convert({ + type: 'system', + subtype: 'init', + session_id: 'session-7', + model: 'claude-fable-5' + } as SDKSystemMessage) + + const turn1 = seededConverter.convert({ + type: 'assistant', + message: { + role: 'assistant', + model: 'claude-fable-5', + content: [{ type: 'text', text: 'hi' }], + usage: { input_tokens: 10, output_tokens: 20 } + } + } as any) as any + + expect(turn1?.message?.usage?.context_window).toBe(1_000_000) + }) + + it('seeds a conservative 200k when neither the bare init model nor a selectedModel hint indicates 1M, until result confirms it', () => { + // Bare init model + no selectedModel hint: neither signal says "1M", so we can't + // know the real window on turn 1 and seed 200k conservatively rather than + // guessing high. The authoritative value arrives with the first result. (A real + // 1M account whose init keeps the suffix, e.g. "claude-opus-4-8[1m]", or that + // carries a selectedModel hint, seeds 1M immediately instead — covered above.) + const defaultConverter = new SDKToLogConverter({ ...context } as any) + + defaultConverter.convert({ + type: 'system', + subtype: 'init', + session_id: 'session-8', + model: 'claude-fable-5' + } as SDKSystemMessage) + + const turn1 = defaultConverter.convert({ + type: 'assistant', + message: { + role: 'assistant', + model: 'claude-fable-5', + content: [{ type: 'text', text: 'hi' }], + usage: { input_tokens: 10, output_tokens: 20 } + } + } as any) as any + + expect(turn1?.message?.usage?.context_window).toBe(200_000) + }) + + it('seeds correctly after a live mid-session switch TO an [1m] preset (updateSelectedModel), not the stale construction-time value', () => { + // Session started on Default (no selectedModel) -- as HAPI's remote launcher + // does for every turn via updateSelectedModel(), not just turn 1. + const liveConverter = new SDKToLogConverter({ ...context } as any) + + liveConverter.convert({ + type: 'system', + subtype: 'init', + session_id: 'session-9', + model: 'claude-sonnet-5' + } as SDKSystemMessage) + const beforeSwitch = liveConverter.convert({ + type: 'assistant', + message: { + role: 'assistant', + model: 'claude-sonnet-5', + content: [{ type: 'text', text: 'hi' }], + usage: { input_tokens: 10, output_tokens: 20 } + } + } as any) as any + expect(beforeSwitch?.message?.usage?.context_window).toBe(200_000) + + // User switches to an explicit 1M preset (fable[1m], whose init model arrives + // bare so the selectedModel hint is what carries the 1M signal). Without a live + // update, the converter would still be seeding from the session-start snapshot + // (none) and would under-seed 200k for this new model's first turn too. + liveConverter.updateSelectedModel('fable[1m]') + liveConverter.convert({ + type: 'system', + subtype: 'init', + session_id: 'session-9', + model: 'claude-fable-5' + } as SDKSystemMessage) + const afterSwitch = liveConverter.convert({ + type: 'assistant', + message: { + role: 'assistant', + model: 'claude-fable-5', + content: [{ type: 'text', text: 'hi' }], + usage: { input_tokens: 10, output_tokens: 20 } + } + } as any) as any + expect(afterSwitch?.message?.usage?.context_window).toBe(1_000_000) + }) + + it('does not over-seed after a live mid-session switch AWAY FROM an [1m] preset (updateSelectedModel)', () => { + // Session started on an explicit 1M preset (fable[1m]; its init model arrives + // bare, so the selectedModel hint carries the 1M signal on turn 1). + const liveConverter = new SDKToLogConverter({ + ...context, + selectedModel: 'fable[1m]' + } as any) + + liveConverter.convert({ + type: 'system', + subtype: 'init', + session_id: 'session-10', + model: 'claude-fable-5' + } as SDKSystemMessage) + const beforeSwitch = liveConverter.convert({ + type: 'assistant', + message: { + role: 'assistant', + model: 'claude-fable-5', + content: [{ type: 'text', text: 'hi' }], + usage: { input_tokens: 10, output_tokens: 20 } + } + } as any) as any + expect(beforeSwitch?.message?.usage?.context_window).toBe(1_000_000) + + // User switches away to a model with no 1M signal (no "[1m]" on the updated + // selectedModel and none on the bare init model). Without a live update, the + // converter would still be seeding from the stale "fable[1m]" snapshot and + // would over-seed 1,000,000 for the newly-selected model's first turn. + liveConverter.updateSelectedModel('sonnet') + liveConverter.convert({ + type: 'system', + subtype: 'init', + session_id: 'session-10', + model: 'claude-sonnet-5' + } as SDKSystemMessage) + const afterSwitch = liveConverter.convert({ + type: 'assistant', + message: { + role: 'assistant', + model: 'claude-sonnet-5', + content: [{ type: 'text', text: 'hi' }], + usage: { input_tokens: 10, output_tokens: 20 } + } + } as any) as any + expect(afterSwitch?.message?.usage?.context_window).toBe(200_000) + }) + + it('injects 1M for an explicit [1m] preset whose assistant messages report a bare model id (real opus[1m] shape)', () => { + // Real claude 2.1.200 shape for an explicit "opus[1m]" session: system/init and + // result.modelUsage both use the suffixed id "claude-opus-4-8[1m]", while each + // assistant message reports the bare "claude-opus-4-8". Because init and result + // agree, the cache stores 1M under the suffixed key and the assistant lookup — + // which goes through the resolved cache key (= the suffixed init id here), not + // the bare message.model — finds it. (A lookup keyed on the bare message.model + // would miss.) + const seededConverter = new SDKToLogConverter({ + ...context, + selectedModel: 'opus[1m]' + } as any) + + seededConverter.convert({ + type: 'system', + subtype: 'init', + session_id: 'session-11', + model: 'claude-opus-4-8[1m]' + } as SDKSystemMessage) + + // result reports the authoritative 1M under the SUFFIXED key... + seededConverter.convert({ + type: 'result', + subtype: 'success', + num_turns: 1, + total_cost_usd: 0, + duration_ms: 1, + duration_api_ms: 1, + is_error: false, + session_id: 'session-11', + modelUsage: { + 'claude-opus-4-8[1m]': { contextWindow: 1_000_000 } + } + } as SDKResultMessage) + + // ...but the assistant message reports the BARE model id. + const assistant = seededConverter.convert({ + type: 'assistant', + message: { + role: 'assistant', + model: 'claude-opus-4-8', + content: [{ type: 'text', text: 'hi' }], + usage: { input_tokens: 10, output_tokens: 20 } + } + } as any) as any + + expect(assistant?.message?.usage?.context_window).toBe(1_000_000) + }) + + it('injects the MAIN session window into a sidechain (subagent) message, not the subagent model\'s own window', () => { + // Main session is opus[1m] (1M). A Task subagent runs on haiku (200k). Because + // the web status bar picks the most recent usage message without filtering + // sidechains, the subagent's assistant message must carry the main 1M window, + // or the footer denominator would visibly drop to 200k while the subagent runs. + // The subagent emits no system/init of its own, so the resolved cache key stays + // on the main model — and since lookups always go through that key, the sidechain + // message inherits the main window automatically. (The subagent's own 200k is + // still cached under its own id, but it is never the resolved lookup key here.) + const liveConverter = new SDKToLogConverter({ + ...context, + selectedModel: 'opus[1m]' + } as any) + + liveConverter.convert({ + type: 'system', + subtype: 'init', + session_id: 'session-12', + model: 'claude-opus-4-8[1m]' + } as SDKSystemMessage) + liveConverter.convert({ + type: 'result', + subtype: 'success', + num_turns: 1, + total_cost_usd: 0, + duration_ms: 1, + duration_api_ms: 1, + is_error: false, + session_id: 'session-12', + modelUsage: { + 'claude-opus-4-8[1m]': { contextWindow: 1_000_000 }, + 'claude-haiku-4-5-20251001': { contextWindow: 200_000 } + } + } as SDKResultMessage) + + // Sidechain assistant message from the haiku subagent (carries parent_tool_use_id). + const sidechain = liveConverter.convert({ + type: 'assistant', + parent_tool_use_id: 'toolu_task_1', + message: { + role: 'assistant', + model: 'claude-haiku-4-5-20251001', + content: [{ type: 'text', text: 'subagent reply' }], + usage: { input_tokens: 10, output_tokens: 20 } + } + } as any) as any + + expect(sidechain?.message?.usage?.context_window).toBe(1_000_000) + }) + + it('keeps plain vs [1m] variants of the same base model on distinct cache keys (multi-tier: no collision)', () => { + // On some tiers plain "sonnet" is 200k while "sonnet[1m]" is 1M. For sonnet the + // CLI already reports the "[1m]" on system/init.model and the result key, so the + // two variants land on DISTINCT cache keys on their own (only the per-turn + // assistant message.model is bare/lossy, which is why lookups go through the + // resolved cache key, not message.model). fable is the case where the CLI does + // NOT suffix the id and the key has to be folded — covered by the next test. + const conv = new SDKToLogConverter({ ...context, selectedModel: 'sonnet[1m]' } as any) + + // Turn 1 on sonnet[1m]: learns 1M under the suffixed key. + conv.convert({ type: 'system', subtype: 'init', session_id: 's', model: 'claude-sonnet-5[1m]' } as SDKSystemMessage) + conv.convert({ + type: 'result', subtype: 'success', num_turns: 1, total_cost_usd: 0, + duration_ms: 1, duration_api_ms: 1, is_error: false, session_id: 's', + modelUsage: { 'claude-sonnet-5[1m]': { contextWindow: 1_000_000 } } + } as SDKResultMessage) + const t1 = conv.convert({ + type: 'assistant', + message: { role: 'assistant', model: 'claude-sonnet-5', content: [{ type: 'text', text: 'a' }], usage: { input_tokens: 10, output_tokens: 20 } } + } as any) as any + expect(t1?.message?.usage?.context_window).toBe(1_000_000) + + // Switch to plain sonnet (200k on this tier): learns 200k under the bare key. + conv.updateSelectedModel('sonnet') + conv.convert({ type: 'system', subtype: 'init', session_id: 's', model: 'claude-sonnet-5' } as SDKSystemMessage) + conv.convert({ + type: 'result', subtype: 'success', num_turns: 1, total_cost_usd: 0, + duration_ms: 1, duration_api_ms: 1, is_error: false, session_id: 's', + modelUsage: { 'claude-sonnet-5': { contextWindow: 200_000 } } + } as SDKResultMessage) + const t2 = conv.convert({ + type: 'assistant', + message: { role: 'assistant', model: 'claude-sonnet-5', content: [{ type: 'text', text: 'b' }], usage: { input_tokens: 10, output_tokens: 20 } } + } as any) as any + expect(t2?.message?.usage?.context_window).toBe(200_000) + + // Switch back to sonnet[1m], turn-1 before its result re-arrives: must still read + // 1M from the suffixed key, NOT the 200k that plain sonnet just cached under the + // bare key (that cross-contamination is exactly what suffix-stripping would cause). + conv.updateSelectedModel('sonnet[1m]') + conv.convert({ type: 'system', subtype: 'init', session_id: 's', model: 'claude-sonnet-5[1m]' } as SDKSystemMessage) + const t3 = conv.convert({ + type: 'assistant', + message: { role: 'assistant', model: 'claude-sonnet-5', content: [{ type: 'text', text: 'c' }], usage: { input_tokens: 10, output_tokens: 20 } } + } as any) as any + expect(t3?.message?.usage?.context_window).toBe(1_000_000) + }) + + it('distinguishes fable vs fable[1m] even though the CLI reports both with the bare id', () => { + // Unlike opus[1m]/sonnet[1m], the CLI reports BOTH "fable" and "fable[1m]" with + // the bare id "claude-fable-5" on system/init and in result.modelUsage. A cache + // keyed on that raw id alone can't tell the two apart, so switching fable[1m] + // (1M) -> fable (200k) would keep showing the stale 1M until fable's result + // lands. Folding the selectedModel's "[1m]" into the cache key keeps them + // distinct. selectedModel is the ONLY turn-1 signal that separates them here. + const conv = new SDKToLogConverter({ ...context, selectedModel: 'fable[1m]' } as any) + + // fable[1m]: seeds 1M from selectedModel, result confirms 1M. + conv.convert({ type: 'system', subtype: 'init', session_id: 's', model: 'claude-fable-5' } as SDKSystemMessage) + conv.convert({ + type: 'result', subtype: 'success', num_turns: 1, total_cost_usd: 0, + duration_ms: 1, duration_api_ms: 1, is_error: false, session_id: 's', + modelUsage: { 'claude-fable-5': { contextWindow: 1_000_000 } } + } as SDKResultMessage) + const t1 = conv.convert({ + type: 'assistant', + message: { role: 'assistant', model: 'claude-fable-5', content: [{ type: 'text', text: 'a' }], usage: { input_tokens: 10, output_tokens: 20 } } + } as any) as any + expect(t1?.message?.usage?.context_window).toBe(1_000_000) + + // Switch to plain fable (200k): must re-seed 200k, NOT keep the stale 1M. + conv.updateSelectedModel('fable') + conv.convert({ type: 'system', subtype: 'init', session_id: 's', model: 'claude-fable-5' } as SDKSystemMessage) + const t2 = conv.convert({ + type: 'assistant', + message: { role: 'assistant', model: 'claude-fable-5', content: [{ type: 'text', text: 'b' }], usage: { input_tokens: 10, output_tokens: 20 } } + } as any) as any + expect(t2?.message?.usage?.context_window).toBe(200_000) + + // Switch back to fable[1m]: its 1M entry was never overwritten by plain fable's + // 200k (distinct keys), so turn-1 before the next result still reads 1M. + conv.updateSelectedModel('fable[1m]') + conv.convert({ type: 'system', subtype: 'init', session_id: 's', model: 'claude-fable-5' } as SDKSystemMessage) + const t3 = conv.convert({ + type: 'assistant', + message: { role: 'assistant', model: 'claude-fable-5', content: [{ type: 'text', text: 'c' }], usage: { input_tokens: 10, output_tokens: 20 } } + } as any) as any + expect(t3?.message?.usage?.context_window).toBe(1_000_000) + }) }) describe('Parent-child relationships', () => { diff --git a/cli/src/claude/utils/sdkToLogConverter.ts b/cli/src/claude/utils/sdkToLogConverter.ts index 7dcbafb6a6..36ec09cf29 100644 --- a/cli/src/claude/utils/sdkToLogConverter.ts +++ b/cli/src/claude/utils/sdkToLogConverter.ts @@ -24,6 +24,12 @@ export interface ConversionContext { version?: string gitBranch?: string parentUuid?: string | null + // The model preset the session actually selected at launch time (e.g. "fable[1m]"), + // with the `[1m]` suffix intact. Some 1M presets (fable[1m]) arrive on system/init + // with the suffix already dropped ("claude-fable-5"), so this preserved preset is + // the only turn-1 signal that such a session is 1M. Used only to seed the very first + // contextWindow estimate before result.modelUsage confirms the real value. + selectedModel?: string | null } type PermissionResponse = { @@ -57,8 +63,22 @@ export class SDKToLogConverter { private context: ConversionContext private responses?: Map private sidechainLastUUID = new Map(); + // The raw model id from the most recent system/init (the session's authoritative model). private resolvedModel: string | null = null - private modelContextWindow: number | null = null + // The cache key for the current session model's contextWindow. Usually equal to + // resolvedModel, but for presets whose "[1m]" variant the CLI still reports with the + // bare id (fable[1m] arrives as "claude-fable-5", same as plain fable) it folds the + // selectedModel's "[1m]" back in so the two variants don't collide. See + // computeContextWindowKey. + private resolvedContextWindowKey: string | null = null + // Per-model contextWindow cache. Keys are the CLI's model id, except that for a + // preset whose "[1m]" variant shares the bare id of its plain form (fable) the "[1m]" + // is folded back into the key (computeContextWindowKey) so a 1M variant and a + // potentially-smaller plain variant stay on distinct entries. opus[1m]/sonnet[1m] + // already arrive suffixed from the CLI, so their keys are unchanged. Keying per model + // — rather than a single sticky number — means a mid-session model switch picks up the + // new model's own window immediately instead of inheriting the previous model's. + private modelContextWindows = new Map() constructor( context: Omit, @@ -73,6 +93,39 @@ export class SDKToLogConverter { this.responses = responses } + /** + * Compute the contextWindow cache key for an init model id. + * + * The CLI reports opus[1m]/sonnet[1m] with the "[1m]" suffix already on the id, but + * reports fable[1m] with the same bare id as plain fable ("claude-fable-5"). To keep a + * 1M variant from colliding with its (possibly-smaller) plain form, we fold the "[1m]" + * back onto the bare id when the session's selected preset asks for 1M. Ids that + * already carry the suffix are returned unchanged. + */ + private computeContextWindowKey(model: string): string { + if (model.endsWith('[1m]')) { + return model + } + const wants1m = this.context.selectedModel?.endsWith('[1m]') ?? false + return wants1m ? `${model}[1m]` : model + } + + /** + * Update the originally-selected model hint (for when the session's model + * changes mid-conversation, e.g. via the web model picker). `context.selectedModel` + * is only a turn-1 seed hint (see the system/init handler in `convert()`), but the + * caller (claudeRemoteLauncher) re-resolves the active mode -- including its model -- + * on every turn, not just the first, since a single long-running `claudeRemote()` + * call keeps accepting new turns with a live-updatable mode. Without this update, + * a mid-session switch would seed new models from a stale, session-start value: + * switching *to* an 1M preset would under-seed (still guess 200k for its first + * turn), and switching *away from* one would over-seed (guess 1M for a model that + * isn't 1M-capable) until a result message corrects it. + */ + updateSelectedModel(model: string | null | undefined): void { + this.context.selectedModel = model ?? null + } + /** * Update session ID (for when session changes during resume) */ @@ -198,10 +251,24 @@ export class SDKToLogConverter { case 'assistant': { const assistantMsg = sdkMessage as SDKAssistantMessage const message = assistantMsg.message as Record - if (this.modelContextWindow !== null && message && typeof message.usage === 'object' && message.usage !== null) { + // Look up the contextWindow by the session's resolved cache key (derived + // from the last system/init model), NOT the assistant message's own `model` + // field. The message's model is always reported bare (no "[1m]"), so it + // can't tell a 200k plain preset apart from its 1M "[1m]" variant when they + // share a base id; resolvedContextWindowKey carries the disambiguated key. + // Using the resolved key also means sidechain (Task subagent) messages carry + // the MAIN session window rather than the subagent's own — the web status + // bar's latestUsage picks the most recent usage message without filtering + // sidechains (Claude usage carries no scope_role), so a subagent's smaller + // window would otherwise make the footer denominator visibly drop while it + // runs. + const contextWindow = this.resolvedContextWindowKey + ? this.modelContextWindows.get(this.resolvedContextWindowKey) + : undefined + if (contextWindow !== undefined && message && typeof message.usage === 'object' && message.usage !== null) { const usage = message.usage as Record if (usage.context_window === undefined) { - usage.context_window = this.modelContextWindow + usage.context_window = contextWindow } } logMessage = { @@ -229,13 +296,33 @@ export class SDKToLogConverter { this.updateSessionId(systemMsg.session_id) } - // Capture the resolved model name on init (e.g. "claude-opus-4-7[1m]"). - // The `[1m]` suffix is stripped on per-turn assistant messages, so - // remember the full name here to derive an initial contextWindow - // estimate. The result message later refines it with the real value. + // Capture the resolved model name on init. The remote launcher re-emits + // system/init on every turn for the lifetime of this converter, so if we + // already learned this model's real contextWindow from a previous result + // message, leave it alone — recomputing a heuristic guess here would + // downgrade an already-known-good value and is the exact cause of the + // 200k<->1M flicker. Only seed a heuristic when this model has no cached + // value yet (first time we see it in this session). if (systemMsg.subtype === 'init' && typeof systemMsg.model === 'string') { this.resolvedModel = systemMsg.model - this.modelContextWindow = systemMsg.model.endsWith('[1m]') ? 1_000_000 : 200_000 + this.resolvedContextWindowKey = this.computeContextWindowKey(systemMsg.model) + if (!this.modelContextWindows.has(this.resolvedContextWindowKey)) { + // Best-effort 1M-vs-200k seed for turn 1, before any authoritative + // result has arrived. `systemMsg.model` only tells us it's a 1M + // model for the presets whose init keeps the "[1m]" suffix + // (opus[1m]/sonnet[1m]); for others the init model is bare even + // when it's a 1M preset (fable[1m] -> "claude-fable-5"). So we + // primarily consult the originally-selected preset, which always + // preserves the suffix (e.g. "fable[1m]"), and fall back to the + // init model string. This selectedModel seed is load-bearing — + // without it, a fresh fable[1m] turn would flash 200k until the + // first result lands. Guarding/seeding on resolvedContextWindowKey + // (not the bare init id) is what forces a re-seed when switching + // fable[1m] <-> fable, whose bare ids would otherwise be identical. + const seedIs1m = (this.context.selectedModel?.endsWith('[1m]') ?? false) + || systemMsg.model.endsWith('[1m]') + this.modelContextWindows.set(this.resolvedContextWindowKey, seedIs1m ? 1_000_000 : 200_000) + } } // System messages are typically not sent to logs @@ -257,13 +344,25 @@ export class SDKToLogConverter { // They're SDK-specific messages that indicate session completion // Not part of the actual conversation log. // - // But they carry the authoritative per-model contextWindow, - // which we cache and inject into subsequent assistant messages. + // But they carry the authoritative per-model contextWindow. modelUsage is + // keyed by the same raw model id the CLI reports on system/init, so the + // entry for the current session model is stored under resolvedContextWindowKey + // (which folds in the "[1m]" for fable), matching what assistant lookups use. + // Other entries — Task subagents like haiku — are stored under their own raw + // id (never fold the session's "[1m]" onto a subagent; haiku is 200k). Always + // overwrite on result — it is ground truth — for every model reported, so a + // model switched away from earlier this session keeps its real value cached + // for if/when the session switches back to it. const resultMsg = sdkMessage as SDKResultMessage - if (resultMsg.modelUsage && this.resolvedModel) { - const cw = resultMsg.modelUsage[this.resolvedModel]?.contextWindow - if (typeof cw === 'number' && cw > 0) { - this.modelContextWindow = cw + if (resultMsg.modelUsage) { + for (const [model, usage] of Object.entries(resultMsg.modelUsage)) { + const cw = usage?.contextWindow + if (typeof cw === 'number' && cw > 0) { + const key = (this.resolvedModel && model === this.resolvedModel) + ? (this.resolvedContextWindowKey ?? model) + : model + this.modelContextWindows.set(key, cw) + } } } break diff --git a/web/src/chat/modelConfig.test.ts b/web/src/chat/modelConfig.test.ts index 6917c6c52b..d270fb5128 100644 --- a/web/src/chat/modelConfig.test.ts +++ b/web/src/chat/modelConfig.test.ts @@ -10,6 +10,10 @@ describe('getContextBudgetTokens', () => { expect(getContextBudgetTokens('claude-sonnet-4-6', 'claude')).toBe(190_000) }) + it('uses the large budget for a full Claude model name carrying a [1m] suffix', () => { + expect(getContextBudgetTokens('claude-opus-4-8[1m]', 'claude')).toBe(990_000) + }) + it('uses Codex app-server context window with headroom', () => { expect(getContextBudgetTokens('gpt-5.4', 'codex')).toBe(248_400) }) diff --git a/web/src/chat/modelConfig.ts b/web/src/chat/modelConfig.ts index 65466a54fe..b6d9b427a5 100644 --- a/web/src/chat/modelConfig.ts +++ b/web/src/chat/modelConfig.ts @@ -74,14 +74,11 @@ export function getContextBudgetTokens(model: string | null | undefined, flavor? if (!trimmedModel) { return DEFAULT_CLAUDE_CONTEXT_WINDOW_TOKENS } - if (isClaudeModelPreset(trimmedModel)) { + if (isClaudeModelPreset(trimmedModel) || trimmedModel.startsWith('claude-')) { return trimmedModel.endsWith('[1m]') ? LARGE_CLAUDE_CONTEXT_WINDOW_TOKENS : DEFAULT_CLAUDE_CONTEXT_WINDOW_TOKENS } - if (trimmedModel.startsWith('claude-')) { - return DEFAULT_CLAUDE_CONTEXT_WINDOW_TOKENS - } return null })() From 43e7b6bef753c7f89eba01d0e1bbdd8d4559d80a Mon Sep 17 00:00:00 2001 From: Junmo Kim Date: Sat, 11 Jul 2026 11:40:06 +0900 Subject: [PATCH 02/28] fix(cli): compute macOS machine-health memory from vm_stat (#990) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cli): add darwin vm_stat memory percent parser Add readDarwinMemoryUsedPercent, a pure parser that computes macOS used memory as App Memory + Wired + Compressed (anonymous + wired-down + occupied-by-compressor pages), matching Activity Monitor's "Memory Used" figure. Page size is parsed from the vm_stat header rather than hardcoded, since it differs between Apple Silicon (16KB) and Intel (4KB) Macs. Not wired up yet; covered by unit tests, including a verbatim vm_stat capture from a 16GB Mac mini where the pre-fix total - freemem() path reported 99% (counting reclaimable cache as used) while App+Wired+ Compressed is 79% — the number a user sees in Activity Monitor. * fix(cli): wire darwin memory percent into computeMemoryPercent Add a platform() === 'darwin' branch that shells out to vm_stat (1s timeout, guarded by try/catch) and feeds its output to readDarwinMemoryUsedPercent. On any failure or undefined result it falls through to the existing total - freemem() fallback, matching the Linux branch's structure. This fixes the Machine capacity tooltip showing a stuck ~99% "High pressure" warning on macOS runners: os.freemem() there counts reclaimable file cache as used, so it reports near-total usage. Summing only App Memory + Wired + Compressed reports the same figure Activity Monitor shows. --- cli/src/utils/machineHealth.test.ts | 108 +++++++++++++++++++++++++++- cli/src/utils/machineHealth.ts | 71 ++++++++++++++++++ 2 files changed, 178 insertions(+), 1 deletion(-) diff --git a/cli/src/utils/machineHealth.test.ts b/cli/src/utils/machineHealth.test.ts index bdfb207089..b3fab5bcec 100644 --- a/cli/src/utils/machineHealth.test.ts +++ b/cli/src/utils/machineHealth.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest' -import { collectMachineHealth, readLinuxMemoryUsedPercent, resetMachineHealthSamplerForTests } from './machineHealth' +import { + collectMachineHealth, + readDarwinMemoryUsedPercent, + readLinuxMemoryUsedPercent, + resetMachineHealthSamplerForTests +} from './machineHealth' describe('readLinuxMemoryUsedPercent', () => { it('uses MemAvailable, not MemFree, so page cache does not read as pressure', () => { @@ -15,6 +20,107 @@ Cached: 9758076 kB }) }) +describe('readDarwinMemoryUsedPercent', () => { + it('reports App+Wired+Compressed used memory, not total-minus-free (real Apple Silicon capture)', () => { + // Verbatim `vm_stat` capture from a 16GB Mac mini (M-series, macOS 26.4, hw.memsize + // 17179869184). `top` reported the same moment as "15G used (1952M wired, 5204M + // compressor)"; anonymous 370327 pages ≈ 5.65GB App Memory. Activity Monitor's + // "Memory Used" = App + Wired + Compressed ≈ 12.6GB → 79%. + // The pre-fix path (`total - os.freemem()`) reported 99% here and drove the tooltip to + // "High pressure — avoid spawning more here", because os.freemem() counts reclaimable + // file cache as used. Summing only anonymous + wired + compressor yields 79%, matching + // the number a user sees in Activity Monitor. + const vmStat = ` +Mach Virtual Memory Statistics: (page size of 16384 bytes) +Pages free: 15078. +Pages active: 269344. +Pages inactive: 269559. +Pages speculative: 1623. +Pages throttled: 0. +Pages wired down: 124928. +Pages purgeable: 6245. +"Translation faults": 6794924000. +Pages copy-on-write: 151784964. +Pages zero filled: 6137372590. +Pages reactivated: 57500663. +Pages purged: 11350044. +File-backed pages: 170199. +Anonymous pages: 370327. +Pages stored in compressor: 785407. +Pages occupied by compressor: 333030. +Decompressions: 49937944. +Compressions: 60303007. +Pageins: 30419969. +Pageouts: 124211. +Swapins: 288. +Swapouts: 12228. +`.trim() + + const totalBytes = 17179869184 + expect(readDarwinMemoryUsedPercent(vmStat, totalBytes)).toBe(79) + }) + + it('parses page size from the header instead of hardcoding it (Intel, 4KB pages)', () => { + const vmStat = ` +Mach Virtual Memory Statistics: (page size of 4096 bytes) +Pages free: 48576. +Pages active: 900000. +Pages inactive: 900000. +Pages speculative: 100000. +Pages throttled: 0. +Pages wired down: 200000. +Pages purgeable: 1000. +Anonymous pages: 800000. +Pages occupied by compressor: 48576. +`.trim() + + // 8GB total (2097152 pages of 4096B), chosen so anonymous+wired+compressor pages == + // exactly half of total pages — verifies the header page size is used, not hardcoded. + const totalBytes = 8589934592 + expect(readDarwinMemoryUsedPercent(vmStat, totalBytes)).toBe(50) + }) + + it('returns undefined when a required page count is missing, so callers fall back', () => { + // Valid header and two of the three required counts present, but "Anonymous pages" is + // absent — must fail safe to the caller's os.freemem() path, not compute from partial data. + const vmStat = ` +Mach Virtual Memory Statistics: (page size of 16384 bytes) +Pages wired down: 124928. +Pages occupied by compressor: 333030. +`.trim() + + expect(readDarwinMemoryUsedPercent(vmStat, 17179869184)).toBeUndefined() + }) + + it('returns undefined for entirely unparseable input', () => { + expect(readDarwinMemoryUsedPercent('not a vm_stat output at all', 17179869184)).toBeUndefined() + }) + + it('returns undefined when totalBytes is not positive', () => { + const vmStat = ` +Mach Virtual Memory Statistics: (page size of 16384 bytes) +Pages free: 10000. +Pages inactive: 150000. +Pages speculative: 9125. +`.trim() + + expect(readDarwinMemoryUsedPercent(vmStat, 0)).toBeUndefined() + }) + + it('falls back (undefined) when the page-size header is missing instead of guessing 4096', () => { + // Required page counts are all present, but no "page size of N bytes" header. Guessing + // 4096 would 4x-underestimate used bytes on Apple Silicon and re-introduce the + // false-high-pressure bug, so this must fail safe to the caller's os.freemem() path. + const vmStat = ` +Pages wired down: 124928. +Anonymous pages: 370327. +Pages occupied by compressor: 333030. +`.trim() + + expect(readDarwinMemoryUsedPercent(vmStat, 17179869184)).toBeUndefined() + }) +}) + describe('collectMachineHealth', () => { it('returns schema-valid health with memory, uptime, and cpu count', () => { resetMachineHealthSamplerForTests() diff --git a/cli/src/utils/machineHealth.ts b/cli/src/utils/machineHealth.ts index 53d837e21e..94c98051ec 100644 --- a/cli/src/utils/machineHealth.ts +++ b/cli/src/utils/machineHealth.ts @@ -1,3 +1,4 @@ +import { execSync } from 'node:child_process' import { readFileSync } from 'node:fs' import { availableParallelism, cpus, freemem, loadavg, platform, totalmem, uptime } from 'node:os' import type { MachineHealth } from '@hapi/protocol/types' @@ -72,6 +73,64 @@ export function readLinuxMemoryUsedPercent(meminfo: string): number | undefined return Math.max(0, Math.min(100, Math.round(((total - approxAvailable) / total) * 100))) } +function parseVmStatPagesValue(vmStat: string, label: string): number | undefined { + for (const line of vmStat.split('\n')) { + const trimmed = line.trim() + if (!trimmed.startsWith(`${label}:`)) { + continue + } + const match = trimmed.match(/(\d+)\.?\s*$/) + if (!match) { + return undefined + } + const pages = Number(match[1]) + return Number.isFinite(pages) ? pages : undefined + } + return undefined +} + +function parseVmStatPageSize(vmStat: string): number | undefined { + const match = vmStat.match(/page size of (\d+) bytes/) + if (!match) { + return undefined + } + const size = Number(match[1]) + return Number.isFinite(size) && size > 0 ? size : undefined +} + +/** + * Darwin used-memory percent, matching Activity Monitor's "Memory Used" figure: + * App Memory + Wired + Compressed. It sums the anonymous (non-file-backed) resident + * pages, the wired-down pages, and the pages occupied by the compressor — the + * genuinely-occupied memory. Free and reclaimable file cache (inactive/speculative + * file-backed pages) are simply not part of that sum. + * + * os.freemem() (total - free) instead counts reclaimable file cache as used, which is why + * the pre-fix path reported a stuck ~99% "High pressure" on macOS. Summing occupied pages + * keeps parity with the Linux branch, whose total - MemAvailable likewise treats reclaimable + * cache as available and anonymous/wired memory as used. The three fields were validated on a + * real Mac mini against `top`'s wired/compressor byte figures. + * + * Any unparseable required field — including the page-size header — returns undefined so the + * caller falls back to os.freemem() rather than reporting a wrong-but-plausible percent. + */ +export function readDarwinMemoryUsedPercent(vmStat: string, totalBytes: number): number | undefined { + if (!totalBytes || totalBytes <= 0) { + return undefined + } + + const pageSize = parseVmStatPageSize(vmStat) + const anonymous = parseVmStatPagesValue(vmStat, 'Anonymous pages') + const wired = parseVmStatPagesValue(vmStat, 'Pages wired down') + const compressor = parseVmStatPagesValue(vmStat, 'Pages occupied by compressor') + if (pageSize === undefined || anonymous === undefined || wired === undefined || compressor === undefined) { + return undefined + } + + const used = (anonymous + wired + compressor) * pageSize + return Math.max(0, Math.min(100, Math.round((used / totalBytes) * 100))) +} + function computeMemoryPercent(): number | undefined { if (platform() === 'linux') { try { @@ -84,6 +143,18 @@ function computeMemoryPercent(): number | undefined { } } + if (platform() === 'darwin') { + try { + const vmStat = execSync('vm_stat', { encoding: 'utf8', timeout: 1000 }) + const fromVmStat = readDarwinMemoryUsedPercent(vmStat, totalmem()) + if (fromVmStat !== undefined) { + return fromVmStat + } + } catch { + // fall through to os.freemem() + } + } + const total = totalmem() if (total <= 0) { return undefined From afdcd92fc6cf3ee70d15be75b41e32ee23474d8c Mon Sep 17 00:00:00 2001 From: quecai-niu <114210016+quecai-niu@users.noreply.github.com> Date: Sat, 11 Jul 2026 10:40:48 +0800 Subject: [PATCH 03/28] fix: normalize Windows drive roots (#979) --- cli/src/api/apiMachine.test.ts | 16 ++++++++++++++-- cli/src/api/apiMachine.ts | 18 +++++++++++++----- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/cli/src/api/apiMachine.test.ts b/cli/src/api/apiMachine.test.ts index 321990f0f2..4204c41213 100644 --- a/cli/src/api/apiMachine.test.ts +++ b/cli/src/api/apiMachine.test.ts @@ -18,7 +18,7 @@ vi.mock('../modules/common/opencodeModels', () => ({ listOpencodeModelsForCwd: listOpencodeModelsForCwdMock })) -import { ApiMachineClient } from './apiMachine' +import { ApiMachineClient, normalizeWindowsDriveRoot } from './apiMachine' import type { Machine } from './types' function makeMachine(id: string): Machine { @@ -37,6 +37,18 @@ function makeMachine(id: string): Machine { } } +describe('normalizeWindowsDriveRoot', () => { + it('restores the trailing separator when Windows realpath returns a bare drive', () => { + expect(normalizeWindowsDriveRoot('C:')).toBe('C:\\') + expect(normalizeWindowsDriveRoot('D:')).toBe('D:\\') + }) + + it('leaves non-drive-root paths unchanged', () => { + expect(normalizeWindowsDriveRoot('C:\\Users')).toBe('C:\\Users') + expect(normalizeWindowsDriveRoot('/tmp/workspace')).toBe('/tmp/workspace') + }) +}) + async function callListOpencodeModels(client: ApiMachineClient, machineId: string, cwd: string): Promise { // Reach into the private rpc handler manager to dispatch a request. // Mirrors how the on-socket 'rpc-request' listener invokes handleRequest. @@ -137,7 +149,7 @@ describe('ApiMachineClient listOpencodeModelsForCwd handler', () => { }) // The handler realpaths the cwd (security: prevents symlink escape), // so on macOS /var/folders/... resolves to /private/var/folders/... - expect(listOpencodeModelsForCwdMock).toHaveBeenCalledWith(realpathSync(secondWorkspaceRoot)) + expect(listOpencodeModelsForCwdMock).toHaveBeenCalledWith(realpathSync.native(secondWorkspaceRoot)) } finally { rmSync(secondWorkspaceRoot, { recursive: true, force: true }) client.shutdown() diff --git a/cli/src/api/apiMachine.ts b/cli/src/api/apiMachine.ts index 4225ff6a51..07e241538f 100644 --- a/cli/src/api/apiMachine.ts +++ b/cli/src/api/apiMachine.ts @@ -41,6 +41,14 @@ interface ListMachineDirectoryRequest { path: string } +export function normalizeWindowsDriveRoot(path: string): string { + return /^[A-Za-z]:$/.test(path) ? `${path}\\` : path +} + +function canonicalRealpathSync(path: string): string { + return normalizeWindowsDriveRoot(realpathSync.native(path)) +} + function normalizeWorkspaceRoots(paths?: string[]): string[] | undefined { if (!paths?.length) { return undefined @@ -48,9 +56,9 @@ function normalizeWorkspaceRoots(paths?: string[]): string[] | undefined { const normalized = Array.from(new Set(paths.map((path) => { try { - return realpathSync(path) + return canonicalRealpathSync(path) } catch { - return resolvePath(path) + return normalizeWindowsDriveRoot(resolvePath(path)) } }))) @@ -232,7 +240,7 @@ export class ApiMachineClient { private async resolveForWorkspaceCheck(path: string): Promise { const absolute = resolvePath(path) try { - return await realpath(absolute) + return normalizeWindowsDriveRoot(await realpath(absolute)) } catch { const missing: string[] = [] let cursor = absolute @@ -240,12 +248,12 @@ export class ApiMachineClient { missing.unshift(basename(cursor)) cursor = dirname(cursor) try { - return join(await realpath(cursor), ...missing) + return join(normalizeWindowsDriveRoot(await realpath(cursor)), ...missing) } catch { // keep walking to the nearest existing parent } } - return absolute + return normalizeWindowsDriveRoot(absolute) } } From a2465c782b1d4420330b25ad623d6e7f42337317 Mon Sep 17 00:00:00 2001 From: quecai-niu <114210016+quecai-niu@users.noreply.github.com> Date: Sat, 11 Jul 2026 10:41:02 +0800 Subject: [PATCH 04/28] [codex] fix Qwen realtime compatibility (#977) * fix: improve Qwen realtime compatibility * fix: preserve Qwen endpoint query parameters --- hub/src/web/qwenProxyHandler.test.ts | 38 +++++++++++++++++++++++++++- hub/src/web/qwenProxyHandler.ts | 21 ++++++++++++--- shared/src/voice.ts | 6 +++-- 3 files changed, 59 insertions(+), 6 deletions(-) diff --git a/hub/src/web/qwenProxyHandler.test.ts b/hub/src/web/qwenProxyHandler.test.ts index 337bd069ba..8816ac9164 100644 --- a/hub/src/web/qwenProxyHandler.test.ts +++ b/hub/src/web/qwenProxyHandler.test.ts @@ -53,6 +53,7 @@ class FakeClient { } let lastUpstream: FakeUpstream | null = null +const origQwenRealtimeWsUrl = process.env.QWEN_REALTIME_WS_URL const FakeWebSocket = function FakeWebSocket(url: string, opts?: unknown) { const u = new FakeUpstream(url, opts) lastUpstream = u @@ -65,13 +66,47 @@ beforeEach(() => { afterEach(() => { lastUpstream = null + if (origQwenRealtimeWsUrl === undefined) delete process.env.QWEN_REALTIME_WS_URL + else process.env.QWEN_REALTIME_WS_URL = origQwenRealtimeWsUrl }) function newClient() { - return new FakeClient({ apiKey: 'k', model: 'qwen3-omni-flash-realtime', language: 'en', voiceName: 'Cherry' }) as unknown as ServerWebSocket & FakeClient + return new FakeClient({ apiKey: 'k', model: 'qwen3.5-omni-flash-realtime', language: 'en', voiceName: 'Cherry' }) as unknown as ServerWebSocket & FakeClient } describe('createQwenProxyWebSocketHandler ack-gate', () => { + test('preserves the international DashScope realtime endpoint by default', () => { + delete process.env.QWEN_REALTIME_WS_URL + const handler = createQwenProxyWebSocketHandler(FakeWebSocket) + const client = newClient() + + handler.open(client) + + expect(lastUpstream?.url).toBe('wss://dashscope-intl.aliyuncs.com/api-ws/v1/realtime?model=qwen3.5-omni-flash-realtime') + }) + + test('allows overriding the DashScope realtime endpoint with QWEN_REALTIME_WS_URL', () => { + process.env.QWEN_REALTIME_WS_URL = 'wss://example.test/realtime' + const handler = createQwenProxyWebSocketHandler(FakeWebSocket) + const client = newClient() + + handler.open(client) + + expect(lastUpstream?.url).toBe('wss://example.test/realtime?model=qwen3.5-omni-flash-realtime') + }) + + test('preserves existing query parameters in QWEN_REALTIME_WS_URL', () => { + process.env.QWEN_REALTIME_WS_URL = 'wss://example.test/realtime?workspace=abc&model=old' + const handler = createQwenProxyWebSocketHandler(FakeWebSocket) + const client = newClient() + + handler.open(client) + + expect(lastUpstream?.url).toBe( + 'wss://example.test/realtime?workspace=abc&model=qwen3.5-omni-flash-realtime' + ) + }) + test('queues client frames until upstream acks hub-owned session.update with session.updated', () => { const handler = createQwenProxyWebSocketHandler(FakeWebSocket) const client = newClient() @@ -85,6 +120,7 @@ describe('createQwenProxyWebSocketHandler ack-gate', () => { const hubSetup = JSON.parse(upstream.sent[0] as string) as { type: string; session: { instructions: string } } expect(hubSetup.type).toBe('session.update') expect(typeof hubSetup.session.instructions).toBe('string') + expect(hubSetup).not.toHaveProperty('session.tool_choice') handler.message(client, JSON.stringify({ type: 'response.create' })) handler.message(client, JSON.stringify({ type: 'conversation.item.create', item: { type: 'message' } })) diff --git a/hub/src/web/qwenProxyHandler.ts b/hub/src/web/qwenProxyHandler.ts index 8c1cd18bdf..c0abb66c96 100644 --- a/hub/src/web/qwenProxyHandler.ts +++ b/hub/src/web/qwenProxyHandler.ts @@ -53,9 +53,11 @@ export function createQwenProxyWebSocketHandler( return { open(clientWs) { const data = clientWs.data as { apiKey: string; model: string; language?: string; voiceName?: string; systemInstruction?: string } - const upstreamUrl = `${process.env.QWEN_REALTIME_WS_URL || QWEN_WS_BASE}?model=${encodeURIComponent(data.model)}` + const upstreamBase = process.env.QWEN_REALTIME_WS_URL || QWEN_WS_BASE + const upstreamUrl = new URL(upstreamBase) + upstreamUrl.searchParams.set('model', data.model) - const upstream = new WebSocketImpl(upstreamUrl, { + const upstream = new WebSocketImpl(upstreamUrl.toString(), { headers: { 'Authorization': `Bearer ${data.apiKey}` } }) @@ -106,12 +108,17 @@ export function createQwenProxyWebSocketHandler( } } catch { /* client gone */ } } - upstream.onerror = () => { + upstream.onerror = (event) => { pendingSetupMap.delete(clientWs) setupAckedMap.delete(clientWs) pendingClientFrames.delete(clientWs) pendingClientBytes.delete(clientWs) upstreamMap.delete(clientWs) + console.error('[Voice][QwenProxy] Upstream WebSocket error', { + upstreamBase, + model: data.model, + error: event instanceof Error ? event.message : String(event) + }) try { clientWs.close(1011, 'Upstream error') } catch { /* */ } } upstream.onclose = (event) => { @@ -119,6 +126,14 @@ export function createQwenProxyWebSocketHandler( setupAckedMap.delete(clientWs) pendingClientFrames.delete(clientWs) pendingClientBytes.delete(clientWs) + if (event.code !== 1000) { + console.warn('[Voice][QwenProxy] Upstream WebSocket closed', { + upstreamBase, + model: data.model, + code: event.code, + reason: event.reason + }) + } try { clientWs.close(toClientCloseCode(event.code), event.reason || 'Upstream closed') } catch { /* */ } upstreamMap.delete(clientWs) } diff --git a/shared/src/voice.ts b/shared/src/voice.ts index 0f3ac56c45..7cc4738c68 100644 --- a/shared/src/voice.ts +++ b/shared/src/voice.ts @@ -399,8 +399,10 @@ export function buildQwenSessionUpdateMessage( silence_duration_ms: 800, prefix_padding_ms: 300 }, - tools, - tool_choice: 'auto' + // Qwen-Omni-Realtime decides whether to call tools automatically. + // The official realtime API does not support OpenAI-style tool_choice / + // parallel_tool_calls parameters on session.update. + tools } } } From 58cfc330f966a81df6e518ef5a3b6bbf8cdced60 Mon Sep 17 00:00:00 2001 From: SSU-WEI HUANG Date: Sat, 11 Jul 2026 10:41:49 +0800 Subject: [PATCH 05/28] fix(web): show standard for untouched Codex service tier (#1010) --- .../components/AssistantChat/HappyComposer.tsx | 10 ++++++---- .../AssistantChat/codexFastMode.test.ts | 18 +++++++++++++++++- .../components/AssistantChat/codexFastMode.ts | 9 +++++++++ 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/web/src/components/AssistantChat/HappyComposer.tsx b/web/src/components/AssistantChat/HappyComposer.tsx index e2639d2a13..70b1f0866b 100644 --- a/web/src/components/AssistantChat/HappyComposer.tsx +++ b/web/src/components/AssistantChat/HappyComposer.tsx @@ -35,6 +35,7 @@ import { useTranslation } from '@/lib/use-translation' import { getModelOptionsForFlavor, getNextModelForFlavor } from './modelOptions' import { getClaudeComposerEffortOptions } from './claudeEffortOptions' import { getCodexComposerReasoningEffortOptions } from './codexReasoningEffortOptions' +import { getDisplayedCodexServiceTier } from './codexFastMode' import { getPiThinkingLevelOptions, getHighestThinkingLevel, isThinkingLevelSupported } from './piThinkingLevelOptions' import { groupModelsByProvider } from './piModelGroups' import { PiModelPanel } from './PiModelPanel' @@ -259,6 +260,7 @@ export function HappyComposer(props: { const modelReasoningEffort = rawModelReasoningEffort ?? null const effort = rawEffort ?? null const serviceTier = rawServiceTier ?? null + const displayedServiceTier = getDisplayedCodexServiceTier(serviceTier) const api = useAssistantApi() const { composerEnterBehavior } = useComposerEnterBehavior() @@ -1159,16 +1161,16 @@ export function HappyComposer(props: { >
- {serviceTier === option.value && ( + {displayedServiceTier === option.value && (
)}
- + {option.label} @@ -1225,7 +1227,7 @@ export function HappyComposer(props: { model, modelReasoningEffort, effort, - serviceTier, + displayedServiceTier, collaborationModeOptions, permissionModeOptions, handleCollaborationChange, diff --git a/web/src/components/AssistantChat/codexFastMode.test.ts b/web/src/components/AssistantChat/codexFastMode.test.ts index 88baf15f9b..f7f661bfdd 100644 --- a/web/src/components/AssistantChat/codexFastMode.test.ts +++ b/web/src/components/AssistantChat/codexFastMode.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { codexModelAdvertisesFastTier, isFastServiceTier } from './codexFastMode' +import { + codexModelAdvertisesFastTier, + getDisplayedCodexServiceTier, + isFastServiceTier +} from './codexFastMode' // Mirrors the real Codex catalog: the Fast tier's id is 'priority' and its // display name is 'Fast', so the CLI captures both as lowercased tokens @@ -49,3 +53,15 @@ describe('isFastServiceTier', () => { expect(isFastServiceTier('standard')).toBe(false) }) }) + +describe('getDisplayedCodexServiceTier', () => { + it('displays untouched and explicit non-fast tiers as standard', () => { + expect(getDisplayedCodexServiceTier(null)).toBe('standard') + expect(getDisplayedCodexServiceTier(undefined)).toBe('standard') + expect(getDisplayedCodexServiceTier('standard')).toBe('standard') + }) + + it('preserves the fast selection', () => { + expect(getDisplayedCodexServiceTier('fast')).toBe('fast') + }) +}) diff --git a/web/src/components/AssistantChat/codexFastMode.ts b/web/src/components/AssistantChat/codexFastMode.ts index 7d467d692c..7b85999007 100644 --- a/web/src/components/AssistantChat/codexFastMode.ts +++ b/web/src/components/AssistantChat/codexFastMode.ts @@ -49,3 +49,12 @@ export function codexModelAdvertisesFastTier( export function isFastServiceTier(serviceTier?: string | null): boolean { return serviceTier?.trim().toLowerCase() === 'fast' } + +/** + * The persisted null tier means the user has not chosen an override. The + * current two-option control still needs a visible selection, so display that + * untouched state as Standard without changing the value sent to the backend. + */ +export function getDisplayedCodexServiceTier(serviceTier?: string | null): 'standard' | 'fast' { + return isFastServiceTier(serviceTier) ? 'fast' : 'standard' +} From 018bcb8eaa569aecc05b2f88335ca9ce751f054b Mon Sep 17 00:00:00 2001 From: SSU-WEI HUANG Date: Sat, 11 Jul 2026 10:42:13 +0800 Subject: [PATCH 06/28] fix(web): clean up React tests globally (#993) --- web/src/test/setup.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/web/src/test/setup.ts b/web/src/test/setup.ts index 5df7b144d7..d5ed3d8905 100644 --- a/web/src/test/setup.ts +++ b/web/src/test/setup.ts @@ -1,4 +1,10 @@ import '@testing-library/jest-dom/vitest' +import { cleanup } from '@testing-library/react' +import { afterEach } from 'vitest' + +afterEach(() => { + cleanup() +}) function installMemoryLocalStorage(): void { const store = new Map() From a82dd49049d02509afd3321d157b383fdbc66687 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sat, 11 Jul 2026 04:06:38 +0100 Subject: [PATCH 07/28] feat(web): markdown Source | Preview toggle in session file pane (#957) * feat(web): markdown Source | Preview toggle in session file pane Add Source | Preview toggle for .md/.mdx files in the session file route, defaulting to preview with localStorage persistence. Reuse chat markdown pipeline via MarkdownRenderer standalone mode (no assistant-ui thread). Includes unit tests, Playwright smoke, and e2e fixture. Closes tiann/hapi#954 Co-authored-by: Cursor * fix(web): cast standalone MarkdownRenderer components for react-markdown Soup verify gate: defaultComponents merge type is wider than react-markdown Components; standalone file-pane path needs explicit cast. * fix(web): route file-pane markdown fences through SyntaxHighlighter Standalone file preview now mirrors chat code-block rendering: fenced blocks use SyntaxHighlighter and MARKDOWN_COMPONENTS_BY_LANGUAGE (mermaid included) without requiring ThreadPrimitive context. Addresses HAPI Bot Major on tiann/hapi#957. Co-authored-by: Cursor * fix(web): detect fenced vs inline code in standalone markdown preview Move block detection to the pre override (react-markdown v10 does not pass inline to custom code components). Add inline-code regression test. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- e2e/file-md-preview.spec.ts | 33 +++++ playwright.config.ts | 2 +- web/e2e-fixtures/file-md-preview-fixture.html | 18 +++ web/e2e-fixtures/file-md-preview-fixture.tsx | 88 +++++++++++++ web/src/components/MarkdownRenderer.test.tsx | 31 +++++ web/src/components/MarkdownRenderer.tsx | 74 +++++++++++ web/src/lib/file-markdown-preview.test.ts | 36 ++++++ web/src/lib/file-markdown-preview.ts | 69 ++++++++++ web/src/lib/locales/en.ts | 2 + web/src/lib/locales/zh-CN.ts | 2 + web/src/routes/sessions/file.test.tsx | 98 +++++++++++++++ web/src/routes/sessions/file.tsx | 119 +++++++++++++----- 12 files changed, 540 insertions(+), 32 deletions(-) create mode 100644 e2e/file-md-preview.spec.ts create mode 100644 web/e2e-fixtures/file-md-preview-fixture.html create mode 100644 web/e2e-fixtures/file-md-preview-fixture.tsx create mode 100644 web/src/components/MarkdownRenderer.test.tsx create mode 100644 web/src/lib/file-markdown-preview.test.ts create mode 100644 web/src/lib/file-markdown-preview.ts create mode 100644 web/src/routes/sessions/file.test.tsx diff --git a/e2e/file-md-preview.spec.ts b/e2e/file-md-preview.spec.ts new file mode 100644 index 0000000000..7456ade067 --- /dev/null +++ b/e2e/file-md-preview.spec.ts @@ -0,0 +1,33 @@ +/* + * Playwright smoke for issue #954 — markdown Source | Preview toggle in + * the session file pane. Drives the Vite fixture that mounts the same + * MarkdownRenderer + toggle affordance as production `file.tsx`. + */ + +import { test, expect } from '@playwright/test' +import path from 'node:path' + +const SCREENSHOT_PATH = path.resolve( + process.env.HOME ?? '', + 'coding/hapi/localdocs/playwright-runs/954-file-md-preview.png' +) + +test.describe('file markdown preview e2e', () => { + test('preview renders heading and table; source shows raw markdown', async ({ page }) => { + await page.goto('/e2e-fixtures/file-md-preview-fixture.html') + await expect(page.getByTestId('file-md-preview-fixture')).toBeVisible() + + await expect(page.getByRole('heading', { name: 'Teams and channels' })).toBeVisible() + await expect(page.getByRole('cell', { name: 'general' })).toBeVisible() + await expect(page.locator('.aui-md-codeblock')).toBeVisible() + + await page.getByTestId('markdown-mode-source').click() + await expect(page.getByTestId('markdown-source-view')).toContainText('# Teams and channels') + await expect(page.getByRole('heading', { name: 'Teams and channels' })).toHaveCount(0) + + await page.getByTestId('markdown-mode-preview').click() + await expect(page.getByRole('heading', { name: 'Teams and channels' })).toBeVisible() + + await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true }) + }) +}) diff --git a/playwright.config.ts b/playwright.config.ts index 779efd169c..7ff2c2884b 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,6 +1,6 @@ import { defineConfig, devices } from '@playwright/test' -const PORT = 5179 +const PORT = Number(process.env.PLAYWRIGHT_WEB_PORT ?? 5179) const BASE_URL = `http://localhost:${PORT}` export default defineConfig({ diff --git a/web/e2e-fixtures/file-md-preview-fixture.html b/web/e2e-fixtures/file-md-preview-fixture.html new file mode 100644 index 0000000000..cb3a1abea8 --- /dev/null +++ b/web/e2e-fixtures/file-md-preview-fixture.html @@ -0,0 +1,18 @@ + + + + + + HAPI file markdown preview e2e fixture + + + +
+ + + diff --git a/web/e2e-fixtures/file-md-preview-fixture.tsx b/web/e2e-fixtures/file-md-preview-fixture.tsx new file mode 100644 index 0000000000..7e2f27549a --- /dev/null +++ b/web/e2e-fixtures/file-md-preview-fixture.tsx @@ -0,0 +1,88 @@ +/* + * Standalone Vite-served fixture for the file-pane markdown Source | + * Preview Playwright smoke. Mounts the same toggle + MarkdownRenderer + * path as `file.tsx` without the HAPI auth / git / socket stack. + */ + +import React from 'react' +import ReactDOM from 'react-dom/client' +import '../src/index.css' +import { I18nProvider } from '../src/lib/i18n-context' +import { MarkdownRenderer } from '../src/components/MarkdownRenderer' +import { + getInitialMarkdownPreviewMode, + persistMarkdownPreviewMode, + type MarkdownPreviewMode, +} from '../src/lib/file-markdown-preview' +import { useTranslation } from '../src/lib/use-translation' + +const SAMPLE_MARKDOWN = `# Teams and channels + +| Channel | Purpose | +| --- | --- | +| general | Day-to-day coordination | +| incidents | Outage response | + +\`\`\`ts +export const ok = true +\`\`\` + +> Preview uses the same markdown pipeline as chat. +` + +function FileMarkdownPreviewFixture() { + const { t } = useTranslation() + const [mode, setMode] = React.useState(() => getInitialMarkdownPreviewMode()) + const showSource = mode === 'source' + + const setMarkdownPreviewMode = (next: MarkdownPreviewMode) => { + setMode(next) + persistMarkdownPreviewMode(next) + } + + return ( +
+
+ + +
+ {showSource ? ( +
+                    {SAMPLE_MARKDOWN}
+                
+ ) : ( +
+ +
+ )} +
+ ) +} + +const rootEl = document.getElementById('root') +if (rootEl) { + ReactDOM.createRoot(rootEl).render( + + + + + + ) +} diff --git a/web/src/components/MarkdownRenderer.test.tsx b/web/src/components/MarkdownRenderer.test.tsx new file mode 100644 index 0000000000..ed4834990d --- /dev/null +++ b/web/src/components/MarkdownRenderer.test.tsx @@ -0,0 +1,31 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { cleanup, render } from '@testing-library/react' +import { I18nProvider } from '@/lib/i18n-context' +import { MarkdownRenderer } from './MarkdownRenderer' + +describe('MarkdownRenderer', () => { + afterEach(() => { + cleanup() + }) + + it('renders fenced code blocks with the shared syntax highlighter shell in standalone mode', () => { + render( + + + + ) + + expect(document.querySelector('.aui-md-codeblock')).toBeTruthy() + }) + + it('renders inline code without the fenced-code shell in standalone mode', () => { + render( + + + + ) + + expect(document.querySelector('.aui-md-codeblock')).toBeFalsy() + expect(document.querySelector('.aui-md-code')).toBeTruthy() + }) +}) diff --git a/web/src/components/MarkdownRenderer.tsx b/web/src/components/MarkdownRenderer.tsx index ecc71e736f..e66122cd6e 100644 --- a/web/src/components/MarkdownRenderer.tsx +++ b/web/src/components/MarkdownRenderer.tsx @@ -1,6 +1,8 @@ import type { MarkdownTextPrimitiveProps } from '@assistant-ui/react-markdown' import { MarkdownTextPrimitive } from '@assistant-ui/react-markdown' import { TextMessagePartProvider } from '@assistant-ui/react' +import { Children, isValidElement, useMemo, type ComponentPropsWithoutRef, type ComponentType } from 'react' +import ReactMarkdown, { type Components } from 'react-markdown' import { MARKDOWN_PLUGINS, MARKDOWN_PLUGINS_WITH_BREAKS, @@ -11,6 +13,8 @@ import { denyOnlyTransform, UriConfirmProvider, } from '@/components/assistant-ui/markdown-text' +import { SyntaxHighlighter } from '@/components/assistant-ui/shiki-highlighter' +import type { CodeHeaderProps, SyntaxHighlighterProps } from '@assistant-ui/react-markdown' import { cn } from '@/lib/utils' interface MarkdownRendererProps { @@ -18,6 +22,73 @@ interface MarkdownRendererProps { components?: MarkdownTextPrimitiveProps['components'] className?: string preserveSingleLineBreaks?: boolean + /** Render outside assistant-ui thread context (file pane, fixtures). */ + standalone?: boolean +} + +function StandaloneCode(props: ComponentPropsWithoutRef<'code'>) { + const Code = defaultComponents.code! + return +} + +function StandalonePre(props: ComponentPropsWithoutRef<'pre'>) { + const child = Children.toArray(props.children)[0] + if (!isValidElement>(child)) { + const Pre = defaultComponents.pre! + return
+    }
+
+    const className = String(child.props.className ?? '')
+    const language = /language-(\w+)/.exec(className)?.[1] ?? 'unknown'
+    const code = String(child.props.children ?? '').replace(/\n$/, '')
+    const Highlighter: ComponentType =
+        MARKDOWN_COMPONENTS_BY_LANGUAGE[language as keyof typeof MARKDOWN_COMPONENTS_BY_LANGUAGE]?.SyntaxHighlighter
+        ?? SyntaxHighlighter
+    const CodeHeader = defaultComponents.CodeHeader as ComponentType
+    const Pre = defaultComponents.pre!
+    const Code = defaultComponents.code!
+
+    return (
+        <>
+            
+            
+        
+    )
+}
+
+function StandaloneMarkdownContent(props: MarkdownRendererProps) {
+    const mergedComponents = props.components
+        ? { ...defaultComponents, ...props.components }
+        : defaultComponents
+
+    const {
+        pre: _pre,
+        code: _code,
+        SyntaxHighlighter: _sh,
+        CodeHeader: _header,
+        ...componentsRest
+    } = mergedComponents as typeof mergedComponents & Record
+
+    const components = useMemo(() => ({
+        ...(componentsRest as Components),
+        pre: StandalonePre,
+        code: StandaloneCode,
+    }), [componentsRest])
+
+    return (
+        
+            
+ + {props.content} + +
+
+ ) } function MarkdownContent(props: MarkdownRendererProps) { @@ -42,5 +113,8 @@ function MarkdownContent(props: MarkdownRendererProps) { } export function MarkdownRenderer(props: MarkdownRendererProps) { + if (props.standalone) { + return + } return } diff --git a/web/src/lib/file-markdown-preview.test.ts b/web/src/lib/file-markdown-preview.test.ts new file mode 100644 index 0000000000..68d57a6dd5 --- /dev/null +++ b/web/src/lib/file-markdown-preview.test.ts @@ -0,0 +1,36 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { + DEFAULT_MARKDOWN_PREVIEW_MODE, + MARKDOWN_PREVIEW_MODE_STORAGE_KEY, + getInitialMarkdownPreviewMode, + isMarkdownFile, + persistMarkdownPreviewMode, +} from './file-markdown-preview' + +describe('file-markdown-preview helpers', () => { + beforeEach(() => { + window.localStorage.clear() + }) + + it('detects markdown file extensions', () => { + expect(isMarkdownFile('README.md')).toBe(true) + expect(isMarkdownFile('docs/guide/page.mdx')).toBe(true) + expect(isMarkdownFile('src/file.ts')).toBe(false) + expect(isMarkdownFile('noext')).toBe(false) + }) + + it('defaults to preview when storage is missing or invalid', () => { + expect(getInitialMarkdownPreviewMode()).toBe(DEFAULT_MARKDOWN_PREVIEW_MODE) + window.localStorage.setItem(MARKDOWN_PREVIEW_MODE_STORAGE_KEY, 'nope') + expect(getInitialMarkdownPreviewMode()).toBe(DEFAULT_MARKDOWN_PREVIEW_MODE) + }) + + it('reads and persists a valid preview mode', () => { + persistMarkdownPreviewMode('source') + expect(getInitialMarkdownPreviewMode()).toBe('source') + + persistMarkdownPreviewMode('preview') + expect(window.localStorage.getItem(MARKDOWN_PREVIEW_MODE_STORAGE_KEY)).toBeNull() + expect(getInitialMarkdownPreviewMode()).toBe(DEFAULT_MARKDOWN_PREVIEW_MODE) + }) +}) diff --git a/web/src/lib/file-markdown-preview.ts b/web/src/lib/file-markdown-preview.ts new file mode 100644 index 0000000000..a5d399208c --- /dev/null +++ b/web/src/lib/file-markdown-preview.ts @@ -0,0 +1,69 @@ +export type MarkdownPreviewMode = 'source' | 'preview' + +export const MARKDOWN_PREVIEW_MODE_STORAGE_KEY = 'hapi.filePreview.markdownMode.v1' +export const DEFAULT_MARKDOWN_PREVIEW_MODE: MarkdownPreviewMode = 'preview' + +function isBrowser(): boolean { + return typeof window !== 'undefined' && typeof document !== 'undefined' +} + +function safeGetItem(key: string): string | null { + if (!isBrowser()) { + return null + } + try { + return localStorage.getItem(key) + } catch { + return null + } +} + +function safeSetItem(key: string, value: string): void { + if (!isBrowser()) { + return + } + try { + localStorage.setItem(key, value) + } catch { + // Ignore storage errors + } +} + +function safeRemoveItem(key: string): void { + if (!isBrowser()) { + return + } + try { + localStorage.removeItem(key) + } catch { + // Ignore storage errors + } +} + +export function isMarkdownFile(path: string): boolean { + const parts = path.split('.') + if (parts.length <= 1) { + return false + } + const ext = parts[parts.length - 1]?.toLowerCase() + return ext === 'md' || ext === 'mdx' +} + +function parseMarkdownPreviewMode(raw: string | null): MarkdownPreviewMode { + if (raw === 'source' || raw === 'preview') { + return raw + } + return DEFAULT_MARKDOWN_PREVIEW_MODE +} + +export function getInitialMarkdownPreviewMode(): MarkdownPreviewMode { + return parseMarkdownPreviewMode(safeGetItem(MARKDOWN_PREVIEW_MODE_STORAGE_KEY)) +} + +export function persistMarkdownPreviewMode(mode: MarkdownPreviewMode): void { + if (mode === DEFAULT_MARKDOWN_PREVIEW_MODE) { + safeRemoveItem(MARKDOWN_PREVIEW_MODE_STORAGE_KEY) + return + } + safeSetItem(MARKDOWN_PREVIEW_MODE_STORAGE_KEY, mode) +} diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 3e1d922091..da94bdc71f 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -336,6 +336,8 @@ export default { 'file.page.download': 'Download file', 'file.page.tab.diff': 'Diff', 'file.page.tab.file': 'File', + 'file.page.tab.source': 'Source', + 'file.page.tab.preview': 'Preview', 'file.page.missingPath': 'No file path provided.', 'file.page.binary': 'This looks like a binary file. It cannot be displayed.', 'file.page.imagePreviewAlt': 'Image preview for {name}', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 7518bb771e..05ad556f2d 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -340,6 +340,8 @@ export default { 'file.page.download': '下载文件', 'file.page.tab.diff': 'Diff', 'file.page.tab.file': '文件', + 'file.page.tab.source': '源码', + 'file.page.tab.preview': '预览', 'file.page.missingPath': '未提供文件路径。', 'file.page.binary': '该文件看起来是二进制文件,无法显示。', 'file.page.imagePreviewAlt': '{name} 图片预览', diff --git a/web/src/routes/sessions/file.test.tsx b/web/src/routes/sessions/file.test.tsx new file mode 100644 index 0000000000..a653e37dee --- /dev/null +++ b/web/src/routes/sessions/file.test.tsx @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { I18nProvider } from '@/lib/i18n-context' +import { encodeBase64 } from '@/lib/utils' +import FilePage from './file' + +const goBackMock = vi.fn() + +const sampleMarkdown = '# Heading\n\n| Col A | Col B |\n| --- | --- |\n| one | two |' +const filePath = 'docs/README.md' +const encodedPath = encodeBase64(filePath) +const encodedContent = encodeBase64(sampleMarkdown) + +vi.mock('@tanstack/react-router', () => ({ + useParams: () => ({ sessionId: 'session-1' }), + useSearch: () => ({ + path: encodedPath, + staged: undefined, + }), +})) + +vi.mock('@/lib/app-context', () => ({ + useAppContext: () => ({ + api: { + getGitDiffFile: vi.fn(async () => ({ success: true, stdout: '' })), + readSessionFile: vi.fn(async () => ({ + success: true, + content: encodedContent, + })), + }, + }), +})) + +vi.mock('@/hooks/useAppGoBack', () => ({ + useAppGoBack: () => goBackMock, +})) + +vi.mock('@/hooks/useCopyToClipboard', () => ({ + useCopyToClipboard: () => ({ + copied: false, + copy: vi.fn(), + }), +})) + +vi.mock('@/lib/shiki', () => ({ + langAlias: { md: 'markdown' }, + useShikiHighlighter: (content: string) => content, +})) + +vi.mock('@/components/MarkdownRenderer', () => ({ + MarkdownRenderer: (props: { content: string }) => ( +
{props.content}
+ ), +})) + +function renderWithProviders() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }) + return render( + + + + + + ) +} + +describe('FilePage markdown preview', () => { + beforeEach(() => { + vi.clearAllMocks() + window.localStorage.clear() + }) + + it('renders markdown preview by default and toggles to source', async () => { + renderWithProviders() + + await waitFor(() => { + expect(screen.getByTestId('markdown-preview')).toHaveTextContent('# Heading') + }) + expect(screen.getByRole('button', { name: 'Preview' })).toHaveClass('opacity-80') + + fireEvent.click(screen.getByRole('button', { name: 'Source' })) + + await waitFor(() => { + expect(screen.getByRole('code')).toHaveTextContent('# Heading') + }) + expect(screen.queryByTestId('markdown-preview')).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Preview' })) + await waitFor(() => { + expect(screen.getByTestId('markdown-preview')).toBeInTheDocument() + }) + }) +}) diff --git a/web/src/routes/sessions/file.tsx b/web/src/routes/sessions/file.tsx index ab2b89d961..b9df79e515 100644 --- a/web/src/routes/sessions/file.tsx +++ b/web/src/routes/sessions/file.tsx @@ -13,6 +13,13 @@ import { langAlias, useShikiHighlighter } from '@/lib/shiki' import { useTranslation } from '@/lib/use-translation' import { decodeBase64 } from '@/lib/utils' import { ImagePreview } from '@/components/ImagePreview' +import { MarkdownRenderer } from '@/components/MarkdownRenderer' +import { + getInitialMarkdownPreviewMode, + isMarkdownFile, + persistMarkdownPreviewMode, + type MarkdownPreviewMode, +} from '@/lib/file-markdown-preview' const MAX_COPYABLE_FILE_BYTES = 1_000_000 const IMAGE_MIME_BY_EXTENSION: Record = { @@ -193,6 +200,7 @@ export default function FilePage() { const filePath = useMemo(() => decodePath(encodedPath), [encodedPath]) const fileName = filePath.split('/').pop() || filePath || t('file.page.fallbackName') const imageMimeType = useMemo(() => resolveImageMimeType(filePath), [filePath]) + const markdownFile = useMemo(() => isMarkdownFile(filePath), [filePath]) const diffQuery = useQuery({ queryKey: queryKeys.gitFileDiff(sessionId, filePath, staged), @@ -234,7 +242,12 @@ export default function FilePage() { : null const language = useMemo(() => imageMimeType ? undefined : resolveLanguage(filePath), [filePath, imageMimeType]) - const highlighted = useShikiHighlighter(imageMimeType ? '' : decodedContent, language) + const [markdownMode, setMarkdownMode] = useState(getInitialMarkdownPreviewMode) + const showMarkdownSource = !markdownFile || markdownMode === 'source' + const highlighted = useShikiHighlighter( + imageMimeType || (markdownFile && !showMarkdownSource) ? '' : decodedContent, + language + ) const contentSizeBytes = useMemo( () => (decodedContent ? getUtf8ByteLength(decodedContent) : 0), [decodedContent] @@ -248,6 +261,11 @@ export default function FilePage() { const [displayMode, setDisplayMode] = useState<'diff' | 'file'>('diff') + const setMarkdownPreviewMode = (mode: MarkdownPreviewMode) => { + setMarkdownMode(mode) + persistMarkdownPreviewMode(mode) + } + useEffect(() => { if (imageMimeType) { setDisplayMode('file') @@ -313,23 +331,46 @@ export default function FilePage() {
- {diffContent ? ( + {diffContent || (markdownFile && displayMode === 'file') ? (
- - + {diffContent ? ( + <> + + + + ) : null} + {markdownFile && displayMode === 'file' ? ( + <> + {diffContent ?
) : null} @@ -364,21 +405,37 @@ export default function FilePage() { ) : ( decodedContent ? ( -
- {canCopyContent ? ( - - ) : null} -
-                                        {highlighted ?? decodedContent}
-                                    
-
+ markdownFile && !showMarkdownSource ? ( +
+ {canCopyContent ? ( + + ) : null} + +
+ ) : ( +
+ {canCopyContent ? ( + + ) : null} +
+                                            {highlighted ?? decodedContent}
+                                        
+
+ ) ) : (
{t('file.page.empty')}
) From 65e1708c78fee6011009ba8e2ce98ae2b0decabe Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sat, 11 Jul 2026 04:07:12 +0100 Subject: [PATCH 08/28] feat(web): mermaid diagram lightbox on click (#741) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web): mermaid diagram lightbox on click Click rendered mermaid blocks in chat to open a zoomable full-screen viewer. Re-renders from source in the modal with the current theme. Closes #737. Co-authored-by: Cursor * fix(web): fit mermaid lightbox to viewport on open Auto-scale diagrams to fill the viewer instead of opening at intrinsic mermaid size. Reset returns to fit; zoom label is relative to fit (100%). Co-authored-by: Cursor * fix(web): fit mermaid lightbox to device screen not inner panel Use visualViewport for fit scale, full-screen pan layer, and a floating toolbar so the diagram can use the whole display. Co-authored-by: Cursor * fix(web): show mermaid lightbox by reusing inline SVG Second mermaid.render on open often left a 0×0 SVG while fit scale was computed from the loading placeholder. Reuse the inline SVG in the modal and measure viewBox with retried fit-to-screen. Co-authored-by: Cursor * fix(web): uniquify mermaid SVG ids in lightbox clone Inlining the same mermaid markup twice duplicates element ids and breaks url(#ref) resolution in the modal copy. Prefix ids and hrefs for lightbox only. Co-authored-by: Cursor * fix(web): give mermaid lightbox SVG explicit dimensions Mermaid emits width="100%" with max-width in px; that collapses to 0×0 inside the centered lightbox layer. Derive width/height from viewBox for the uniquified lightbox clone. Co-authored-by: Cursor * fix(web): render mermaid lightbox via isolated SVG data URL String id rewrites broke mermaid's embedded CSS so only labels appeared zoomed. Rasterize the inline SVG to a data-URL img instead of duplicating markup in the DOM. Co-authored-by: Cursor * fix(web): lightbox re-renders SVG for sequence diagrams Data-URL images drop or blank some mermaid diagram types (sequence). Re-render with a modal-specific id into inline SVG on a code-bg panel, and add sequence theme variables for dark/light. Co-authored-by: Cursor * fix(web): mermaid lightbox uses inline SVG in shadow DOM Reuse the inline render in an isolated shadow root so sequence CSS stays intact, and fit the viewport from viewBox dimensions instead of the loading placeholder or width="100%" layout. Co-authored-by: Cursor * test(web): Playwright lightbox coverage per mermaid diagram type Add e2e harness and a script that opens the lightbox for each diagram kind (flowchart through kanban). Fit uses inline getBBox() so compact charts like gitGraph fill the viewport. Co-authored-by: Cursor * test(web): bounded Playwright via webServer, fix gantt fit sizing Playwright owns Vite lifecycle (no agent-spawned dev server). Fit uses viewBox unless viewBox padding is excessive (gitGraph); wide charts use width-based coverage in e2e. Co-authored-by: Cursor * chore(web): gitignore Playwright test-results Co-authored-by: Cursor * fix(web): address PR 741 bot feedback (typecheck, fit floor, gitignore) Guard lightbox open when svg is null; allow fit scale down to 0.01 while keeping 0.25 minimum for manual zoom; ignore Playwright test-results/ correctly. Co-authored-by: Cursor * test(web): Playwright asserts click expands diagram vs inline Measure inline vs lightbox bounding box after click; require visible growth (area ratio or max dimension) plus dialog + shadow SVG content. Co-authored-by: Cursor * test(web): Playwright against live HAPI session for mermaid lightbox Add seed script for a dedicated chat session, live hub Playwright suite (HAPI_LIVE=1), and dogfood doc. Live tests fail until driver serves shadow-DOM lightbox (catches gray-box regression on stale bundles). Co-authored-by: Cursor * fix(web): undo wrapper transform in lightbox fit; carry fit floor in zoom Resolves PR #741 review threads (HAPI Bot Major): 1. measureSvgIntrinsicSize / measureContentSize prefer intrinsic dimensions (viewBox -> width/height attrs -> img.naturalSize) before getBoundingClientRect. When the rect is the only signal, divide by scaleRef.current so the 50/200ms refit retries stop compounding with the wrapper's scale(...) transform. Large diagrams no longer jump tiny or oversize after async render completes. 2. Interactive zoom (wheel/keys/buttons/pinch) now clamps with Math.min(MIN_SCALE, baseScaleRef.current). A diagram fitted below the normal 25% floor stays reachable instead of snapping back to 25% and clipping. Zoom-out button disabled threshold uses the same min. 3. Add Vitest coverage for both helpers (intrinsic precedence, scale-aware rect fallback, divide-by-zero guard) so regressions surface without needing the full Playwright stack. Co-authored-by: Cursor * fix(scripts): mermaid seed refuses to wipe non-fixture sessions HAPI Bot Major (PR #741): SESSION_ID is documented as overridable, and the script unconditionally deletes every message for the target session before seeding fixtures. If pointed at a real session id, that's silent data loss. Refuse to proceed when an existing session id has a tag other than 'mermaid-lightbox-e2e'. New ids and the canonical fixture session still seed normally; real sessions throw before any DELETE runs. Co-authored-by: Cursor * fix(web): normalize mermaid svg for lightbox shadow root Mermaid emits width="100%" on every diagram. Inside a shadow root whose host has no explicit size, that collapses to zero in Chromium for most diagram types - only ones that ship pixel attrs (e.g. journey) happen to render. Operator confirmed on the live driver: every diagram except journey opened to a grey rounded square. MermaidLightboxSvg now runs normalizeMermaidSvgForStandaloneDisplay before injecting (strips width/height="100%", bakes viewBox dims as pixels) and sets :host{display:inline-block} so the host sizes to the SVG. Inline svg in chat is unchanged - only the lightbox copy is normalized. Co-authored-by: Cursor * fix(web): keep mermaid lightbox content below the toolbar Operator screenshot showed the diagram top (e.g. pie 'Pets' title) clipped behind the toolbar bar. Two causes: 1. getScreenFitSize used the full viewport height, so the fit scale sized the diagram to fill an area the toolbar overlapped. 2. The viewport (drag/zoom area) was inset-0; content centered on the full viewport center, not the visible region's center, pushing the top behind the toolbar. Measure the toolbar with a ResizeObserver, subtract its height from the fit calculation (clamped at zero), and start the viewport region below the toolbar (top: toolbarHeight). Fit scale recomputes whenever toolbar height changes. Adds Vitest coverage for getScreenFitSize reserved-top math. Co-authored-by: Cursor * fix(web): guard ResizeObserver before constructing it HAPI Bot Major (PR #741): Vitest jsdom does not polyfill ResizeObserver, so the toolbar measure effect throws ReferenceError when the existing mermaid-diagram React tests open the lightbox. Same code path is also brittle in any browser/webview without the API. Fall back to plain window 'resize' listener when ResizeObserver is absent. Toolbar height won't auto-update on element resize without it, but the lightbox still renders and the resize listener catches the common viewport-rotation case. Co-authored-by: Cursor * fix(scripts): live mermaid playwright wrapper runs from repo root HAPI Bot Minor (PR #741): the wrapper sets cwd to scripts/, but the test:mermaid-lightbox:live npm script lives in the repo-root package.json, so spawning npm there exited before Playwright started. Switch cwd to the repo root and drop the unused WEB_DIR constant. Co-authored-by: Cursor * fix(web): accept signed viewBox values in mermaid lightbox normalize HAPI Bot Minor (PR #741): the viewBox regex only matched digits, dots, and spaces, so a valid viewBox with negative origin (e.g. '-8 -8 640 480') returned null. normalizeMermaidSvgForStandaloneDisplay then became a no-op and left width='100%', re-introducing the zero-sized lightbox render this PR is meant to fix for the affected diagrams. Switch to the bot's suggested regex (signed numbers, single or double quotes, comma or space separators) and reject NaN parts. Adds Vitest coverage for signed origins, single quotes, comma separators, the malformed/no-viewBox null paths, and an end-to-end normalize test that fails against the old regex. Co-authored-by: Cursor * fix(web): align @playwright/test on 1.60.0 across workspaces HAPI Bot Major (PR #741): web/package.json pinned @playwright/test at 1.49.1 while the root workspace and bun.lock were on 1.60.0. The mismatch surfaced after rebasing onto upstream/main, where the root had already moved to 1.60.0 while my web devDependency lagged from an older commit. A frozen install would reject the lockfile and the new web e2e script could resolve a different Playwright than root scripts. Bump the web devDependency to 1.60.0 and regenerate bun.lock so all workspaces share one Playwright version. Co-authored-by: Cursor * fix(web): move mermaid playwright fixtures out of public HAPI Bot Minor (PR #741): the e2e and smoke fixtures lived under web/public, so Vite copied them verbatim into web/dist and the hub asset generator embedded them in production bundles. Both pages import Vite dev-only paths (/@react-refresh and /src/dev/...), so the production /mermaid-lightbox-{e2e,smoke}.html routes would 404 on those imports. Move both fixtures to web/e2e-fixtures/ to match the existing scratchlist-fixture pattern (relative ../src/dev import, served by Vite at /e2e-fixtures/...) and update the Playwright spec to hit the new path. Build now ships 112 PWA precache entries instead of 114 (both fixtures excluded from dist). Co-authored-by: Cursor --------- Co-authored-by: Cursor --- bun.lock | 1 + docs/tooling/mermaid-lightbox-dogfood.md | 56 ++ package.json | 3 + .../dev/mermaid-lightbox-live-playwright.mjs | 20 + scripts/dev/mermaid-lightbox-playwright.mjs | 26 + .../dev/mermaid-lightbox-seed-session-db.ts | 85 ++++ web/.gitignore | 1 + web/e2e-fixtures/mermaid-lightbox-e2e.html | 16 + web/e2e-fixtures/mermaid-lightbox-smoke.html | 16 + web/e2e/helpers/hapi-live.ts | 82 +++ web/e2e/mermaid-lightbox-session.spec.ts | 107 ++++ web/e2e/mermaid-lightbox.spec.ts | 156 ++++++ web/package.json | 6 +- web/playwright.config.ts | 33 ++ web/playwright.live.config.ts | 26 + web/src/components/ZoomableLightbox.test.ts | 155 ++++++ web/src/components/ZoomableLightbox.tsx | 480 ++++++++++++++++++ .../mermaid-diagram.live.test.tsx | 101 ++++ .../assistant-ui/mermaid-diagram.test.tsx | 84 ++- .../assistant-ui/mermaid-diagram.tsx | 239 ++++++++- .../assistant-ui/mermaid-svg-id.test.ts | 49 ++ web/src/dev/mermaid-lightbox-cases.ts | 96 ++++ web/src/dev/mermaid-lightbox-e2e.tsx | 39 ++ web/src/dev/mermaid-lightbox-smoke.tsx | 21 + web/src/lib/locales/en.ts | 6 + web/src/lib/locales/zh-CN.ts | 6 + 26 files changed, 1874 insertions(+), 36 deletions(-) create mode 100644 docs/tooling/mermaid-lightbox-dogfood.md create mode 100644 scripts/dev/mermaid-lightbox-live-playwright.mjs create mode 100644 scripts/dev/mermaid-lightbox-playwright.mjs create mode 100644 scripts/dev/mermaid-lightbox-seed-session-db.ts create mode 100644 web/e2e-fixtures/mermaid-lightbox-e2e.html create mode 100644 web/e2e-fixtures/mermaid-lightbox-smoke.html create mode 100644 web/e2e/helpers/hapi-live.ts create mode 100644 web/e2e/mermaid-lightbox-session.spec.ts create mode 100644 web/e2e/mermaid-lightbox.spec.ts create mode 100644 web/playwright.config.ts create mode 100644 web/playwright.live.config.ts create mode 100644 web/src/components/ZoomableLightbox.test.ts create mode 100644 web/src/components/ZoomableLightbox.tsx create mode 100644 web/src/components/assistant-ui/mermaid-diagram.live.test.tsx create mode 100644 web/src/components/assistant-ui/mermaid-svg-id.test.ts create mode 100644 web/src/dev/mermaid-lightbox-cases.ts create mode 100644 web/src/dev/mermaid-lightbox-e2e.tsx create mode 100644 web/src/dev/mermaid-lightbox-smoke.tsx diff --git a/bun.lock b/bun.lock index 7a40e43908..931356b579 100644 --- a/bun.lock +++ b/bun.lock @@ -132,6 +132,7 @@ "workbox-window": "^7.4.0", }, "devDependencies": { + "@playwright/test": "1.60.0", "@tailwindcss/postcss": "^4.1.18", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", diff --git a/docs/tooling/mermaid-lightbox-dogfood.md b/docs/tooling/mermaid-lightbox-dogfood.md new file mode 100644 index 0000000000..081f07e4f3 --- /dev/null +++ b/docs/tooling/mermaid-lightbox-dogfood.md @@ -0,0 +1,56 @@ +# Mermaid lightbox dogfood (Playwright) + +Two Playwright targets: + +| Target | What it exercises | Command | +|--------|-------------------|---------| +| **Component (Vite)** | `MermaidDiagram` in isolation on dev server | `npm run test:mermaid-lightbox:playwright` | +| **Live session (hub)** | Real chat thread, click-to-zoom | `npm run test:mermaid-lightbox:live` | + +## Live session (production-shaped) + +**Session URL (after seed):** + +`{HAPI_URL}/sessions/a7370000-0000-4000-8000-000000000737` + +Default `HAPI_URL` for live tests: `http://127.0.0.1:3006` (daily driver). +For tailnet: `HAPI_URL=https://hapi.tail9944ee.ts.net` (seed **that** hub's DB first). + +### 1. Seed fixtures (hub DB) + +On the machine that owns `HAPI_DB_PATH` (usually `~/.hapi/hapi.db`): + +```bash +bun run seed:mermaid-lightbox:session +``` + +Inserts 15 assistant messages (one per diagram type). Re-run to replace messages in that session. + +### 2. Deploy web with your branch + +```bash +hapi-driver-rebuild --build-web +# activate soup when ready (restarts hub) +``` + +Hard-refresh the browser after web changes. + +### 3. Run live Playwright + +```bash +HAPI_LIVE=1 HAPI_URL=http://127.0.0.1:3006 npm run test:mermaid-lightbox:live +``` + +Requires `~/.hapi/settings.json` `cliApiToken` (or `HAPI_ACCESS_TOKEN`). + +**Pass criteria:** dialog opens, SVG in **shadow root** (`[data-mermaid-lightbox]`), expands vs inline, sequence has multiple actors/lines. + +If tests report `legacy` or `empty` lightbox, the served web bundle predates the shadow-DOM fix — rebuild driver. + +## Isolation page (not chat) + +Only for component regression; **not** the same as chat: + +`http://127.0.0.1:5173/mermaid-lightbox-e2e.html?case=sequence` (Vite dev, not on tailnet dist unless you add the HTML to a build). + +Diagram sources: `web/src/dev/mermaid-lightbox-cases.ts` diff --git a/package.json b/package.json index c33c2acc89..1f5184492d 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,9 @@ "test:shared": "cd shared && bun run test", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", + "test:mermaid-lightbox:playwright": "timeout 600 node scripts/dev/mermaid-lightbox-playwright.mjs", + "test:mermaid-lightbox:live": "timeout 900 env HAPI_LIVE=1 playwright test -c web/playwright.live.config.ts", + "seed:mermaid-lightbox:session": "bun run scripts/dev/mermaid-lightbox-seed-session-db.ts", "clean-session": "bun run hub/scripts/cleanup-sessions.ts", "release-all": "cd cli && bun run release-all" }, diff --git a/scripts/dev/mermaid-lightbox-live-playwright.mjs b/scripts/dev/mermaid-lightbox-live-playwright.mjs new file mode 100644 index 0000000000..e137029cd1 --- /dev/null +++ b/scripts/dev/mermaid-lightbox-live-playwright.mjs @@ -0,0 +1,20 @@ +#!/usr/bin/env node +/** Bounded wrapper: Playwright against a real HAPI chat session (no Vite). */ +import { spawnSync } from 'node:child_process' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..') +const npmBin = process.env.NPM_BIN ?? 'npm' + +const result = spawnSync( + npmBin, + ['run', 'test:mermaid-lightbox:live'], + { + cwd: REPO_ROOT, + stdio: 'inherit', + env: { ...process.env, PATH: process.env.PATH, HAPI_LIVE: '1' }, + }, +) + +process.exit(result.status === null ? 1 : result.status) diff --git a/scripts/dev/mermaid-lightbox-playwright.mjs b/scripts/dev/mermaid-lightbox-playwright.mjs new file mode 100644 index 0000000000..6914ef73e1 --- /dev/null +++ b/scripts/dev/mermaid-lightbox-playwright.mjs @@ -0,0 +1,26 @@ +#!/usr/bin/env node +/** + * Bounded wrapper for mermaid lightbox Playwright (web/e2e). + * Vite lifecycle is owned by web/playwright.config.ts webServer — not this process. + * + * Usage (from repo root): + * npm run test:mermaid-lightbox:playwright + */ +import { spawnSync } from 'node:child_process' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const WEB_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '../../web') +const npmBin = process.env.NPM_BIN ?? 'npm' + +const result = spawnSync( + npmBin, + ['run', 'test:mermaid-lightbox:e2e'], + { + cwd: WEB_DIR, + stdio: 'inherit', + env: { ...process.env, PATH: process.env.PATH }, + }, +) + +process.exit(result.status === null ? 1 : result.status) diff --git a/scripts/dev/mermaid-lightbox-seed-session-db.ts b/scripts/dev/mermaid-lightbox-seed-session-db.ts new file mode 100644 index 0000000000..8b2fa9181b --- /dev/null +++ b/scripts/dev/mermaid-lightbox-seed-session-db.ts @@ -0,0 +1,85 @@ +/** + * Seed assistant messages with mermaid fixtures into a hub SQLite DB. + * Run on the host that owns HAPI_DB_PATH (usually the hub machine). + * + * HAPI_DB_PATH=~/.hapi/hapi.db SESSION_ID= bun run scripts/dev/mermaid-lightbox-seed-session-db.ts + */ +import { Database } from 'bun:sqlite' +import { randomUUID } from 'node:crypto' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { + MERMAID_LIGHTBOX_CASE_IDS, + MERMAID_LIGHTBOX_CASES, +} from '../../web/src/dev/mermaid-lightbox-cases' + +const dbPath = process.env.HAPI_DB_PATH ?? join(homedir(), '.hapi', 'hapi.db') +/** Stable id for mermaid Playwright live session (create if missing). */ +const sessionId = process.env.SESSION_ID ?? 'a7370000-0000-4000-8000-000000000737' +const sessionTag = 'mermaid-lightbox-e2e' + +function agentMermaidEnvelope(caseId: string, code: string) { + const text = `\n\`\`\`mermaid\n${code.trim()}\n\`\`\`` + return { + role: 'agent', + content: { + type: 'output', + data: { + type: 'assistant', + uuid: randomUUID(), + parentUuid: null, + isSidechain: false, + message: { + content: [{ type: 'text', text }], + }, + }, + }, + } +} + +const db = new Database(dbPath) +const now = Date.now() +const existing = db.prepare('SELECT id, tag FROM sessions WHERE id = ?').get(sessionId) as + | { id: string; tag: string | null } + | undefined + +if (existing && existing.tag !== sessionTag) { + throw new Error( + `Refusing to seed mermaid fixtures into session ${sessionId}: tag is ` + + `${JSON.stringify(existing.tag)}, expected ${JSON.stringify(sessionTag)}. ` + + `Unset SESSION_ID or use a session created by this script.`, + ) +} + +if (!existing) { + db.prepare(` + INSERT INTO sessions ( + id, tag, namespace, created_at, updated_at, active, seq + ) VALUES (?, ?, 'default', ?, ?, 0, 0) + `).run(sessionId, sessionTag, now, now) + console.log(`created session ${sessionId} (${sessionTag})`) +} + +const insert = db.prepare(` + INSERT INTO messages (id, session_id, content, created_at, seq, local_id, invoked_at, scheduled_at) + VALUES (?, ?, ?, ?, ?, NULL, ?, NULL) +`) + +db.prepare('DELETE FROM messages WHERE session_id = ?').run(sessionId) + +let seqRow = db.prepare('SELECT COALESCE(MAX(seq), 0) AS maxSeq FROM messages WHERE session_id = ?').get(sessionId) as { + maxSeq: number +} + +for (const caseId of MERMAID_LIGHTBOX_CASE_IDS) { + const code = MERMAID_LIGHTBOX_CASES[caseId] + const envelope = agentMermaidEnvelope(caseId, code) + const seq = (seqRow.maxSeq ?? 0) + 1 + seqRow = { maxSeq: seq } + const messageId = randomUUID() + insert.run(messageId, sessionId, JSON.stringify(envelope), now, seq, now) + console.log(`seeded ${caseId} @ seq ${seq}`) +} + +db.prepare('UPDATE sessions SET updated_at = ? WHERE id = ?').run(now, sessionId) +console.log(`Done. Open: /sessions/${sessionId}`) diff --git a/web/.gitignore b/web/.gitignore index 9ba881afd8..f9f5a543f4 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -1,3 +1,4 @@ node_modules/ dist/ dev-dist/ +test-results/ diff --git a/web/e2e-fixtures/mermaid-lightbox-e2e.html b/web/e2e-fixtures/mermaid-lightbox-e2e.html new file mode 100644 index 0000000000..a1cfc7ead9 --- /dev/null +++ b/web/e2e-fixtures/mermaid-lightbox-e2e.html @@ -0,0 +1,16 @@ + + + + + + Mermaid lightbox e2e + + + +
+ + + diff --git a/web/e2e-fixtures/mermaid-lightbox-smoke.html b/web/e2e-fixtures/mermaid-lightbox-smoke.html new file mode 100644 index 0000000000..6e18f25a52 --- /dev/null +++ b/web/e2e-fixtures/mermaid-lightbox-smoke.html @@ -0,0 +1,16 @@ + + + + + + Mermaid lightbox smoke + + + +
+ + + diff --git a/web/e2e/helpers/hapi-live.ts b/web/e2e/helpers/hapi-live.ts new file mode 100644 index 0000000000..60f5aef2f5 --- /dev/null +++ b/web/e2e/helpers/hapi-live.ts @@ -0,0 +1,82 @@ +import { readFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' +import type { Page } from '@playwright/test' + +export function getHapiBaseUrl(): string { + return (process.env.HAPI_URL ?? 'http://127.0.0.1:3006').replace(/\/$/, '') +} + +export function getMermaidTestSessionId(): string { + return process.env.SESSION_ID ?? 'a7370000-0000-4000-8000-000000000737' +} + +export function readCliAccessToken(): string { + if (process.env.HAPI_ACCESS_TOKEN?.trim()) { + return process.env.HAPI_ACCESS_TOKEN.trim() + } + const settingsPath = process.env.HAPI_SETTINGS_PATH ?? join(homedir(), '.hapi', 'settings.json') + const settings = JSON.parse(readFileSync(settingsPath, 'utf8')) as { cliApiToken?: string } + if (!settings.cliApiToken) { + throw new Error(`Missing cliApiToken in ${settingsPath}`) + } + return settings.cliApiToken +} + +export async function installHapiAuth(page: Page, baseUrl: string, accessToken: string) { + await page.addInitScript(({ token, url }) => { + localStorage.setItem(`hapi_access_token::${url}`, token) + }, { token: accessToken, url: baseUrl }) +} + +export async function scrollChatToBottom(page: Page) { + for (let i = 0; i < 24; i += 1) { + const found = await page.locator('[data-mermaid-diagram][data-rendered="true"]').count() + if (found > 0) break + await page.evaluate(() => { + const scrollers = [...document.querySelectorAll('*')].filter( + (el) => el.scrollHeight > el.clientHeight + 80, + ) + scrollers.sort((a, b) => b.scrollHeight - a.scrollHeight) + const target = scrollers[0] + if (target) target.scrollTop = target.scrollHeight + window.scrollTo(0, document.body.scrollHeight) + }) + await page.waitForTimeout(400) + } +} + +export type LiveLightboxMetrics = { + inlineW: number + inlineH: number + lightboxW: number + lightboxH: number + hasShadowSvg: boolean + shapeTotal: number + coverage: number +} + +export async function readLiveLightboxMetrics(page: Page): Promise { + return page.evaluate(() => { + const inlineSvg = document.querySelector('[data-mermaid-diagram][data-rendered="true"] svg') + const inlineBox = inlineSvg?.getBoundingClientRect() + const host = document.querySelector('[data-mermaid-lightbox]') + const lightboxSvg = host?.shadowRoot?.querySelector('svg') + const lightboxBox = lightboxSvg?.getBoundingClientRect() + const vw = window.visualViewport?.width ?? window.innerWidth + const vh = window.visualViewport?.height ?? window.innerHeight + const shapes = + (lightboxSvg?.querySelectorAll('rect').length ?? 0) + + (lightboxSvg?.querySelectorAll('path').length ?? 0) + + (lightboxSvg?.querySelectorAll('line').length ?? 0) + return { + inlineW: inlineBox?.width ?? 0, + inlineH: inlineBox?.height ?? 0, + lightboxW: lightboxBox?.width ?? 0, + lightboxH: lightboxBox?.height ?? 0, + hasShadowSvg: Boolean(lightboxSvg), + shapeTotal: shapes, + coverage: Math.max((lightboxBox?.width ?? 0) / vw, (lightboxBox?.height ?? 0) / vh), + } + }) +} diff --git a/web/e2e/mermaid-lightbox-session.spec.ts b/web/e2e/mermaid-lightbox-session.spec.ts new file mode 100644 index 0000000000..f5af7c9e59 --- /dev/null +++ b/web/e2e/mermaid-lightbox-session.spec.ts @@ -0,0 +1,107 @@ +import { expect, test } from '@playwright/test' +import { MERMAID_LIGHTBOX_CASE_IDS } from '../src/dev/mermaid-lightbox-cases' +import { + getHapiBaseUrl, + getMermaidTestSessionId, + installHapiAuth, + readCliAccessToken, + readLiveLightboxMetrics, + scrollChatToBottom, +} from './helpers/hapi-live' + +const liveEnabled = process.env.HAPI_LIVE === '1' +const MIN_COVERAGE = Number(process.env.MERMAID_E2E_MIN_COVERAGE ?? '0.35') +const MIN_EXPAND_RATIO = Number(process.env.MERMAID_E2E_MIN_EXPAND_RATIO ?? '1.25') + +test.describe.configure({ mode: 'serial' }) + +test.describe('mermaid lightbox (live HAPI session)', () => { + test.skip(!liveEnabled, 'Set HAPI_LIVE=1 to run against a real hub session') + + test.beforeAll(() => { + readCliAccessToken() + }) + + for (const caseId of MERMAID_LIGHTBOX_CASE_IDS) { + test(`live session lightbox: ${caseId}`, async ({ page }) => { + const baseUrl = getHapiBaseUrl() + const sessionId = getMermaidTestSessionId() + const token = readCliAccessToken() + + await installHapiAuth(page, baseUrl, token) + await page.goto(`${baseUrl}/sessions/${sessionId}`, { + waitUntil: 'domcontentloaded', + timeout: 60_000, + }) + await page.waitForTimeout(2000) + await scrollChatToBottom(page) + + const diagramIndex = MERMAID_LIGHTBOX_CASE_IDS.indexOf(caseId) + const rendered = page.locator('[data-mermaid-diagram][data-rendered="true"]') + await expect( + rendered, + `Expected ${MERMAID_LIGHTBOX_CASE_IDS.length} seeded diagrams. Run: bun run seed:mermaid-lightbox:session`, + ).toHaveCount(MERMAID_LIGHTBOX_CASE_IDS.length, { timeout: 20_000 }) + + const diagram = rendered.nth(diagramIndex) + + await diagram.scrollIntoViewIfNeeded() + const before = await diagram.evaluate((el) => { + const svg = el.querySelector('svg') + const box = svg?.getBoundingClientRect() + return { inlineW: box?.width ?? 0, inlineH: box?.height ?? 0 } + }) + + await diagram.click({ timeout: 15_000 }) + await page.waitForSelector('[role="dialog"]', { timeout: 10_000 }) + const lightboxKind = await page.waitForFunction(() => { + const dialog = document.querySelector('[role="dialog"]') + if (!dialog) return 'no-dialog' + const shadowSvg = dialog.querySelector('[data-mermaid-lightbox]')?.shadowRoot?.querySelector('svg') + if (shadowSvg) { + const box = shadowSvg.getBoundingClientRect() + if (box.width > 0 && box.height > 0) return 'shadow' + } + const legacySvg = dialog.querySelector('.rounded-lg svg') + if (legacySvg) { + const box = legacySvg.getBoundingClientRect() + if (box.width > 0 && box.height > 0) return 'legacy' + } + return 'empty' + }, { timeout: 15_000 }).then((h) => h.jsonValue() as Promise) + + expect( + lightboxKind, + `${caseId}: expected shadow-DOM lightbox (rebuild driver web after feat/mermaid-lightbox-737)`, + ).toBe('shadow') + + const after = await readLiveLightboxMetrics(page) + const areaRatio = + before.inlineW > 0 && before.inlineH > 0 + ? (after.lightboxW * after.lightboxH) / (before.inlineW * before.inlineH) + : 0 + + expect(after.hasShadowSvg, `${caseId}: shadow SVG in live chat`).toBe(true) + expect(after.shapeTotal, `${caseId}: diagram shapes`).toBeGreaterThan(0) + expect(after.coverage, `${caseId}: viewport coverage`).toBeGreaterThanOrEqual(MIN_COVERAGE) + expect( + areaRatio >= MIN_EXPAND_RATIO || after.lightboxW > before.inlineW * 1.05, + `${caseId}: expand ${areaRatio.toFixed(2)}x inline ${Math.round(before.inlineW)}x${Math.round(before.inlineH)} → lightbox ${Math.round(after.lightboxW)}x${Math.round(after.lightboxH)}`, + ).toBe(true) + + if (caseId === 'sequence') { + const seqShapes = await page.evaluate(() => { + const svg = document.querySelector('[data-mermaid-lightbox]')?.shadowRoot?.querySelector('svg') + return { + rect: svg?.querySelectorAll('rect').length ?? 0, + line: svg?.querySelectorAll('line').length ?? 0, + } + }) + expect(seqShapes.rect >= 2 || seqShapes.line >= 2, `${caseId}: sequence content`).toBe(true) + } + + await page.keyboard.press('Escape') + await page.waitForSelector('[role="dialog"]', { state: 'detached', timeout: 5000 }).catch(() => undefined) + }) + } +}) diff --git a/web/e2e/mermaid-lightbox.spec.ts b/web/e2e/mermaid-lightbox.spec.ts new file mode 100644 index 0000000000..2f1c7f3452 --- /dev/null +++ b/web/e2e/mermaid-lightbox.spec.ts @@ -0,0 +1,156 @@ +import { expect, test } from '@playwright/test' +import { MERMAID_LIGHTBOX_CASE_IDS } from '../src/dev/mermaid-lightbox-cases' + +const MIN_COVERAGE = Number(process.env.MERMAID_E2E_MIN_COVERAGE ?? '0.35') +const MIN_SVG_PX = Number(process.env.MERMAID_E2E_MIN_SVG_PX ?? '200') + +type LightboxMetrics = { + hasShadowSvg: boolean + usesDataUrlImg: boolean + svgW: number + svgH: number + coverageW: number + coverageH: number + shapeTotal: number + shapes: { rect: number; path: number; line: number } +} + +type ExpandMetrics = { + inlineW: number + inlineH: number + lightboxW: number + lightboxH: number + areaRatio: number +} + +async function readExpandMetrics(page: import('@playwright/test').Page): Promise { + return page.evaluate(() => { + const inlineSvg = document.querySelector('[data-mermaid-diagram][data-rendered="true"] svg') + const inlineBox = inlineSvg?.getBoundingClientRect() + const host = document.querySelector('[data-mermaid-lightbox]') + const lightboxSvg = host?.shadowRoot?.querySelector('svg') + const lightboxBox = lightboxSvg?.getBoundingClientRect() + const inlineArea = (inlineBox?.width ?? 0) * (inlineBox?.height ?? 0) + const lightboxArea = (lightboxBox?.width ?? 0) * (lightboxBox?.height ?? 0) + return { + inlineW: inlineBox?.width ?? 0, + inlineH: inlineBox?.height ?? 0, + lightboxW: lightboxBox?.width ?? 0, + lightboxH: lightboxBox?.height ?? 0, + areaRatio: inlineArea > 0 ? lightboxArea / inlineArea : 0, + } + }) +} + +async function readLightboxMetrics(page: import('@playwright/test').Page): Promise { + return page.evaluate(() => { + const dialog = document.querySelector('[role="dialog"]') + const host = dialog?.querySelector('[data-mermaid-lightbox]') + const svg = host?.shadowRoot?.querySelector('svg') + const box = svg?.getBoundingClientRect() + const vw = window.visualViewport?.width ?? window.innerWidth + const vh = window.visualViewport?.height ?? window.innerHeight + const shapes = { + rect: svg?.querySelectorAll('rect').length ?? 0, + path: svg?.querySelectorAll('path').length ?? 0, + line: svg?.querySelectorAll('line').length ?? 0, + } + const shapeTotal = + shapes.rect + + shapes.path + + shapes.line + + (svg?.querySelectorAll('text').length ?? 0) + + (svg?.querySelectorAll('circle').length ?? 0) + return { + hasShadowSvg: Boolean(svg), + usesDataUrlImg: Boolean(dialog?.querySelector('img[src^="data:image/svg"]')), + svgW: box?.width ?? 0, + svgH: box?.height ?? 0, + coverageW: (box?.width ?? 0) / vw, + coverageH: (box?.height ?? 0) / vh, + shapeTotal, + shapes, + } + }) +} + +function assertMetrics(caseId: string, metrics: LightboxMetrics) { + expect(metrics.hasShadowSvg, `${caseId}: shadow-root svg`).toBe(true) + expect(metrics.usesDataUrlImg, `${caseId}: data-url img regression`).toBe(false) + expect( + metrics.svgW >= MIN_SVG_PX || metrics.svgH >= MIN_SVG_PX, + `${caseId}: svg ${Math.round(metrics.svgW)}x${Math.round(metrics.svgH)}px`, + ).toBe(true) + const coverage = Math.max(metrics.coverageW, metrics.coverageH) + const wideShort = caseId === 'gantt' || caseId === 'kanban' + if (wideShort) { + expect( + metrics.coverageW >= MIN_COVERAGE || metrics.svgW >= 600, + `${caseId}: wide chart width cov ${(metrics.coverageW * 100).toFixed(0)}%`, + ).toBe(true) + } else { + expect(coverage, `${caseId}: viewport coverage ${(coverage * 100).toFixed(0)}%`).toBeGreaterThanOrEqual( + MIN_COVERAGE, + ) + } + expect(metrics.shapeTotal, `${caseId}: shape count`).toBeGreaterThan(0) + if (caseId === 'sequence') { + expect( + metrics.shapes.rect >= 2 || metrics.shapes.line >= 2, + `${caseId}: sequence actors/lines`, + ).toBe(true) + } +} + +/** Lightbox should be visibly larger than the inline chat preview after click. */ +const MIN_EXPAND_AREA_RATIO = Number(process.env.MERMAID_E2E_MIN_EXPAND_RATIO ?? '1.4') + +for (const caseId of MERMAID_LIGHTBOX_CASE_IDS) { + test(`mermaid lightbox: ${caseId}`, async ({ page }) => { + await page.goto(`/e2e-fixtures/mermaid-lightbox-e2e.html?case=${encodeURIComponent(caseId)}`) + await page.waitForSelector('[data-mermaid-diagram][data-rendered="true"]', { timeout: 20_000 }) + + const beforeExpand = await readExpandMetrics(page) + expect(beforeExpand.lightboxW, `${caseId}: dialog closed before click`).toBe(0) + + await page.locator('[data-mermaid-diagram][data-rendered="true"]').click() + await page.waitForSelector('[role="dialog"]', { timeout: 10_000 }) + await page.waitForFunction(() => { + const host = document.querySelector('[data-mermaid-lightbox]') + const svg = host?.shadowRoot?.querySelector('svg') + const box = svg?.getBoundingClientRect() + return Boolean(box && box.width > 0 && box.height > 0) + }, { timeout: 15_000 }) + + await page + .waitForFunction( + (minCoverage) => { + const host = document.querySelector('[data-mermaid-lightbox]') + const svg = host?.shadowRoot?.querySelector('svg') + const box = svg?.getBoundingClientRect() + if (!box || box.width <= 0) return false + const vw = window.visualViewport?.width ?? window.innerWidth + const vh = window.visualViewport?.height ?? window.innerHeight + return Math.max(box.width / vw, box.height / vh) >= minCoverage + }, + MIN_COVERAGE, + { timeout: 8_000 }, + ) + .catch(() => undefined) + + const expand = await readExpandMetrics(page) + const inlineMax = Math.max(expand.inlineW, expand.inlineH) + const lightboxMax = Math.max(expand.lightboxW, expand.lightboxH) + const expandedVisibly = + expand.areaRatio >= MIN_EXPAND_AREA_RATIO || lightboxMax > inlineMax * 1.05 + expect(expandedVisibly, `${caseId}: expand inline ${Math.round(expand.inlineW)}x${Math.round(expand.inlineH)} → lightbox ${Math.round(expand.lightboxW)}x${Math.round(expand.lightboxH)}`).toBe(true) + + const metrics = await readLightboxMetrics(page) + assertMetrics(caseId, metrics) + + test.info().annotations.push({ + type: 'expand', + description: `${caseId}: inline ${Math.round(expand.inlineW)}x${Math.round(expand.inlineH)} → lightbox ${Math.round(expand.lightboxW)}x${Math.round(expand.lightboxH)} (${expand.areaRatio.toFixed(1)}x area)`, + }) + }) +} diff --git a/web/package.json b/web/package.json index ba28bffca1..f6c8904e85 100644 --- a/web/package.json +++ b/web/package.json @@ -9,7 +9,8 @@ "build": "vite build && cp dist/index.html dist/404.html", "typecheck": "tsc --noEmit", "preview": "vite preview", - "test": "vitest run" + "test": "vitest run", + "test:mermaid-lightbox:e2e": "playwright test e2e/mermaid-lightbox.spec.ts" }, "dependencies": { "@assistant-ui/react": "^0.11.53", @@ -67,6 +68,7 @@ "typescript": "^5.9.3", "unified": "^11.0.5", "vite": "^7.3.0", - "vitest": "^4.0.16" + "vitest": "^4.0.16", + "@playwright/test": "1.60.0" } } diff --git a/web/playwright.config.ts b/web/playwright.config.ts new file mode 100644 index 0000000000..e80617226a --- /dev/null +++ b/web/playwright.config.ts @@ -0,0 +1,33 @@ +import { defineConfig, devices } from '@playwright/test' + +const chromePath = process.env.PLAYWRIGHT_CHROME_PATH + +export default defineConfig({ + testDir: './e2e', + timeout: 45_000, + expect: { timeout: 15_000 }, + fullyParallel: false, + workers: 1, + reporter: [['list']], + use: { + ...devices['Desktop Chrome'], + baseURL: 'http://127.0.0.1:5173', + viewport: { width: 1440, height: 900 }, + ...(chromePath + ? { + launchOptions: { + executablePath: chromePath, + args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'], + }, + } + : {}), + }, + webServer: { + command: 'npm run dev', + url: 'http://127.0.0.1:5173', + reuseExistingServer: !process.env.CI, + timeout: 120_000, + stdout: 'pipe', + stderr: 'pipe', + }, +}) diff --git a/web/playwright.live.config.ts b/web/playwright.live.config.ts new file mode 100644 index 0000000000..73b93f45af --- /dev/null +++ b/web/playwright.live.config.ts @@ -0,0 +1,26 @@ +import { defineConfig, devices } from '@playwright/test' + +const chromePath = process.env.PLAYWRIGHT_CHROME_PATH + +/** Live hub session tests — no Vite; hits HAPI_URL with HAPI_LIVE=1. */ +export default defineConfig({ + testDir: './e2e', + testMatch: 'mermaid-lightbox-session.spec.ts', + timeout: 120_000, + expect: { timeout: 20_000 }, + fullyParallel: false, + workers: 1, + reporter: [['list']], + use: { + ...devices['Desktop Chrome'], + viewport: { width: 1440, height: 1100 }, + ...(chromePath + ? { + launchOptions: { + executablePath: chromePath, + args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'], + }, + } + : {}), + }, +}) diff --git a/web/src/components/ZoomableLightbox.test.ts b/web/src/components/ZoomableLightbox.test.ts new file mode 100644 index 0000000000..c8cd542bf0 --- /dev/null +++ b/web/src/components/ZoomableLightbox.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from 'vitest' +import { getScreenFitSize, measureContentSize, measureSvgIntrinsicSize } from './ZoomableLightbox' + +type Rect = { width: number; height: number } + +function makeSvg(opts: { + viewBox?: { width: number; height: number } + widthAttr?: string | null + heightAttr?: string | null + rect?: Rect | null +}): SVGSVGElement { + const svg = { + viewBox: opts.viewBox + ? { baseVal: { width: opts.viewBox.width, height: opts.viewBox.height } } + : { baseVal: { width: 0, height: 0 } }, + getAttribute(name: string) { + if (name === 'width') return opts.widthAttr ?? null + if (name === 'height') return opts.heightAttr ?? null + return null + }, + getBoundingClientRect() { + return opts.rect ?? { width: 0, height: 0 } + }, + } + return svg as unknown as SVGSVGElement +} + +function makeContent(opts: { + img?: { naturalWidth: number; naturalHeight: number; rect?: Rect } | null + svg?: SVGSVGElement | null + rect?: Rect | null +}): HTMLElement { + const queryResults = new Map() + if (opts.img) { + queryResults.set('img', { + naturalWidth: opts.img.naturalWidth, + naturalHeight: opts.img.naturalHeight, + getBoundingClientRect: () => opts.img?.rect ?? { width: 0, height: 0 }, + }) + } + if (opts.svg) queryResults.set('svg', opts.svg) + const content = { + querySelector(selector: string) { + return queryResults.get(selector) ?? null + }, + getBoundingClientRect() { + return opts.rect ?? { width: 0, height: 0 } + }, + } + return content as unknown as HTMLElement +} + +describe('measureSvgIntrinsicSize', () => { + it('prefers viewBox over the (possibly transformed) bounding rect', () => { + const svg = makeSvg({ + viewBox: { width: 1200, height: 900 }, + rect: { width: 60, height: 45 }, + }) + expect(measureSvgIntrinsicSize(svg, 0.05)).toEqual({ width: 1200, height: 900 }) + }) + + it('falls back to width/height attributes when viewBox is missing', () => { + const svg = makeSvg({ widthAttr: '640', heightAttr: '480' }) + expect(measureSvgIntrinsicSize(svg)).toEqual({ width: 640, height: 480 }) + }) + + it('divides bounding rect by the current scale to undo wrapper transform', () => { + const svg = makeSvg({ rect: { width: 200, height: 100 } }) + expect(measureSvgIntrinsicSize(svg, 0.5)).toEqual({ width: 400, height: 200 }) + }) + + it('returns null when no source is usable', () => { + const svg = makeSvg({}) + expect(measureSvgIntrinsicSize(svg)).toBeNull() + }) +}) + +describe('getScreenFitSize', () => { + const originalVisualViewport = Object.getOwnPropertyDescriptor(window, 'visualViewport') + const originalInnerWidth = window.innerWidth + const originalInnerHeight = window.innerHeight + + function setViewport(width: number, height: number) { + Object.defineProperty(window, 'visualViewport', { + configurable: true, + value: { width, height }, + }) + } + + function clearViewport() { + Object.defineProperty(window, 'visualViewport', { configurable: true, value: null }) + Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalInnerWidth }) + Object.defineProperty(window, 'innerHeight', { configurable: true, value: originalInnerHeight }) + } + + function restore() { + if (originalVisualViewport) { + Object.defineProperty(window, 'visualViewport', originalVisualViewport) + } + } + + it('subtracts the reserved top region (toolbar) from height', () => { + setViewport(1000, 800) + try { + expect(getScreenFitSize(40)).toEqual({ width: 1000, height: 760 }) + } finally { + restore() + } + }) + + it('clamps reserved height at zero (no negative regions)', () => { + setViewport(800, 100) + try { + expect(getScreenFitSize(200)).toEqual({ width: 800, height: 0 }) + } finally { + restore() + } + }) + + it('falls back to window inner size when visualViewport is unavailable', () => { + clearViewport() + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 }) + Object.defineProperty(window, 'innerHeight', { configurable: true, value: 900 }) + try { + expect(getScreenFitSize(60)).toEqual({ width: 1280, height: 840 }) + } finally { + restore() + } + }) +}) + +describe('measureContentSize', () => { + it('prefers img.naturalSize over its bounding rect', () => { + const content = makeContent({ + img: { naturalWidth: 800, naturalHeight: 600, rect: { width: 80, height: 60 } }, + }) + expect(measureContentSize(content, 0.1)).toEqual({ width: 800, height: 600 }) + }) + + it('uses svg intrinsic size when no img is present', () => { + const svg = makeSvg({ viewBox: { width: 500, height: 250 } }) + const content = makeContent({ svg }) + expect(measureContentSize(content, 0.5)).toEqual({ width: 500, height: 250 }) + }) + + it('divides the host rect by current scale as the last fallback', () => { + const content = makeContent({ rect: { width: 100, height: 50 } }) + expect(measureContentSize(content, 0.5)).toEqual({ width: 200, height: 100 }) + }) + + it('treats non-positive scale as identity to avoid divide-by-zero', () => { + const content = makeContent({ rect: { width: 100, height: 100 } }) + expect(measureContentSize(content, 0)).toEqual({ width: 100, height: 100 }) + }) +}) diff --git a/web/src/components/ZoomableLightbox.tsx b/web/src/components/ZoomableLightbox.tsx new file mode 100644 index 0000000000..503a8e17ef --- /dev/null +++ b/web/src/components/ZoomableLightbox.tsx @@ -0,0 +1,480 @@ +import { useCallback, useEffect, useLayoutEffect, useRef, useState, type PointerEvent, type ReactNode, type WheelEvent } from 'react' +import { CloseIcon } from '@/components/icons' + +const MIN_SCALE = 0.25 +/** Floor for fit-to-screen only; dense diagrams can need under 25% to fit. */ +const MIN_FIT_SCALE = 0.01 +const MAX_SCALE = 8 +const SCALE_STEP = 0.25 +const BACKDROP_CLICK_MAX_MOVEMENT = 4 +/** Edge margin when fitting to the device screen (not the inner panel only). */ +const SCREEN_FIT_PADDING_PX = 12 + +export function getScreenFitSize(reservedTopPx = 0): { width: number; height: number } { + const viewport = window.visualViewport + const width = viewport ? viewport.width : window.innerWidth + const fullHeight = viewport ? viewport.height : window.innerHeight + const reserved = Math.max(0, reservedTopPx) + return { width, height: Math.max(0, fullHeight - reserved) } +} + +type Point = { x: number; y: number } + +function clampScale(value: number, minScale = MIN_SCALE): number { + return Math.min(MAX_SCALE, Math.max(minScale, value)) +} + +function getPointDistance(a: Point, b: Point): number { + return Math.hypot(a.x - b.x, a.y - b.y) +} + +function getPointCenter(a: Point, b: Point): Point { + return { + x: (a.x + b.x) / 2, + y: (a.y + b.y) / 2, + } +} + +/** + * Measure SVG intrinsic size, ignoring the wrapper's `scale(...)` transform. + * Order: viewBox -> width/height attrs -> bounding rect divided by current scale. + */ +export function measureSvgIntrinsicSize( + svg: SVGSVGElement, + currentScale = 1, +): { width: number; height: number } | null { + const viewBox = svg.viewBox?.baseVal + if (viewBox && viewBox.width > 0 && viewBox.height > 0) { + return { width: viewBox.width, height: viewBox.height } + } + + const widthAttr = svg.getAttribute('width') ?? '' + const heightAttr = svg.getAttribute('height') ?? '' + const parsedWidth = Number.parseFloat(widthAttr) + const parsedHeight = Number.parseFloat(heightAttr) + if (parsedWidth > 0 && parsedHeight > 0) { + return { width: parsedWidth, height: parsedHeight } + } + + const safeScale = currentScale > 0 ? currentScale : 1 + const box = svg.getBoundingClientRect() + if (box.width > 0 && box.height > 0) { + return { width: box.width / safeScale, height: box.height / safeScale } + } + + return null +} + +/** + * Measure rendered content size, ignoring any ancestor `scale(...)` transform. + * Prefer intrinsic dimensions (img.naturalSize, SVG viewBox/attrs) before the + * bounding rect, which otherwise compounds with `scaleRef.current` on retry. + */ +export function measureContentSize( + content: HTMLElement, + currentScale = 1, +): { width: number; height: number } | null { + const safeScale = currentScale > 0 ? currentScale : 1 + + const img = content.querySelector('img') + if (img) { + if (img.naturalWidth > 0 && img.naturalHeight > 0) { + return { width: img.naturalWidth, height: img.naturalHeight } + } + const box = img.getBoundingClientRect() + if (box.width > 0 && box.height > 0) { + return { width: box.width / safeScale, height: box.height / safeScale } + } + } + + const svg = content.querySelector('svg') + if (svg) { + const intrinsic = measureSvgIntrinsicSize(svg, safeScale) + if (intrinsic) return intrinsic + } + + const rect = content.getBoundingClientRect() + if (rect.width > 0 && rect.height > 0) { + return { width: rect.width / safeScale, height: rect.height / safeScale } + } + + return null +} + +export type ZoomableLightboxProps = { + open: boolean + onClose: () => void + title?: string + ariaLabel: string + children: ReactNode + /** When set, re-fit viewport when this value changes (e.g. after async SVG load). */ + fitContentKey?: string | number | null + /** Intrinsic content size for fit (e.g. mermaid viewBox) when layout is not measurable yet. */ + fitContentSize?: { width: number; height: number } | null + /** Compute initial scale to fill the device screen (default true). */ + fitOnOpen?: boolean +} + +export function ZoomableLightbox(props: ZoomableLightboxProps) { + const { + open, + onClose, + title, + ariaLabel, + children, + fitContentKey = null, + fitContentSize = null, + fitOnOpen = true, + } = props + const [scale, setScale] = useState(1) + const [offset, setOffset] = useState({ x: 0, y: 0 }) + const [toolbarHeight, setToolbarHeight] = useState(0) + const scaleRef = useRef(scale) + const offsetRef = useRef(offset) + const baseScaleRef = useRef(1) + const viewportRef = useRef(null) + const contentRef = useRef(null) + const toolbarRef = useRef(null) + const activePointersRef = useRef(new Map()) + const dragRef = useRef<{ pointerId: number; startX: number; startY: number; originX: number; originY: number } | null>(null) + const pinchRef = useRef<{ startDistance: number; startScale: number; startCenter: Point; origin: Point } | null>(null) + const backdropPressRef = useRef<{ pointerId: number; x: number; y: number } | null>(null) + + const updateScale = useCallback((next: number | ((current: number) => number)) => { + setScale((current) => { + const value = typeof next === 'function' ? next(current) : next + scaleRef.current = value + return value + }) + }, []) + + const updateOffset = useCallback((next: Point) => { + offsetRef.current = next + setOffset(next) + }, []) + + const applyFitScale = useCallback(() => { + if (!fitOnOpen) { + baseScaleRef.current = 1 + updateScale(1) + updateOffset({ x: 0, y: 0 }) + return + } + + const content = contentRef.current + if (!content) return + + const contentSize = fitContentSize ?? measureContentSize(content, scaleRef.current) + if (!contentSize) return + + const screen = getScreenFitSize(toolbarHeight) + const pad = SCREEN_FIT_PADDING_PX * 2 + const fitWidth = (screen.width - pad) / contentSize.width + const fitHeight = (screen.height - pad) / contentSize.height + const fitScale = clampScale(Math.min(fitWidth, fitHeight), MIN_FIT_SCALE) + + baseScaleRef.current = fitScale + updateScale(fitScale) + updateOffset({ x: 0, y: 0 }) + }, [fitContentSize, fitOnOpen, toolbarHeight, updateOffset, updateScale]) + + const resetView = useCallback(() => { + updateScale(baseScaleRef.current) + updateOffset({ x: 0, y: 0 }) + }, [updateOffset, updateScale]) + + const closeViewer = useCallback(() => { + onClose() + activePointersRef.current.clear() + dragRef.current = null + pinchRef.current = null + backdropPressRef.current = null + baseScaleRef.current = 1 + updateScale(1) + updateOffset({ x: 0, y: 0 }) + }, [onClose, updateOffset, updateScale]) + + /** + * Lower bound for interactive zoom. Carries the fit floor when the diagram + * was opened below MIN_SCALE (e.g. 0.05); otherwise sticks at MIN_SCALE. + */ + const getMinInteractiveScale = useCallback(() => { + return Math.min(MIN_SCALE, baseScaleRef.current) + }, []) + + const zoomBy = useCallback((delta: number) => { + updateScale((current) => clampScale(current + delta, getMinInteractiveScale())) + }, [getMinInteractiveScale, updateScale]) + + const handleWheel = useCallback((event: WheelEvent) => { + event.preventDefault() + const delta = event.deltaY < 0 ? SCALE_STEP : -SCALE_STEP + zoomBy(delta) + }, [zoomBy]) + + const beginPinch = useCallback(() => { + const pointers = Array.from(activePointersRef.current.values()) + if (pointers.length < 2) return + + const [first, second] = pointers + pinchRef.current = { + startDistance: getPointDistance(first, second), + startScale: scaleRef.current, + startCenter: getPointCenter(first, second), + origin: offsetRef.current, + } + dragRef.current = null + }, []) + + const handlePointerDown = useCallback((event: PointerEvent) => { + if (event.button !== 0) return + event.currentTarget.setPointerCapture(event.pointerId) + activePointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY }) + backdropPressRef.current = event.target === event.currentTarget + ? { pointerId: event.pointerId, x: event.clientX, y: event.clientY } + : null + + if (activePointersRef.current.size >= 2) { + backdropPressRef.current = null + beginPinch() + return + } + + dragRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + originX: offsetRef.current.x, + originY: offsetRef.current.y, + } + }, [beginPinch]) + + const handlePointerMove = useCallback((event: PointerEvent) => { + if (!activePointersRef.current.has(event.pointerId)) return + activePointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY }) + + if (activePointersRef.current.size >= 2 && pinchRef.current) { + const pointers = Array.from(activePointersRef.current.values()) + const [first, second] = pointers + const distance = getPointDistance(first, second) + const center = getPointCenter(first, second) + const pinch = pinchRef.current + const nextScale = pinch.startDistance > 0 + ? clampScale( + pinch.startScale * (distance / pinch.startDistance), + getMinInteractiveScale(), + ) + : pinch.startScale + + updateScale(nextScale) + updateOffset({ + x: pinch.origin.x + center.x - pinch.startCenter.x, + y: pinch.origin.y + center.y - pinch.startCenter.y, + }) + return + } + + const drag = dragRef.current + if (!drag || drag.pointerId !== event.pointerId) return + updateOffset({ + x: drag.originX + event.clientX - drag.startX, + y: drag.originY + event.clientY - drag.startY, + }) + }, [getMinInteractiveScale, updateOffset, updateScale]) + + const handlePointerUp = useCallback((event: PointerEvent) => { + const backdropPress = backdropPressRef.current + const moved = backdropPress + ? Math.hypot(event.clientX - backdropPress.x, event.clientY - backdropPress.y) + : Number.POSITIVE_INFINITY + const shouldCloseFromBackdrop = event.type === 'pointerup' + && backdropPress?.pointerId === event.pointerId + && event.target === event.currentTarget + && activePointersRef.current.size === 1 + && moved <= BACKDROP_CLICK_MAX_MOVEMENT + + activePointersRef.current.delete(event.pointerId) + if (backdropPress?.pointerId === event.pointerId) { + backdropPressRef.current = null + } + if (dragRef.current?.pointerId === event.pointerId) { + dragRef.current = null + } + pinchRef.current = null + + const remainingPointer = activePointersRef.current.entries().next().value as [number, Point] | undefined + if (remainingPointer) { + dragRef.current = { + pointerId: remainingPointer[0], + startX: remainingPointer[1].x, + startY: remainingPointer[1].y, + originX: offsetRef.current.x, + originY: offsetRef.current.y, + } + } + if (shouldCloseFromBackdrop) { + closeViewer() + } + }, [closeViewer]) + + useLayoutEffect(() => { + if (!open) return + if (fitOnOpen && !fitContentKey) return + + let frame = 0 + let attempt = 0 + const maxAttempts = 16 + + const scheduleFit = () => { + frame = requestAnimationFrame(() => { + const content = contentRef.current + const hadSize = fitContentSize ?? (content ? measureContentSize(content) : null) + applyFitScale() + attempt += 1 + if (!hadSize && attempt < maxAttempts) { + scheduleFit() + } + }) + } + + scheduleFit() + const retry = window.setTimeout(scheduleFit, 50) + const lateRetry = window.setTimeout(scheduleFit, 200) + + return () => { + cancelAnimationFrame(frame) + window.clearTimeout(retry) + window.clearTimeout(lateRetry) + } + }, [fitContentKey, fitContentSize, fitOnOpen, open, applyFitScale]) + + useLayoutEffect(() => { + if (!open) return undefined + const toolbar = toolbarRef.current + if (!toolbar) return undefined + + const apply = () => { + const next = toolbar.getBoundingClientRect().height + setToolbarHeight((current) => (Math.abs(current - next) < 0.5 ? current : next)) + } + + apply() + window.addEventListener('resize', apply) + + if (typeof ResizeObserver === 'undefined') { + return () => window.removeEventListener('resize', apply) + } + + const resize = new ResizeObserver(apply) + resize.observe(toolbar) + return () => { + resize.disconnect() + window.removeEventListener('resize', apply) + } + }, [open]) + + useEffect(() => { + if (!open) return + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + closeViewer() + } + if (event.key === '0') { + resetView() + } + if (event.key === '+' || event.key === '=') { + zoomBy(SCALE_STEP) + } + if (event.key === '-') { + zoomBy(-SCALE_STEP) + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [closeViewer, open, resetView, zoomBy]) + + if (!open) return null + + const baseScale = baseScaleRef.current + const zoomLabel = baseScale > 0 + ? `${Math.round((scale / baseScale) * 100)}%` + : `${Math.round(scale * 100)}%` + const minInteractiveScale = Math.min(MIN_SCALE, baseScale) + + return ( +
+
+
+ {children} +
+
+
event.stopPropagation()} + > +
+
{title ?? ariaLabel}
+ + + + +
+
+
+ ) +} diff --git a/web/src/components/assistant-ui/mermaid-diagram.live.test.tsx b/web/src/components/assistant-ui/mermaid-diagram.live.test.tsx new file mode 100644 index 0000000000..e026389b11 --- /dev/null +++ b/web/src/components/assistant-ui/mermaid-diagram.live.test.tsx @@ -0,0 +1,101 @@ +import type React from 'react' +import { describe, expect, it } from 'vitest' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { MermaidDiagram } from '@/components/assistant-ui/mermaid-diagram' +import { I18nProvider } from '@/lib/i18n-context' + +function installSvgBBoxPolyfill() { + const bbox = () => ({ + x: 0, + y: 0, + width: 120, + height: 24, + top: 0, + left: 0, + right: 120, + bottom: 24, + toJSON() { + return {} + }, + }) + + for (const proto of [Element.prototype, HTMLElement.prototype, SVGElement.prototype]) { + if (proto && !('getBBox' in proto)) { + Object.defineProperty(proto, 'getBBox', { + configurable: true, + value: bbox, + }) + } + } +} + +const sequenceDiagram = `sequenceDiagram + participant U as Operator + participant C as Chat + participant M as Mermaid + U->>C: Send message + C->>M: Render SVG + U->>M: Click diagram + M-->>U: Lightbox + zoom` + +const defaultComponents = { + Pre: (props: React.ComponentProps<'pre'>) =>
,
+    Code: (props: React.ComponentProps<'code'>) => ,
+}
+
+async function expectLightboxShowsDiagram(code: string) {
+    installSvgBBoxPolyfill()
+
+    render(
+        
+            
+        ,
+    )
+
+    await waitFor(
+        () => {
+            expect(document.querySelector('[data-mermaid-diagram][data-rendered="true"] svg')).toBeTruthy()
+        },
+        { timeout: 10000 },
+    )
+
+    fireEvent.click(document.querySelector('[data-mermaid-diagram][data-rendered="true"]') as HTMLButtonElement)
+
+    await waitFor(
+        () => {
+            const dialog = screen.getByRole('dialog', { name: 'Diagram' })
+            const host = dialog.querySelector('[data-mermaid-lightbox]')
+            expect(host).toBeTruthy()
+            const lightboxSvg = host?.shadowRoot?.querySelector('svg')
+            expect(lightboxSvg).toBeTruthy()
+            expect(lightboxSvg?.querySelector('path, line, rect')).toBeTruthy()
+            if (code.includes('sequenceDiagram')) {
+                const actorRects = lightboxSvg?.querySelectorAll('rect.actor, g.actor rect, rect').length ?? 0
+                expect(actorRects).toBeGreaterThanOrEqual(3)
+            }
+        },
+        { timeout: 10000 },
+    )
+}
+
+describe('MermaidDiagram live render', () => {
+    it(
+        'renders real mermaid source to svg in jsdom',
+        async () => {
+            await expectLightboxShowsDiagram('flowchart LR\n  Hub --> WebUI\n  WebUI --> SVG')
+        },
+        20_000,
+    )
+
+    it(
+        'renders sequence diagrams in the lightbox',
+        async () => {
+            await expectLightboxShowsDiagram(sequenceDiagram)
+        },
+        20_000,
+    )
+})
diff --git a/web/src/components/assistant-ui/mermaid-diagram.test.tsx b/web/src/components/assistant-ui/mermaid-diagram.test.tsx
index 463d44d853..89c0e050cc 100644
--- a/web/src/components/assistant-ui/mermaid-diagram.test.tsx
+++ b/web/src/components/assistant-ui/mermaid-diagram.test.tsx
@@ -1,5 +1,7 @@
+import type { ComponentProps } from 'react'
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { cleanup, render, waitFor } from '@testing-library/react'
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { I18nProvider } from '@/lib/i18n-context'
 
 const mermaidMocks = vi.hoisted(() => ({
     initializeMock: vi.fn(),
@@ -20,16 +22,16 @@ vi.mock('mermaid', () => ({
 import { MermaidDiagram } from '@/components/assistant-ui/mermaid-diagram'
 import { MARKDOWN_COMPONENTS_BY_LANGUAGE } from '@/components/assistant-ui/markdown-text'
 
-function renderMermaid(code: string) {
+const defaultComponents = {
+    Pre: (props: ComponentProps<'pre'>) => 
,
+    Code: (props: ComponentProps<'code'>) => ,
+}
+
+function renderDiagram(props: ComponentProps) {
     return render(
-         
,
-                Code: (props) => ,
-            }}
-        />
+        
+            
+        ,
     )
 }
 
@@ -41,7 +43,7 @@ describe('MermaidDiagram', () => {
         mermaidMocks.parseMock.mockResolvedValue({ diagramType: 'flowchart-v2' })
         mermaidMocks.renderMock.mockReset()
         mermaidMocks.renderMock.mockResolvedValue({
-            svg: ''
+            svg: '',
         })
     })
 
@@ -51,7 +53,11 @@ describe('MermaidDiagram', () => {
     })
 
     it('is wired into the shared markdown language overrides and renders svg output', async () => {
-        renderMermaid('graph TD\nA --> B')
+        renderDiagram({
+            code: 'graph TD\nA --> B',
+            language: 'mermaid',
+            components: defaultComponents,
+        })
 
         await waitFor(() => {
             const diagram = document.querySelector('[data-mermaid-diagram][data-rendered="true"]')
@@ -73,7 +79,11 @@ describe('MermaidDiagram', () => {
         document.documentElement.dataset.theme = 'dark'
         mermaidMocks.parseMock.mockResolvedValueOnce(false)
 
-        renderMermaid('graph TD\nA --')
+        renderDiagram({
+            code: 'graph TD\nA --',
+            language: 'mermaid',
+            components: defaultComponents,
+        })
 
         await waitFor(() => {
             const fallback = document.querySelector('.aui-mermaid-fallback')
@@ -90,7 +100,11 @@ describe('MermaidDiagram', () => {
         mermaidMocks.renderMock.mockRejectedValueOnce(new Error('render failed'))
         const code = 'gantt\ndateFormat YYYY-MM-DD\nsection A\nTask :a, 2024-01-01'
 
-        renderMermaid(code)
+        renderDiagram({
+            code,
+            language: 'mermaid',
+            components: defaultComponents,
+        })
 
         await waitFor(() => {
             const fallback = document.querySelector('.aui-mermaid-fallback')
@@ -104,4 +118,46 @@ describe('MermaidDiagram', () => {
         }))
     })
 
+    it('opens a zoomable lightbox when the rendered diagram is clicked', async () => {
+        renderDiagram({
+            code: 'graph TD\nA --> B',
+            language: 'mermaid',
+            components: defaultComponents,
+        })
+
+        await waitFor(() => {
+            expect(document.querySelector('[data-mermaid-diagram][data-rendered="true"]')).toBeTruthy()
+        })
+
+        fireEvent.click(document.querySelector('[data-mermaid-diagram][data-rendered="true"]') as HTMLButtonElement)
+
+        await waitFor(() => {
+            const dialog = screen.getByRole('dialog', { name: 'Diagram' })
+            const host = dialog.querySelector('[data-mermaid-lightbox]')
+            expect(host?.shadowRoot?.querySelector('[data-testid="mock-mermaid"]')).toBeTruthy()
+        })
+
+        expect(mermaidMocks.renderMock).toHaveBeenCalledTimes(1)
+        expect(mermaidMocks.renderMock).toHaveBeenCalledWith(
+            expect.stringContaining('mermaid-'),
+            'graph TD\nA --> B',
+        )
+        expect(document.querySelector('[data-mermaid-lightbox]')).toBeTruthy()
+    })
+
+    it('does not expose a lightbox trigger when rendering fails', async () => {
+        mermaidMocks.renderMock.mockRejectedValue(new Error('syntax'))
+
+        renderDiagram({
+            code: 'not valid mermaid',
+            language: 'mermaid',
+            components: defaultComponents,
+        })
+
+        await waitFor(() => {
+            expect(document.querySelector('[data-mermaid-diagram][data-rendered="false"]')).toBeTruthy()
+        })
+
+        expect(screen.queryByRole('button', { name: 'Open diagram full screen' })).toBeNull()
+    })
 })
diff --git a/web/src/components/assistant-ui/mermaid-diagram.tsx b/web/src/components/assistant-ui/mermaid-diagram.tsx
index 35a77e17b2..36f0f6775a 100644
--- a/web/src/components/assistant-ui/mermaid-diagram.tsx
+++ b/web/src/components/assistant-ui/mermaid-diagram.tsx
@@ -1,6 +1,16 @@
 import type { SyntaxHighlighterProps } from '@assistant-ui/react-markdown'
-import { useEffect, useId, useState, type ComponentPropsWithoutRef } from 'react'
+import {
+    useEffect,
+    useId,
+    useRef,
+    useState,
+    type ComponentPropsWithoutRef,
+    type Ref,
+    type SyntheticEvent,
+} from 'react'
+import { ZoomableLightbox } from '@/components/ZoomableLightbox'
 import { cn } from '@/lib/utils'
+import { useTranslation } from '@/lib/use-translation'
 
 let initializedTheme: 'light' | 'dark' | null = null
 let mermaidPromise: Promise | null = null
@@ -43,6 +53,15 @@ async function ensureMermaid(theme: 'light' | 'dark') {
                 clusterBkg: '#2d3440',
                 clusterBorder: '#6d8fd6',
                 edgeLabelBackground: '#2a2f35',
+                actorBkg: '#323843',
+                actorBorder: '#6d8fd6',
+                actorTextColor: '#edf1f5',
+                signalColor: '#94a3b8',
+                labelBoxBkgColor: '#323843',
+                labelTextColor: '#edf1f5',
+                loopTextColor: '#edf1f5',
+                noteBkgColor: '#2d3440',
+                noteTextColor: '#edf1f5',
             }
             : {
                 primaryColor: '#f8fbff',
@@ -57,6 +76,15 @@ async function ensureMermaid(theme: 'light' | 'dark') {
                 clusterBkg: '#eef4ff',
                 clusterBorder: '#b8cdfd',
                 edgeLabelBackground: '#f5f6f7',
+                actorBkg: '#f8fbff',
+                actorBorder: '#b8cdfd',
+                actorTextColor: '#2d333b',
+                signalColor: '#94a3b8',
+                labelBoxBkgColor: '#f8fbff',
+                labelTextColor: '#2d333b',
+                loopTextColor: '#2d333b',
+                noteBkgColor: '#eef4ff',
+                noteTextColor: '#2d333b',
             },
     })
 
@@ -64,24 +92,176 @@ async function ensureMermaid(theme: 'light' | 'dark') {
     return mermaid
 }
 
+export async function renderMermaidSvg(
+    code: string,
+    elementId: string,
+    theme: 'light' | 'dark',
+): Promise {
+    const mermaid = await ensureMermaid(theme)
+    const isValid = await mermaid.parse(code, { suppressErrors: true })
+    if (!isValid) return null
+    const result = await mermaid.render(elementId, code)
+    return result.svg
+}
+
+export function getMermaidSvgLayoutSize(svg: string): { width: number; height: number } | null {
+    const viewBoxMatch = svg.match(/\bviewBox=(['"])([^'"]+)\1/i)
+    if (!viewBoxMatch) return null
+    const parts = viewBoxMatch[2].trim().split(/[\s,]+/).map(Number)
+    if (parts.length < 4 || parts.some(Number.isNaN) || parts[2] <= 0 || parts[3] <= 0) return null
+    return { width: parts[2], height: parts[3] }
+}
+
+/** Mermaid often emits width="100%"; normalize before rasterizing for the lightbox. */
+export function normalizeMermaidSvgForStandaloneDisplay(svg: string): string {
+    let result = svg
+    const viewBoxSize = getMermaidSvgLayoutSize(result)
+    if (!viewBoxSize) return result
+
+    const { width, height } = viewBoxSize
+    result = result.replace(/\swidth="100%"/gi, '')
+    result = result.replace(/\sheight="100%"/gi, '')
+
+    if (/\sstyle="/i.test(result)) {
+        result = result.replace(
+            /(]*?\sstyle=")([^"]*)(")/i,
+            (_full, prefix: string, style: string, suffix: string) => {
+                const cleaned = style
+                    .replace(/(?:^|;)\s*max-width:\s*[^;]+/gi, '')
+                    .replace(/(?:^|;)\s*width:\s*[^;]+/gi, '')
+                    .replace(/(?:^|;)\s*height:\s*[^;]+/gi, '')
+                    .replace(/^;+|;+$/g, '')
+                    .replace(/;\s*;/g, ';')
+                    .trim()
+                const nextStyle = cleaned
+                    ? `${cleaned};width:${width}px;height:${height}px`
+                    : `width:${width}px;height:${height}px`
+                return `${prefix}${nextStyle}${suffix}`
+            },
+        )
+    } else {
+        result = result.replace(
+            / & { code: string }) {
+    const { code, className, ...rest } = props
     return (
         
-            {props.code}
+            {code}
         
) } +function MermaidSvgContent(props: { svg: string; className?: string; hostRef?: Ref }) { + return ( +
+ ) +} + +/** Prefer viewBox layout; use getBBox when Mermaid pads the viewBox (e.g. gitGraph). */ +export function resolveMermaidLightboxFitSize( + svgElement: SVGSVGElement | null, + svgString: string, +): { width: number; height: number } | null { + const fromViewBox = getMermaidSvgLayoutSize(svgString) + if (!svgElement) return fromViewBox + + try { + const bbox = svgElement.getBBox() + if (bbox.width <= 0 || bbox.height <= 0) return fromViewBox + if (!fromViewBox) return { width: bbox.width, height: bbox.height } + + const viewBoxArea = fromViewBox.width * fromViewBox.height + const bboxArea = bbox.width * bbox.height + if (viewBoxArea > bboxArea * 2) { + return { width: bbox.width, height: bbox.height } + } + } catch { + // getBBox unavailable (some test environments) + } + + return fromViewBox +} + +/** + * Shadow root isolates duplicate mermaid ids from the inline diagram in the page. + * + * Mermaid emits `width="100%"` on every diagram. Inside a shadow root whose host + * has no explicit size, that collapses to zero in Chromium for most diagram types + * (only ones that ship pixel attrs - e.g. `journey` - happen to render). Strip + * the relative size and bake explicit pixels from the viewBox before injecting, + * and give the host an inline-block layout so it sizes to the SVG. + */ +function MermaidLightboxSvg(props: { svg: string }) { + const hostRef = useRef(null) + + useEffect(() => { + const host = hostRef.current + if (!host) return + + const root = host.shadowRoot ?? host.attachShadow({ mode: 'open' }) + const normalized = normalizeMermaidSvgForStandaloneDisplay(props.svg) + root.innerHTML = [ + '', + normalized, + ].join('') + }, [props.svg]) + + return
+} + +function readMermaidE2eCaseId(code: string): string | undefined { + return code.match(//i)?.[1] +} + export function MermaidDiagram(props: SyntaxHighlighterProps) { + const { t } = useTranslation() + const e2eCaseId = readMermaidE2eCaseId(props.code) const [theme, setTheme] = useState<'light' | 'dark'>(() => resolveTheme()) const [renderError, setRenderError] = useState(false) const [svg, setSvg] = useState(null) + const [lightboxOpen, setLightboxOpen] = useState(false) + const [lightboxFitSize, setLightboxFitSize] = useState<{ width: number; height: number } | null>(null) + const inlineHostRef = useRef(null) const id = useId().replace(/:/g, '-') + const openLabel = t('mermaid.openFullscreen') + const viewerLabel = t('mermaid.viewerTitle') + + const stopEvent = (event: SyntheticEvent) => { + event.stopPropagation() + } + + const openLightbox = (event: SyntheticEvent) => { + event.preventDefault() + event.stopPropagation() + if (!svg) return + const inlineSvg = inlineHostRef.current?.querySelector('svg') ?? null + setLightboxFitSize(resolveMermaidLightboxFitSize(inlineSvg, svg)) + setLightboxOpen(true) + } useEffect(() => { if (typeof document === 'undefined') return undefined @@ -104,18 +284,14 @@ export function MermaidDiagram(props: SyntaxHighlighterProps) { const render = async () => { try { - const mermaid = await ensureMermaid(theme) - const isValid = await mermaid.parse(props.code, { suppressErrors: true }) + const nextSvg = await renderMermaidSvg(props.code, `mermaid-${id}`, theme) if (cancelled) return - if (!isValid) { + if (!nextSvg) { setSvg(null) setRenderError(true) return } - - const result = await mermaid.render(`mermaid-${id}`, props.code) - if (cancelled) return - setSvg(result.svg) + setSvg(nextSvg) setRenderError(false) } catch { if (cancelled) return @@ -131,20 +307,43 @@ export function MermaidDiagram(props: SyntaxHighlighterProps) { } }, [id, props.code, theme]) + const lightboxLayoutSize = lightboxFitSize ?? (svg ? getMermaidSvgLayoutSize(svg) : null) + if (renderError || !svg) { return } return ( -
-
-
+ <> + + + setLightboxOpen(false)} + title={viewerLabel} + ariaLabel={viewerLabel} + fitContentKey={lightboxOpen ? svg : null} + fitContentSize={lightboxLayoutSize} + > +
+ +
+
+ ) } diff --git a/web/src/components/assistant-ui/mermaid-svg-id.test.ts b/web/src/components/assistant-ui/mermaid-svg-id.test.ts new file mode 100644 index 0000000000..5a7d3787d3 --- /dev/null +++ b/web/src/components/assistant-ui/mermaid-svg-id.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import { + getMermaidSvgLayoutSize, + normalizeMermaidSvgForStandaloneDisplay, +} from '@/components/assistant-ui/mermaid-diagram' + +describe('getMermaidSvgLayoutSize', () => { + it('reads simple unsigned viewBox', () => { + expect(getMermaidSvgLayoutSize('')).toEqual({ width: 200, height: 80 }) + }) + + it('accepts signed origin values (negative offsets)', () => { + expect(getMermaidSvgLayoutSize('')).toEqual({ width: 640, height: 480 }) + }) + + it('accepts single-quoted attribute and comma separators', () => { + expect(getMermaidSvgLayoutSize("")).toEqual({ width: 300, height: 150 }) + }) + + it('rejects malformed viewBox (NaN, missing dim, zero size)', () => { + expect(getMermaidSvgLayoutSize('')).toBeNull() + expect(getMermaidSvgLayoutSize('')).toBeNull() + expect(getMermaidSvgLayoutSize('')).toBeNull() + }) + + it('returns null when no viewBox attribute exists', () => { + expect(getMermaidSvgLayoutSize('')).toBeNull() + }) +}) + +describe('normalizeMermaidSvgForStandaloneDisplay', () => { + it('replaces width="100%" with explicit viewBox dimensions', () => { + const svg = '' + const prepared = normalizeMermaidSvgForStandaloneDisplay(svg) + + expect(prepared).not.toContain('width="100%"') + expect(prepared).toContain('width:200px') + expect(prepared).toContain('height:80px') + }) + + it('normalizes width/height for SVGs with negative viewBox origins', () => { + const svg = '' + const prepared = normalizeMermaidSvgForStandaloneDisplay(svg) + + expect(prepared).not.toContain('width="100%"') + expect(prepared).toContain('width="800"') + expect(prepared).toContain('height="600"') + }) +}) diff --git a/web/src/dev/mermaid-lightbox-cases.ts b/web/src/dev/mermaid-lightbox-cases.ts new file mode 100644 index 0000000000..c926802151 --- /dev/null +++ b/web/src/dev/mermaid-lightbox-cases.ts @@ -0,0 +1,96 @@ +/** Minimal valid sources for Playwright lightbox coverage (one case per diagram kind). */ +export const MERMAID_LIGHTBOX_CASES = { + flowchart: `flowchart LR + Hub --> WebUI + WebUI --> Lightbox`, + + sequence: `sequenceDiagram + participant U as Operator + participant C as Chat + participant M as Mermaid + U->>C: Send message + C->>M: Render SVG + M-->>U: Lightbox`, + + class: `classDiagram + Animal <|-- Duck + Animal : +int age + Duck : +swim()`, + + state: `stateDiagram-v2 + [*] --> Still + Still --> Moving + Moving --> Still`, + + er: `erDiagram + CUSTOMER ||--o{ ORDER : places + ORDER ||--|{ LINE-ITEM : contains`, + + journey: `journey + title Checkout + section Browse + Open site: 5: User + Add item: 3: User`, + + gantt: `gantt + title Plan + dateFormat YYYY-MM-DD + section Build + Ship feature :2024-06-01, 3d`, + + pie: `pie title Pets + "Dogs" : 386 + "Cats" : 214`, + + quadrant: `quadrantChart + title Reach and engagement + x-axis Low Reach --> High Reach + y-axis Low Engagement --> High Engagement + quadrant-1 We should expand + Product A: [0.3, 0.6] + Product B: [0.45, 0.23]`, + + requirement: `requirementDiagram + requirement test_req { + id: 1 + text: the tested requirement. + risk: high + verifymethod: test + }`, + + gitGraph: `gitGraph + commit + branch develop + checkout develop + commit + checkout main + merge develop`, + + c4: `C4Context + title System + Person(user, "User") + System(app, "Application") + Rel(user, app, "Uses")`, + + mindmap: `mindmap + root((HAPI)) + Chat + Hub + Web`, + + timeline: `timeline + title History + 2024 : Alpha + 2025 : Beta`, + + kanban: `kanban + title Board + column Todo + task1[Task 1] + column Done + task2[Task 2]`, +} as const + +export type MermaidLightboxCaseId = keyof typeof MERMAID_LIGHTBOX_CASES + +export const MERMAID_LIGHTBOX_CASE_IDS = Object.keys(MERMAID_LIGHTBOX_CASES) as MermaidLightboxCaseId[] diff --git a/web/src/dev/mermaid-lightbox-e2e.tsx b/web/src/dev/mermaid-lightbox-e2e.tsx new file mode 100644 index 0000000000..e38b007a17 --- /dev/null +++ b/web/src/dev/mermaid-lightbox-e2e.tsx @@ -0,0 +1,39 @@ +import { createRoot } from 'react-dom/client' +import { I18nProvider } from '@/lib/i18n-context' +import { MermaidDiagram } from '@/components/assistant-ui/mermaid-diagram' +import { + MERMAID_LIGHTBOX_CASE_IDS, + MERMAID_LIGHTBOX_CASES, + type MermaidLightboxCaseId, +} from '@/dev/mermaid-lightbox-cases' + +const root = document.getElementById('root') +if (!root) { + throw new Error('missing #root') +} + +const params = new URLSearchParams(window.location.search) +const caseId = params.get('case') as MermaidLightboxCaseId | null +const code = caseId && caseId in MERMAID_LIGHTBOX_CASES + ? MERMAID_LIGHTBOX_CASES[caseId] + : MERMAID_LIGHTBOX_CASES.flowchart + +document.title = `Mermaid lightbox e2e: ${caseId ?? 'flowchart'}` + +createRoot(root).render( + +
+
,
+                    Code: (props) => ,
+                }}
+            />
+        
+
, +) + +// Expose case list for Playwright discovery without importing TS in Node. +;(window as Window & { __MERMAID_E2E_CASES__?: string[] }).__MERMAID_E2E_CASES__ = [...MERMAID_LIGHTBOX_CASE_IDS] diff --git a/web/src/dev/mermaid-lightbox-smoke.tsx b/web/src/dev/mermaid-lightbox-smoke.tsx new file mode 100644 index 0000000000..80ff954632 --- /dev/null +++ b/web/src/dev/mermaid-lightbox-smoke.tsx @@ -0,0 +1,21 @@ +import { createRoot } from 'react-dom/client' +import { I18nProvider } from '@/lib/i18n-context' +import { MermaidDiagram } from '@/components/assistant-ui/mermaid-diagram' + +const root = document.getElementById('root') +if (!root) { + throw new Error('missing #root') +} + +createRoot(root).render( + + Lightbox\n Lightbox --> Visible'} + language="mermaid" + components={{ + Pre: (props) =>
,
+                Code: (props) => ,
+            }}
+        />
+    ,
+)
diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts
index da94bdc71f..c606cea4ae 100644
--- a/web/src/lib/locales/en.ts
+++ b/web/src/lib/locales/en.ts
@@ -196,6 +196,12 @@ export default {
   'session.export.toast.success.body': 'Downloaded {filename}',
   'session.export.toast.error.title': 'Export failed',
 
+  // Mermaid diagrams
+  'mermaid.openFullscreen': 'Open diagram full screen',
+  'mermaid.viewerTitle': 'Diagram',
+  'mermaid.loading': 'Loading diagram…',
+  'mermaid.renderError': 'Could not render diagram.',
+
   // Common buttons
   'button.cancel': 'Cancel',
   'button.save': 'Save',
diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts
index 05ad556f2d..44b73250a6 100644
--- a/web/src/lib/locales/zh-CN.ts
+++ b/web/src/lib/locales/zh-CN.ts
@@ -200,6 +200,12 @@ export default {
   'session.export.toast.success.body': '已下载 {filename}',
   'session.export.toast.error.title': '导出失败',
 
+  // Mermaid diagrams
+  'mermaid.openFullscreen': '全屏查看图表',
+  'mermaid.viewerTitle': '图表',
+  'mermaid.loading': '正在加载图表…',
+  'mermaid.renderError': '无法渲染图表。',
+
   // Common buttons
   'button.cancel': '取消',
   'button.save': '保存',

From 5a377e38b9c43d38d88ffc5ff67b7426c8ea3108 Mon Sep 17 00:00:00 2001
From: weishu 
Date: Sun, 12 Jul 2026 10:59:15 +0800
Subject: [PATCH 09/28] fix(codex): defer session persistence until user
 activity

---
 cli/src/agent/runnerLifecycle.test.ts         |   9 +
 cli/src/agent/runnerLifecycle.ts              |   2 +-
 cli/src/agent/sessionFactory.test.ts          |  68 ++-
 cli/src/agent/sessionFactory.ts               |  82 ++++
 cli/src/api/api.ts                            |  27 +-
 cli/src/api/apiSession.test.ts                | 325 +++++++++++-
 cli/src/api/apiSession.ts                     | 462 +++++++++++++++---
 cli/src/claude/utils/sessionHookForwarder.ts  |  26 +-
 cli/src/claude/utils/startHookServer.ts       |   8 +-
 cli/src/codex/codexLocalLauncher.test.ts      |  82 +++-
 cli/src/codex/codexLocalLauncher.ts           |  39 +-
 cli/src/codex/runCodex.test.ts                |  29 ++
 cli/src/codex/runCodex.ts                     |   7 +-
 cli/src/codex/session.ts                      |   4 +
 cli/src/codex/utils/codexCliOverrides.test.ts |  10 +
 cli/src/codex/utils/codexCliOverrides.ts      |  18 +
 .../codex/utils/codexEventConverter.test.ts   |  16 +-
 cli/src/codex/utils/codexEventConverter.ts    |  18 +-
 .../codex/utils/codexSessionScanner.test.ts   |  26 +
 cli/src/codex/utils/codexSessionScanner.ts    |   6 +-
 .../utils/codexTranscriptLocator.test.ts      | 250 ++++++++++
 cli/src/codex/utils/codexTranscriptLocator.ts | 380 ++++++++++++++
 cli/src/codex/utils/codexVersion.test.ts      |   4 +-
 cli/src/codex/utils/codexVersion.ts           |   2 +
 cli/src/commands/codex.test.ts                |  14 +
 cli/src/commands/codex.ts                     |   6 +-
 cli/src/utils/autoStartServer.ts              |  31 +-
 hub/src/store/sessionStore.ts                 |   5 +-
 hub/src/store/sessions.test.ts                |  61 +++
 hub/src/store/sessions.ts                     |  25 +-
 hub/src/sync/sessionCache.ts                  |  14 +-
 hub/src/sync/syncEngine.ts                    |  14 +-
 hub/src/web/routes/cli.test.ts                | 104 +++-
 hub/src/web/routes/cli.ts                     |  43 +-
 shared/src/apiTypes.ts                        |  20 +-
 35 files changed, 2112 insertions(+), 125 deletions(-)
 create mode 100644 cli/src/codex/utils/codexTranscriptLocator.test.ts
 create mode 100644 cli/src/codex/utils/codexTranscriptLocator.ts

diff --git a/cli/src/agent/runnerLifecycle.test.ts b/cli/src/agent/runnerLifecycle.test.ts
index 172dca0aef..f5b5b2d665 100644
--- a/cli/src/agent/runnerLifecycle.test.ts
+++ b/cli/src/agent/runnerLifecycle.test.ts
@@ -100,6 +100,15 @@ describe('createRunnerLifecycle', () => {
             await lc.cleanup();
             expect(session.sendSessionDeath).toHaveBeenCalledWith('completed');
         });
+
+        it('limits the final connected flush budget to one second', async () => {
+            const session = createMockApiSession();
+            const lc = createRunnerLifecycle({ session, logTag: 'test' });
+
+            await lc.cleanup();
+
+            expect(session.flush).toHaveBeenCalledWith({ timeoutMs: 1_000 });
+        });
     });
 });
 
diff --git a/cli/src/agent/runnerLifecycle.ts b/cli/src/agent/runnerLifecycle.ts
index 16a0495120..d6ae14198d 100644
--- a/cli/src/agent/runnerLifecycle.ts
+++ b/cli/src/agent/runnerLifecycle.ts
@@ -62,7 +62,7 @@ export function createRunnerLifecycle(options: RunnerLifecycleOptions): RunnerLi
         }))
 
         options.session.sendSessionDeath(sessionEndReason)
-        await options.session.flush()
+        await options.session.flush({ timeoutMs: 1_000 })
         await options.session.close()
     }
 
diff --git a/cli/src/agent/sessionFactory.test.ts b/cli/src/agent/sessionFactory.test.ts
index 70a7c31af0..246cc2a18a 100644
--- a/cli/src/agent/sessionFactory.test.ts
+++ b/cli/src/agent/sessionFactory.test.ts
@@ -3,12 +3,14 @@ import type { Session } from '@/api/types'
 
 const {
     getSessionMock,
+    getOrCreateSessionMock,
     getOrCreateMachineMock,
     sessionSyncClientMock,
     notifyRunnerSessionStartedMock,
     readSettingsMock
 } = vi.hoisted(() => ({
     getSessionMock: vi.fn(),
+    getOrCreateSessionMock: vi.fn(),
     getOrCreateMachineMock: vi.fn(),
     sessionSyncClientMock: vi.fn(),
     notifyRunnerSessionStartedMock: vi.fn(async () => ({})),
@@ -19,6 +21,7 @@ vi.mock('@/api/api', () => ({
     ApiClient: {
         create: async () => ({
             getSession: getSessionMock,
+            getOrCreateSession: getOrCreateSessionMock,
             getOrCreateMachine: getOrCreateMachineMock,
             sessionSyncClient: sessionSyncClientMock
         })
@@ -47,7 +50,7 @@ vi.mock('@/ui/logger', () => ({
     }
 }))
 
-import { bootstrapExistingSession, buildSessionMetadata } from './sessionFactory'
+import { bootstrapExistingSession, bootstrapLazySession, buildSessionMetadata } from './sessionFactory'
 
 function createSession(): Session {
     return {
@@ -83,6 +86,7 @@ function createSession(): Session {
 describe('bootstrapExistingSession', () => {
     beforeEach(() => {
         getSessionMock.mockReset()
+        getOrCreateSessionMock.mockReset()
         getOrCreateMachineMock.mockReset()
         sessionSyncClientMock.mockReset()
         notifyRunnerSessionStartedMock.mockClear()
@@ -194,3 +198,65 @@ describe('bootstrapExistingSession', () => {
         expect(metadata.capabilities?.terminal).toBe(true)
     })
 })
+
+describe('bootstrapLazySession', () => {
+    beforeEach(() => {
+        getOrCreateSessionMock.mockReset()
+        getOrCreateMachineMock.mockReset()
+        sessionSyncClientMock.mockReset()
+        notifyRunnerSessionStartedMock.mockClear()
+        readSettingsMock.mockReset()
+    })
+
+    it('does not persist a machine or session until materialization', async () => {
+        const pendingClient = { isPending: () => true }
+        sessionSyncClientMock.mockReturnValue(pendingClient)
+        readSettingsMock.mockResolvedValue({ machineId: 'machine-1' })
+
+        const result = await bootstrapLazySession({
+            flavor: 'codex',
+            startedBy: 'terminal',
+            workingDirectory: '/tmp/project',
+            agentState: { controlledByUser: false }
+        })
+
+        expect(result.session).toBe(pendingClient)
+        expect(getOrCreateMachineMock).not.toHaveBeenCalled()
+        expect(getOrCreateSessionMock).not.toHaveBeenCalled()
+        expect(notifyRunnerSessionStartedMock).not.toHaveBeenCalled()
+
+        const [provisional, options] = sessionSyncClientMock.mock.calls[0]
+        expect(provisional.id).toMatch(/^[0-9a-f-]{36}$/)
+        expect(provisional.metadata).toEqual(expect.objectContaining({
+            machineId: 'machine-1',
+            path: '/tmp/project',
+            flavor: 'codex'
+        }))
+
+        const materialized = createSession()
+        materialized.id = provisional.id
+        getOrCreateSessionMock.mockResolvedValue(materialized)
+        const snapshot = {
+            metadata: {
+                ...provisional.metadata,
+                codexSessionId: 'codex-thread-1'
+            },
+            agentState: { controlledByUser: true }
+        }
+        await options.materialize(snapshot, new AbortController().signal)
+
+        expect(getOrCreateSessionMock).toHaveBeenCalledWith(expect.objectContaining({
+            id: provisional.id,
+            metadata: snapshot.metadata,
+            state: snapshot.agentState,
+            timeoutMs: 10_000,
+            machine: expect.objectContaining({ id: 'machine-1' })
+        }))
+
+        options.onMaterialized(materialized, snapshot)
+        expect(notifyRunnerSessionStartedMock).toHaveBeenCalledWith(
+            provisional.id,
+            expect.objectContaining({ codexSessionId: 'codex-thread-1' })
+        )
+    })
+})
diff --git a/cli/src/agent/sessionFactory.ts b/cli/src/agent/sessionFactory.ts
index fcc2629648..124ae124de 100644
--- a/cli/src/agent/sessionFactory.ts
+++ b/cli/src/agent/sessionFactory.ts
@@ -184,6 +184,88 @@ export async function bootstrapSession(options: SessionBootstrapOptions): Promis
     }
 }
 
+export async function bootstrapLazySession(options: SessionBootstrapOptions): Promise {
+    const workingDirectory = options.workingDirectory ?? getInvokedCwd()
+    const startedBy = options.startedBy ?? 'terminal'
+    if (startedBy !== 'terminal') {
+        throw new Error('Lazy session bootstrap is only supported for terminal sessions')
+    }
+
+    const api = await ApiClient.create()
+    const machineId = await getMachineIdOrExit()
+    const machineMetadata = buildMachineMetadata()
+    const metadata = buildSessionMetadata({
+        flavor: options.flavor,
+        startedBy,
+        workingDirectory,
+        machineId,
+        metadataOverrides: options.metadataOverrides
+    })
+    const agentState = options.agentState === undefined ? {} : options.agentState
+    const now = Date.now()
+    const requestedId = randomUUID()
+    const sessionTag = options.tag ?? randomUUID()
+    const sessionInfo: Session = {
+        id: requestedId,
+        namespace: 'pending',
+        seq: 0,
+        createdAt: now,
+        updatedAt: now,
+        active: false,
+        activeAt: now,
+        metadata,
+        metadataVersion: 0,
+        agentState,
+        agentStateVersion: 0,
+        thinking: false,
+        thinkingAt: now,
+        todos: [],
+        model: options.model ?? null,
+        modelReasoningEffort: options.modelReasoningEffort ?? null,
+        effort: options.effort ?? null,
+        serviceTier: null,
+        permissionMode: undefined,
+        collaborationMode: undefined
+    }
+
+    const session = api.sessionSyncClient(sessionInfo, {
+        materialize: async (snapshot, signal) => {
+            const materialized = await api.getOrCreateSession({
+                id: requestedId,
+                tag: sessionTag,
+                metadata: snapshot.metadata ?? metadata,
+                state: snapshot.agentState,
+                model: options.model,
+                modelReasoningEffort: options.modelReasoningEffort,
+                effort: options.effort,
+                machine: {
+                    id: machineId,
+                    metadata: machineMetadata
+                },
+                timeoutMs: 10_000,
+                signal
+            })
+            if (materialized.id !== requestedId) {
+                throw new Error(`Hub returned unexpected session id ${materialized.id}`)
+            }
+            return materialized
+        },
+        onMaterialized: (materialized, snapshot) => {
+            void reportSessionStarted(materialized.id, snapshot.metadata ?? metadata)
+        }
+    })
+
+    return {
+        api,
+        session,
+        sessionInfo,
+        metadata,
+        machineId,
+        startedBy,
+        workingDirectory
+    }
+}
+
 export async function bootstrapExistingSession(options: {
     sessionId: string
     flavor: string
diff --git a/cli/src/api/api.ts b/cli/src/api/api.ts
index df52e4548a..275c7196b6 100644
--- a/cli/src/api/api.ts
+++ b/cli/src/api/api.ts
@@ -17,7 +17,7 @@ import { configuration } from '@/configuration'
 import { getAuthToken } from '@/api/auth'
 import { apiValidationError } from '@/utils/errorUtils'
 import { ApiMachineClient } from './apiMachine'
-import { ApiSessionClient } from './apiSession'
+import { ApiSessionClient, type ApiSessionClientOptions } from './apiSession'
 import { buildHubRequestHeaders } from './hubExtraHeaders'
 
 export class ApiClient {
@@ -35,29 +35,46 @@ export class ApiClient {
     }
 
     async getOrCreateSession(opts: {
+        id?: string
         tag: string
         metadata: Metadata
         state: AgentState | null
         model?: string
         modelReasoningEffort?: string
         effort?: string
+        machine?: {
+            id: string
+            metadata: MachineMetadata
+            runnerState?: RunnerState
+        }
+        timeoutMs?: number
+        signal?: AbortSignal
     }): Promise {
         const response = await axios.post(
             `${configuration.apiUrl}/cli/sessions`,
             {
+                id: opts.id,
                 tag: opts.tag,
                 metadata: opts.metadata,
                 agentState: opts.state,
                 model: opts.model,
                 modelReasoningEffort: opts.modelReasoningEffort,
-                effort: opts.effort
+                effort: opts.effort,
+                machine: opts.machine
+                    ? {
+                        id: opts.machine.id,
+                        metadata: opts.machine.metadata,
+                        runnerState: opts.machine.runnerState ?? null
+                    }
+                    : undefined
             },
             {
                 headers: buildHubRequestHeaders({
                     Authorization: `Bearer ${this.token}`,
                     'Content-Type': 'application/json'
                 }),
-                timeout: 60_000
+                timeout: opts.timeoutMs ?? 60_000,
+                signal: opts.signal
             }
         )
 
@@ -255,8 +272,8 @@ export class ApiClient {
         }
     }
 
-    sessionSyncClient(session: Session): ApiSessionClient {
-        return new ApiSessionClient(this.token, session)
+    sessionSyncClient(session: Session, options?: ApiSessionClientOptions): ApiSessionClient {
+        return new ApiSessionClient(this.token, session, options)
     }
 
     machineSyncClient(machine: Machine, options?: { workspaceRoots?: string[] }): ApiMachineClient {
diff --git a/cli/src/api/apiSession.test.ts b/cli/src/api/apiSession.test.ts
index a44ebc8670..5b4b5a4453 100644
--- a/cli/src/api/apiSession.test.ts
+++ b/cli/src/api/apiSession.test.ts
@@ -1,5 +1,326 @@
-import { describe, expect, it } from 'vitest'
-import { isExternalUserMessage, IncomingMessageFilter } from './apiSession'
+import { describe, expect, it, vi } from 'vitest'
+import type { Session } from './types'
+
+const socketHarness = vi.hoisted(() => ({
+    sockets: [] as Array<{
+        connected: boolean
+        connectCalls: number
+        connectImmediately: boolean
+        emitted: Array<{ event: string; args: unknown[] }>
+        listeners: Map void>>
+        triggerConnect: () => void
+        triggerConnectError: () => void
+    }>
+}))
+
+vi.mock('socket.io-client', () => ({
+    io: () => {
+        const state = {
+            connected: false,
+            connectCalls: 0,
+            connectImmediately: true,
+            emitted: [] as Array<{ event: string; args: unknown[] }>,
+            listeners: new Map void>>(),
+            triggerConnect: () => {},
+            triggerConnectError: () => {}
+        }
+        const triggerConnect = () => {
+            state.connected = true
+            for (const listener of state.listeners.get('connect') ?? []) listener()
+        }
+        state.triggerConnect = triggerConnect
+        state.triggerConnectError = () => {
+            for (const listener of state.listeners.get('connect_error') ?? []) {
+                listener(new Error('connect failed'))
+            }
+        }
+        const socket = {
+            get connected() {
+                return state.connected
+            },
+            on: (event: string, listener: (...args: any[]) => void) => {
+                const listeners = state.listeners.get(event) ?? []
+                listeners.push(listener)
+                state.listeners.set(event, listeners)
+                return socket
+            },
+            off: (event: string, listener: (...args: any[]) => void) => {
+                const listeners = state.listeners.get(event) ?? []
+                state.listeners.set(event, listeners.filter((candidate) => candidate !== listener))
+                return socket
+            },
+            emit: (event: string, ...args: unknown[]) => {
+                state.emitted.push({ event, args })
+                return socket
+            },
+            emitWithAck: async () => ({}),
+            timeout: () => ({ emitWithAck: async () => ({}) }),
+            connect: () => {
+                state.connectCalls += 1
+                if (state.connectImmediately) {
+                    triggerConnect()
+                }
+                return socket
+            },
+            disconnect: () => {
+                state.connected = false
+                return socket
+            }
+        }
+        Object.assign(socket, { volatile: socket })
+        socketHarness.sockets.push(state)
+        return socket
+    }
+}))
+
+import { ApiSessionClient, isExternalUserMessage, IncomingMessageFilter } from './apiSession'
+
+function createSession(overrides: Partial = {}): Session {
+    return {
+        id: '11111111-1111-4111-8111-111111111111',
+        namespace: 'pending',
+        seq: 0,
+        createdAt: 1,
+        updatedAt: 1,
+        active: false,
+        activeAt: 1,
+        metadata: null,
+        metadataVersion: 0,
+        agentState: { controlledByUser: false },
+        agentStateVersion: 0,
+        thinking: false,
+        thinkingAt: 1,
+        todos: [],
+        model: null,
+        modelReasoningEffort: null,
+        effort: null,
+        serviceTier: null,
+        permissionMode: undefined,
+        collaborationMode: undefined,
+        ...overrides
+    }
+}
+
+function deferred() {
+    let resolve!: (value: T) => void
+    let reject!: (error: unknown) => void
+    const promise = new Promise((promiseResolve, promiseReject) => {
+        resolve = promiseResolve
+        reject = promiseReject
+    })
+    return { promise, resolve, reject }
+}
+
+describe('ApiSessionClient lazy materialization', () => {
+    it('does not connect or materialize without a real user message', async () => {
+        socketHarness.sockets.length = 0
+        const materialize = vi.fn(async () => createSession())
+        const client = new ApiSessionClient('token', createSession(), { materialize })
+
+        client.updateMetadata(() => ({ path: '/tmp/project', host: 'localhost', codexSessionId: 'codex-thread' }))
+        client.sendSessionEvent({ type: 'ready' })
+        client.keepAlive(false, 'local')
+        await client.flush({ timeoutMs: 100 })
+
+        expect(client.getState()).toBe('pending')
+        expect(materialize).not.toHaveBeenCalled()
+        expect(socketHarness.sockets[0]?.connectCalls).toBe(0)
+        expect(socketHarness.sockets[0]?.emitted).toEqual([])
+        client.close()
+    })
+
+    it('materializes on the first user message and replays queued events', async () => {
+        socketHarness.sockets.length = 0
+        const materialize = vi.fn(async (snapshot) => createSession({
+            namespace: 'default',
+            metadata: snapshot.metadata,
+            metadataVersion: 1,
+            agentState: snapshot.agentState,
+            agentStateVersion: 1
+        }))
+        const client = new ApiSessionClient('token', createSession(), { materialize })
+        client.updateMetadata(() => ({ path: '/tmp/project', host: 'localhost', codexSessionId: 'codex-thread' }))
+        client.sendSessionEvent({ type: 'ready' })
+
+        client.sendUserMessage('hello')
+        expect(await client.materialize()).toBe(true)
+
+        expect(materialize).toHaveBeenCalledWith({
+            metadata: { path: '/tmp/project', host: 'localhost', codexSessionId: 'codex-thread' },
+            agentState: { controlledByUser: false }
+        }, expect.any(AbortSignal))
+        expect(client.getState()).toBe('active')
+        expect(socketHarness.sockets[0]?.connectCalls).toBe(1)
+        expect(socketHarness.sockets[0]?.emitted.map((entry) => entry.event)).toEqual([
+            'message',
+            'message',
+            'session-alive'
+        ])
+        client.close()
+    })
+
+    it('materializes on non-text user activity and preserves following agent events', async () => {
+        socketHarness.sockets.length = 0
+        const pendingMaterialization = deferred()
+        const materialize = vi.fn(async () => await pendingMaterialization.promise)
+        const client = new ApiSessionClient('token', createSession(), { materialize })
+
+        client.notifyUserActivity()
+        client.sendAgentMessage({ type: 'message', message: 'image response' })
+        expect(client.getState()).toBe('materializing')
+
+        pendingMaterialization.resolve(createSession({ namespace: 'default' }))
+        expect(await client.materialize()).toBe(true)
+
+        expect(materialize).toHaveBeenCalledTimes(1)
+        const messages = socketHarness.sockets[0]?.emitted.filter((entry) => entry.event === 'message')
+        expect(messages).toHaveLength(1)
+        client.close()
+    })
+
+    it('preserves all replayed transcript messages while materialization is in flight', async () => {
+        socketHarness.sockets.length = 0
+        const pendingMaterialization = deferred()
+        const client = new ApiSessionClient('token', createSession(), {
+            materialize: async () => await pendingMaterialization.promise
+        })
+        const expectedMessages: string[] = []
+
+        for (let index = 0; index < 150; index += 1) {
+            const userMessage = `user-${index}`
+            const agentMessage = `agent-${index}`
+            expectedMessages.push(userMessage, agentMessage)
+            client.sendUserMessage(userMessage)
+            client.sendAgentMessage({ type: 'message', message: agentMessage })
+        }
+
+        pendingMaterialization.resolve(createSession({ namespace: 'default' }))
+        expect(await client.materialize()).toBe(true)
+
+        const emittedMessages = socketHarness.sockets[0]?.emitted
+            .filter((entry) => entry.event === 'message')
+            .map((entry) => {
+                const payload = entry.args[0] as {
+                    message: {
+                        role: 'user' | 'agent'
+                        content: { text?: string; data?: { message?: string } }
+                    }
+                }
+                return payload.message.role === 'user'
+                    ? payload.message.content.text
+                    : payload.message.content.data?.message
+            })
+
+        expect(emittedMessages).toEqual(expectedMessages)
+        client.close()
+    })
+
+    it('drains in-flight materialization and initial socket delivery before closing', async () => {
+        socketHarness.sockets.length = 0
+        const pendingMaterialization = deferred()
+        const client = new ApiSessionClient('token', createSession(), {
+            materialize: async () => await pendingMaterialization.promise
+        })
+        const socket = socketHarness.sockets[0]
+        if (!socket) throw new Error('expected socket')
+        socket.connectImmediately = false
+
+        client.sendUserMessage('persist me')
+        client.sendAgentMessage({ type: 'message', message: 'persist response' })
+        client.sendSessionDeath('completed')
+
+        let flushed = false
+        const flushTask = client.flush({ timeoutMs: 1_000 }).then(() => {
+            flushed = true
+        })
+        await Promise.resolve()
+        expect(flushed).toBe(false)
+
+        pendingMaterialization.resolve(createSession({ namespace: 'default' }))
+        await vi.waitFor(() => expect(socket.connectCalls).toBe(1))
+        expect(flushed).toBe(false)
+
+        socket.triggerConnectError()
+        await Promise.resolve()
+        expect(flushed).toBe(false)
+
+        socket.triggerConnect()
+        await flushTask
+
+        expect(socket.emitted.map((entry) => entry.event)).toEqual([
+            'message',
+            'message',
+            'session-end',
+            'session-alive'
+        ])
+        client.close()
+    })
+
+    it('skips materialization backoff and performs one final attempt during shutdown drain', async () => {
+        socketHarness.sockets.length = 0
+        const materialize = vi.fn(async () => {
+            if (materialize.mock.calls.length === 1) {
+                throw Object.assign(new Error('hub unavailable'), { isAxiosError: true })
+            }
+            return createSession({ namespace: 'default' })
+        })
+        const client = new ApiSessionClient('token', createSession(), { materialize })
+
+        client.sendUserMessage('hello')
+        await vi.waitFor(() => expect(materialize).toHaveBeenCalledTimes(1))
+        await Promise.resolve()
+
+        await client.flush({ timeoutMs: 500 })
+
+        expect(materialize).toHaveBeenCalledTimes(2)
+        expect(client.getState()).toBe('active')
+        expect(socketHarness.sockets[0]?.emitted.some((entry) => entry.event === 'message')).toBe(true)
+        client.close()
+    })
+
+    it('aborts in-flight materialization when closed', async () => {
+        socketHarness.sockets.length = 0
+        const observedSignals: AbortSignal[] = []
+        const materialize = vi.fn(async (_snapshot, signal: AbortSignal) => {
+            observedSignals.push(signal)
+            return await new Promise((_resolve, reject) => {
+                signal.addEventListener('abort', () => reject(new Error('aborted')), { once: true })
+            })
+        })
+        const client = new ApiSessionClient('token', createSession(), { materialize })
+
+        const task = client.materialize()
+        await Promise.resolve()
+        client.close()
+
+        expect(await task).toBe(false)
+        expect(observedSignals[0]?.aborted).toBe(true)
+        expect(client.getState()).toBe('closed')
+    })
+
+    it('reconnects a disconnected active session during final flush', async () => {
+        socketHarness.sockets.length = 0
+        const client = new ApiSessionClient('token', createSession({ namespace: 'default' }))
+        const socket = socketHarness.sockets[0]
+        if (!socket) throw new Error('expected socket')
+        socket.connected = false
+        socket.connectImmediately = false
+        client.sendSessionDeath('completed')
+
+        let flushed = false
+        const flushTask = client.flush({ timeoutMs: 500 }).then(() => {
+            flushed = true
+        })
+        await vi.waitFor(() => expect(socket.connectCalls).toBe(2))
+        expect(flushed).toBe(false)
+
+        socket.triggerConnect()
+        await flushTask
+
+        expect(socket.emitted.some((entry) => entry.event === 'session-end')).toBe(true)
+        client.close()
+    })
+})
 
 describe('isExternalUserMessage', () => {
     const baseUserMsg = {
diff --git a/cli/src/api/apiSession.ts b/cli/src/api/apiSession.ts
index cd98d69a51..3e98814a6e 100644
--- a/cli/src/api/apiSession.ts
+++ b/cli/src/api/apiSession.ts
@@ -152,6 +152,72 @@ export class IncomingMessageFilter {
     }
 }
 
+export type ApiSessionClientState = 'pending' | 'materializing' | 'active' | 'closed'
+
+export type PendingSessionSnapshot = {
+    metadata: Metadata | null
+    agentState: AgentState | null
+}
+
+export type ApiSessionClientOptions = {
+    materialize?: (snapshot: PendingSessionSnapshot, signal: AbortSignal) => Promise
+    onMaterialized?: (session: Session, snapshot: PendingSessionSnapshot) => void
+}
+
+type PendingOutboundEvent = {
+    emit: () => void
+    retention: 'lossless' | 'droppable'
+}
+
+const MAX_PENDING_DROPPABLE_EVENTS = 256
+const MATERIALIZATION_RETRY_MIN_MS = 1_000
+const MATERIALIZATION_RETRY_MAX_MS = 30_000
+
+function isTransientMaterializationError(error: unknown): boolean {
+    if (!axios.isAxiosError(error)) {
+        return false
+    }
+    if (!error.response) {
+        return true
+    }
+    const status = error.response.status
+    return status === 408 || status === 425 || status === 429 || status >= 500
+}
+
+async function waitForAbortableDelay(
+    ms: number,
+    signal: AbortSignal,
+    interruptSignal?: AbortSignal
+): Promise {
+    if (signal.aborted || interruptSignal?.aborted) {
+        return false
+    }
+
+    return await new Promise((resolve) => {
+        let settled = false
+        const finish = (completed: boolean) => {
+            if (settled) return
+            settled = true
+            clearTimeout(timeout)
+            signal.removeEventListener('abort', onAbort)
+            interruptSignal?.removeEventListener('abort', onAbort)
+            resolve(completed)
+        }
+        const timeout = setTimeout(() => {
+            finish(true)
+        }, ms)
+        const onAbort = () => {
+            finish(false)
+        }
+        signal.addEventListener('abort', onAbort, { once: true })
+        interruptSignal?.addEventListener('abort', onAbort, { once: true })
+    })
+}
+
+function hasSameJsonValue(left: unknown, right: unknown): boolean {
+    return JSON.stringify(left) === JSON.stringify(right)
+}
+
 export class ApiSessionClient extends EventEmitter {
     private readonly token: string
     readonly sessionId: string
@@ -171,8 +237,20 @@ export class ApiSessionClient extends EventEmitter {
     private readonly terminalManager: TerminalManager
     private agentStateLock = new AsyncLock()
     private metadataLock = new AsyncLock()
-
-    constructor(token: string, session: Session) {
+    private state: ApiSessionClientState
+    private readonly materializer?: ApiSessionClientOptions['materialize']
+    private readonly onMaterialized?: ApiSessionClientOptions['onMaterialized']
+    private materializationTask: Promise | null = null
+    private materializationAbortController: AbortController | null = null
+    private materializationRetryAbortController: AbortController | null = null
+    private materializationDrainRequested = false
+    private awaitingMaterializedConnection = false
+    private metadataChangedDuringAttempt = false
+    private agentStateChangedDuringAttempt = false
+    private readonly pendingOutboundEvents: PendingOutboundEvent[] = []
+    private didWarnPendingQueueFull = false
+
+    constructor(token: string, session: Session, options: ApiSessionClientOptions = {}) {
         super()
         this.token = token
         this.sessionId = session.id
@@ -180,6 +258,9 @@ export class ApiSessionClient extends EventEmitter {
         this.metadataVersion = session.metadataVersion
         this.agentState = session.agentState
         this.agentStateVersion = session.agentStateVersion
+        this.materializer = options.materialize
+        this.onMaterialized = options.onMaterialized
+        this.state = this.materializer ? 'pending' : 'active'
 
         this.rpcHandlerManager = new RpcHandlerManager({
             scopePrefix: this.sessionId,
@@ -217,6 +298,7 @@ export class ApiSessionClient extends EventEmitter {
 
         this.socket.on('connect', () => {
             logger.debug('Socket connected successfully')
+            this.awaitingMaterializedConnection = false
             this.rpcHandlerManager.onSocketConnect(this.socket)
             if (this.hasConnectedOnce) {
                 this.needsBackfill = true
@@ -332,7 +414,186 @@ export class ApiSessionClient extends EventEmitter {
             }
         })
 
-        this.socket.connect()
+        if (this.state === 'active') {
+            this.socket.connect()
+        }
+    }
+
+    getState(): ApiSessionClientState {
+        return this.state
+    }
+
+    isPending(): boolean {
+        return this.state === 'pending' || this.state === 'materializing'
+    }
+
+    private isClosed(): boolean {
+        return this.state === 'closed'
+    }
+
+    async materialize(): Promise {
+        if (this.state === 'active') {
+            return true
+        }
+        if (this.state === 'closed' || !this.materializer || this.materializationDrainRequested) {
+            return false
+        }
+        if (this.materializationTask) {
+            return await this.materializationTask
+        }
+
+        this.state = 'materializing'
+        const abortController = new AbortController()
+        this.materializationAbortController = abortController
+        this.materializationTask = this.runMaterializationLoop(abortController.signal)
+            .finally(() => {
+                this.materializationTask = null
+                if (this.materializationAbortController === abortController) {
+                    this.materializationAbortController = null
+                }
+            })
+        return await this.materializationTask
+    }
+
+    private async runMaterializationLoop(signal: AbortSignal): Promise {
+        let retryDelayMs = MATERIALIZATION_RETRY_MIN_MS
+        let finalDrainAttemptStarted = false
+
+        while (!signal.aborted && !this.isClosed()) {
+            this.metadataChangedDuringAttempt = false
+            this.agentStateChangedDuringAttempt = false
+            const snapshot: PendingSessionSnapshot = {
+                metadata: this.metadata,
+                agentState: this.agentState
+            }
+
+            try {
+                const materialized = await this.materializer!(snapshot, signal)
+                if (signal.aborted || this.isClosed()) {
+                    return false
+                }
+
+                const latestMetadata = this.metadata
+                const latestAgentState = this.agentState
+                const shouldSyncMetadata = this.metadataChangedDuringAttempt
+                    || !hasSameJsonValue(materialized.metadata, latestMetadata)
+                const shouldSyncAgentState = this.agentStateChangedDuringAttempt
+                    || !hasSameJsonValue(materialized.agentState, latestAgentState)
+
+                this.metadata = materialized.metadata
+                this.metadataVersion = materialized.metadataVersion
+                this.agentState = materialized.agentState
+                this.agentStateVersion = materialized.agentStateVersion
+                this.state = 'active'
+
+                if (shouldSyncMetadata && latestMetadata) {
+                    this.updateMetadata(() => latestMetadata)
+                }
+                if (shouldSyncAgentState && latestAgentState) {
+                    this.updateAgentState(() => latestAgentState)
+                }
+
+                const pendingEvents = this.pendingOutboundEvents.splice(0)
+                this.awaitingMaterializedConnection = pendingEvents.length > 0
+                    || shouldSyncMetadata
+                    || shouldSyncAgentState
+                for (const pendingEvent of pendingEvents) {
+                    pendingEvent.emit()
+                }
+                this.socket.connect()
+                try {
+                    this.onMaterialized?.(materialized, {
+                        metadata: latestMetadata,
+                        agentState: latestAgentState
+                    })
+                } catch (error) {
+                    logger.debug(`[API] Post-materialization callback failed for ${this.sessionId}`, error)
+                }
+                logger.debug(`[API] Materialized pending session ${this.sessionId}`)
+                return true
+            } catch (error) {
+                if (signal.aborted || this.isClosed()) {
+                    return false
+                }
+                if (!isTransientMaterializationError(error)) {
+                    this.state = 'pending'
+                    logger.warn(`[API] Failed to materialize pending session ${this.sessionId}`, error)
+                    return false
+                }
+                if (this.materializationDrainRequested) {
+                    if (finalDrainAttemptStarted) {
+                        this.state = 'pending'
+                        return false
+                    }
+                    finalDrainAttemptStarted = true
+                    logger.debug(`[API] Retrying materialization once during final drain for ${this.sessionId}`)
+                    continue
+                }
+
+                logger.debug(
+                    `[API] Hub unavailable while materializing ${this.sessionId}; retrying in ${retryDelayMs}ms`,
+                    error
+                )
+                const retryAbortController = new AbortController()
+                this.materializationRetryAbortController = retryAbortController
+                const completedDelay = await waitForAbortableDelay(
+                    retryDelayMs,
+                    signal,
+                    retryAbortController.signal
+                )
+                if (this.materializationRetryAbortController === retryAbortController) {
+                    this.materializationRetryAbortController = null
+                }
+                if (!completedDelay) {
+                    if (signal.aborted || this.isClosed()) {
+                        return false
+                    }
+                    if (this.materializationDrainRequested && !finalDrainAttemptStarted) {
+                        finalDrainAttemptStarted = true
+                        logger.debug(`[API] Skipping materialization backoff during final drain for ${this.sessionId}`)
+                        continue
+                    }
+                    this.state = 'pending'
+                    return false
+                }
+                retryDelayMs = Math.min(retryDelayMs * 2, MATERIALIZATION_RETRY_MAX_MS)
+            }
+        }
+
+        return false
+    }
+
+    private emitOrQueue(
+        emit: () => void,
+        retention: PendingOutboundEvent['retention'] = 'lossless'
+    ): void {
+        if (this.state === 'active') {
+            emit()
+            return
+        }
+        if (this.state === 'closed') {
+            return
+        }
+
+        if (retention === 'droppable') {
+            const droppableCount = this.pendingOutboundEvents.reduce(
+                (count, event) => count + (event.retention === 'droppable' ? 1 : 0),
+                0
+            )
+            if (droppableCount >= MAX_PENDING_DROPPABLE_EVENTS) {
+                const oldestDroppableIndex = this.pendingOutboundEvents.findIndex(
+                    (event) => event.retention === 'droppable'
+                )
+                if (oldestDroppableIndex >= 0) {
+                    this.pendingOutboundEvents.splice(oldestDroppableIndex, 1)
+                }
+                if (!this.didWarnPendingQueueFull) {
+                    this.didWarnPendingQueueFull = true
+                    logger.warn(`[API] Pending control event queue full for ${this.sessionId}; dropping oldest control event`)
+                }
+            }
+        }
+        this.pendingOutboundEvents.push({ emit, retention })
     }
 
     onUserMessage(callback: (data: UserMessage, localId?: string) => void): void {
@@ -482,9 +743,11 @@ export class ApiSessionClient extends EventEmitter {
             }
         }
 
-        this.socket.emit('message', {
-            sid: this.sessionId,
-            message: content
+        this.emitOrQueue(() => {
+            this.socket.emit('message', {
+                sid: this.sessionId,
+                message: content
+            })
         })
 
         if (body.type === 'summary' && 'summary' in body && 'leafUuid' in body) {
@@ -515,10 +778,17 @@ export class ApiSessionClient extends EventEmitter {
             }
         }
 
-        this.socket.emit('message', {
-            sid: this.sessionId,
-            message: content
+        this.emitOrQueue(() => {
+            this.socket.emit('message', {
+                sid: this.sessionId,
+                message: content
+            })
         })
+        this.notifyUserActivity()
+    }
+
+    notifyUserActivity(): void {
+        void this.materialize()
     }
 
     sendAgentMessage(body: unknown): void {
@@ -532,9 +802,11 @@ export class ApiSessionClient extends EventEmitter {
                 sentFrom: 'cli'
             }
         }
-        this.socket.emit('message', {
-            sid: this.sessionId,
-            message: content
+        this.emitOrQueue(() => {
+            this.socket.emit('message', {
+                sid: this.sessionId,
+                message: content
+            })
         })
     }
 
@@ -559,10 +831,12 @@ export class ApiSessionClient extends EventEmitter {
             }
         }
 
-        this.socket.emit('message', {
-            sid: this.sessionId,
-            message: content
-        })
+        this.emitOrQueue(() => {
+            this.socket.emit('message', {
+                sid: this.sessionId,
+                message: content
+            })
+        }, event.type === 'message' ? 'lossless' : 'droppable')
     }
 
     keepAlive(
@@ -577,6 +851,9 @@ export class ApiSessionClient extends EventEmitter {
             collaborationMode?: SessionCollaborationMode
         }
     ): void {
+        if (this.state !== 'active') {
+            return
+        }
         this.socket.volatile.emit('session-alive', {
             sid: this.sessionId,
             time: Date.now(),
@@ -588,10 +865,12 @@ export class ApiSessionClient extends EventEmitter {
 
     /** Hub waits for this before mergeSessions on Cursor ACP reopen (tiann/hapi#939). */
     emitSessionReady(): void {
-        this.socket.emit('session-ready', {
-            sid: this.sessionId,
-            time: Date.now()
-        })
+        this.emitOrQueue(() => {
+            this.socket.emit('session-ready', {
+                sid: this.sessionId,
+                time: Date.now()
+            })
+        }, 'droppable')
     }
 
     emitMessagesConsumed(localIds: string[], options?: { clearQueuedThinkingGrace?: boolean }): void {
@@ -609,15 +888,28 @@ export class ApiSessionClient extends EventEmitter {
         if (options?.clearQueuedThinkingGrace) {
             payload.clearQueuedThinkingGrace = true
         }
-        this.socket.emit('messages-consumed', payload)
+        this.emitOrQueue(() => this.socket.emit('messages-consumed', payload))
     }
 
     sendSessionDeath(reason?: SessionEndReason): void {
-        void cleanupUploadDir(this.sessionId)
-        this.socket.emit('session-end', { sid: this.sessionId, time: Date.now(), reason })
+        if (this.state === 'active') {
+            void cleanupUploadDir(this.sessionId)
+        }
+        this.emitOrQueue(() => {
+            this.socket.emit('session-end', { sid: this.sessionId, time: Date.now(), reason })
+        })
     }
 
     updateMetadata(handler: (metadata: Metadata) => Metadata): void {
+        if (this.state !== 'active') {
+            if (this.state === 'closed') return
+            const current = this.metadata ?? ({} as Metadata)
+            this.metadata = handler(current)
+            if (this.state === 'materializing') {
+                this.metadataChangedDuringAttempt = true
+            }
+            return
+        }
         this.metadataLock.inLock(async () => {
             await backoff(async () => {
                 const current = this.metadata ?? ({} as Metadata)
@@ -654,6 +946,15 @@ export class ApiSessionClient extends EventEmitter {
     }
 
     updateAgentState(handler: (state: AgentState) => AgentState): void {
+        if (this.state !== 'active') {
+            if (this.state === 'closed') return
+            const current = this.agentState ?? ({} as AgentState)
+            this.agentState = handler(current)
+            if (this.state === 'materializing') {
+                this.agentStateChangedDuringAttempt = true
+            }
+            return
+        }
         this.agentStateLock.inLock(async () => {
             await backoff(async () => {
                 const current = this.agentState ?? ({} as AgentState)
@@ -689,62 +990,83 @@ export class ApiSessionClient extends EventEmitter {
         })
     }
 
-    private async waitForConnected(timeoutMs: number): Promise {
-        if (this.socket.connected) {
-            return true
+    private async drainLock(lock: AsyncLock, timeoutMs: number): Promise {
+        if (timeoutMs <= 0) {
+            return false
         }
 
-        this.socket.connect()
-
         return await new Promise((resolve) => {
             let settled = false
+            let timeout: ReturnType | null = null
 
-            const cleanup = () => {
-                this.socket.off('connect', onConnect)
-                clearTimeout(timeout)
-            }
-
-            const onConnect = () => {
+            const finish = (value: boolean) => {
                 if (settled) return
                 settled = true
-                cleanup()
-                resolve(true)
+                if (timeout) {
+                    clearTimeout(timeout)
+                }
+                resolve(value)
             }
 
-            const timeout = setTimeout(() => {
+            timeout = setTimeout(() => finish(false), timeoutMs)
+
+            lock.inLock(async () => { })
+                .then(() => finish(true))
+                .catch(() => finish(false))
+        })
+    }
+
+    private async waitForPromise(promise: Promise, timeoutMs: number): Promise {
+        if (timeoutMs <= 0) {
+            return false
+        }
+
+        return await new Promise((resolve) => {
+            let settled = false
+            const timeout = setTimeout(() => finish(false), timeoutMs)
+            const finish = (value: boolean) => {
                 if (settled) return
                 settled = true
-                cleanup()
-                resolve(false)
-            }, Math.max(0, timeoutMs))
+                clearTimeout(timeout)
+                resolve(value)
+            }
 
-            this.socket.on('connect', onConnect)
+            promise.then(() => finish(true)).catch(() => finish(true))
         })
     }
 
-    private async drainLock(lock: AsyncLock, timeoutMs: number): Promise {
+    private async waitForConnected(timeoutMs: number): Promise {
+        if (this.socket.connected) {
+            return true
+        }
         if (timeoutMs <= 0) {
             return false
         }
 
         return await new Promise((resolve) => {
             let settled = false
-            let timeout: ReturnType | null = null
-
+            const cleanup = () => {
+                clearTimeout(timeout)
+                this.socket.off('connect', onConnect)
+            }
             const finish = (value: boolean) => {
                 if (settled) return
                 settled = true
-                if (timeout) {
-                    clearTimeout(timeout)
-                }
+                cleanup()
                 resolve(value)
             }
+            const onConnect = () => finish(true)
+            const timeout = setTimeout(() => finish(false), timeoutMs)
 
-            timeout = setTimeout(() => finish(false), timeoutMs)
+            this.socket.on('connect', onConnect)
 
-            lock.inLock(async () => { })
-                .then(() => finish(true))
-                .catch(() => finish(false))
+            if (!this.awaitingMaterializedConnection) {
+                this.socket.connect()
+            }
+
+            if (this.socket.connected) {
+                finish(true)
+            }
         })
     }
 
@@ -760,23 +1082,38 @@ export class ApiSessionClient extends EventEmitter {
      * Returns true when the lock drained, false when the timeout fired.
      */
     async flushMetadata(timeoutMs: number = 5_000): Promise {
+        if (this.state !== 'active') {
+            return false
+        }
         return await this.drainLock(this.metadataLock, timeoutMs)
     }
 
     async flush(options?: { timeoutMs?: number }): Promise {
         const deadlineMs = Date.now() + (options?.timeoutMs ?? 5_000)
-
         const remainingMs = () => Math.max(0, deadlineMs - Date.now())
 
-        await this.drainLock(this.metadataLock, remainingMs())
-        await this.drainLock(this.agentStateLock, remainingMs())
+        const materializationTask = this.materializationTask
+        if (materializationTask) {
+            this.materializationDrainRequested = true
+            this.materializationRetryAbortController?.abort()
+            await this.waitForPromise(materializationTask, remainingMs())
+        }
 
-        if (remainingMs() === 0) {
+        if (this.state !== 'active') {
             return
         }
 
-        const connected = await this.waitForConnected(remainingMs())
-        if (!connected) {
+        if (!this.socket.connected) {
+            const connected = await this.waitForConnected(remainingMs())
+            if (!connected) {
+                return
+            }
+        }
+
+        await this.drainLock(this.metadataLock, remainingMs())
+        await this.drainLock(this.agentStateLock, remainingMs())
+
+        if (remainingMs() === 0) {
             return
         }
 
@@ -787,12 +1124,23 @@ export class ApiSessionClient extends EventEmitter {
 
         try {
             await this.socket.timeout(pingTimeoutMs).emitWithAck('ping')
+            this.awaitingMaterializedConnection = false
         } catch {
             // best effort
         }
     }
 
     close(): void {
+        if (this.state === 'closed') {
+            return
+        }
+        this.state = 'closed'
+        this.materializationAbortController?.abort()
+        this.materializationAbortController = null
+        this.materializationRetryAbortController?.abort()
+        this.materializationRetryAbortController = null
+        this.awaitingMaterializedConnection = false
+        this.pendingOutboundEvents.length = 0
         this.rpcHandlerManager.onSocketDisconnect()
         this.terminalManager.closeAll()
         this.socket.disconnect()
diff --git a/cli/src/claude/utils/sessionHookForwarder.ts b/cli/src/claude/utils/sessionHookForwarder.ts
index 8cc206d307..b34183ac3e 100644
--- a/cli/src/claude/utils/sessionHookForwarder.ts
+++ b/cli/src/claude/utils/sessionHookForwarder.ts
@@ -1,5 +1,7 @@
 import { request } from 'node:http';
 
+export const SESSION_HOOK_FORWARD_TIMEOUT_MS = 1_000;
+
 function logError(message: string, error?: unknown): void {
     const detail = error instanceof Error ? error.message : (error ? String(error) : '');
     const suffix = detail ? `: ${detail}` : '';
@@ -93,6 +95,13 @@ export async function runSessionHookForwarder(args: string[]): Promise {
 
         let hadError = false;
         await new Promise((resolve) => {
+            let settled = false;
+            let timedOut = false;
+            const finish = () => {
+                if (settled) return;
+                settled = true;
+                resolve();
+            };
             const req = request({
                 host: '127.0.0.1',
                 port,
@@ -111,16 +120,25 @@ export async function runSessionHookForwarder(args: string[]): Promise {
                 res.on('error', (error) => {
                     hadError = true;
                     logError('Error reading hook server response', error);
-                    resolve();
+                    finish();
                 });
-                res.on('end', () => resolve());
+                res.on('end', finish);
                 res.resume();
             });
 
             req.on('error', (error) => {
                 hadError = true;
-                logError('Failed to send hook request', error);
-                resolve();
+                if (!timedOut) {
+                    logError('Failed to send hook request', error);
+                }
+                finish();
+            });
+            req.setTimeout(SESSION_HOOK_FORWARD_TIMEOUT_MS, () => {
+                timedOut = true;
+                hadError = true;
+                logError(`Hook request timed out after ${SESSION_HOOK_FORWARD_TIMEOUT_MS}ms`);
+                req.destroy();
+                finish();
             });
             req.end(body);
         });
diff --git a/cli/src/claude/utils/startHookServer.ts b/cli/src/claude/utils/startHookServer.ts
index 02073edfe6..096ac6f19e 100644
--- a/cli/src/claude/utils/startHookServer.ts
+++ b/cli/src/claude/utils/startHookServer.ts
@@ -107,7 +107,6 @@ export async function startHookServer(options: HookServerOptions): Promise {
+                        try {
+                            onSessionHook(sessionId, data);
+                        } catch (error) {
+                            logger.debug('[hookServer] Error dispatching session hook:', error);
+                        }
+                    });
                 } catch (error) {
                     clearTimeout(timeout);
                     if (timedOut) {
diff --git a/cli/src/codex/codexLocalLauncher.test.ts b/cli/src/codex/codexLocalLauncher.test.ts
index 74e7237a4d..2594ca5a98 100644
--- a/cli/src/codex/codexLocalLauncher.test.ts
+++ b/cli/src/codex/codexLocalLauncher.test.ts
@@ -70,11 +70,13 @@ function createSessionStub(
     codexArgs?: string[],
     path = '/tmp/worktree',
     initialTranscriptPath: string | null = null,
-    replayTranscriptHistoryOnStart = false
+    replayTranscriptHistoryOnStart = false,
+    pendingClient = false
 ) {
     const sessionEvents: Array<{ type: string; message?: string }> = [];
     const userMessages: string[] = [];
     const agentMessages: unknown[] = [];
+    let userActivityCount = 0;
     let localLaunchFailure: { message: string; exitReason: 'switch' | 'exit' } | null = null;
     let sessionId: string | null = null;
     let transcriptPath: string | null = initialTranscriptPath;
@@ -94,6 +96,7 @@ function createSessionStub(
             codexArgs,
             replayTranscriptHistoryOnStart,
             client: {
+                isPending: () => pendingClient,
                 rpcHandlerManager: {
                     registerHandler: () => {}
                 }
@@ -130,6 +133,9 @@ function createSessionStub(
             sendUserMessage: (message: string) => {
                 userMessages.push(message);
             },
+            notifyUserActivity: () => {
+                userActivityCount += 1;
+            },
             sendAgentMessage: (message: unknown) => {
                 agentMessages.push(message);
             },
@@ -138,6 +144,7 @@ function createSessionStub(
         sessionEvents,
         userMessages,
         agentMessages,
+        getUserActivityCount: () => userActivityCount,
         getLocalLaunchFailure: () => localLaunchFailure
     };
 }
@@ -354,6 +361,68 @@ describe('codexLocalLauncher', () => {
         });
     });
 
+    it('falls back to fresh transcript activity when SessionStart does not arrive', async () => {
+        const originalCodexHome = process.env.CODEX_HOME;
+        process.env.CODEX_HOME = tempDir;
+        const now = new Date();
+        const sessionDirectory = join(
+            tempDir,
+            'sessions',
+            String(now.getUTCFullYear()),
+            String(now.getUTCMonth() + 1).padStart(2, '0'),
+            String(now.getUTCDate()).padStart(2, '0')
+        );
+        await mkdir(sessionDirectory, { recursive: true });
+        const transcriptPath = join(sessionDirectory, 'rollout-fallback-thread.jsonl');
+        const { session, userMessages } = createSessionStub(
+            'default',
+            ['--cd', '/tmp/effective-codex-cwd'],
+            '/tmp/worktree',
+            null,
+            true,
+            true
+        );
+        let releaseRunBarrier: (() => void) | undefined;
+        harness.runBarrier = new Promise((resolve) => {
+            releaseRunBarrier = resolve;
+        });
+
+        try {
+            const launcherPromise = codexLocalLauncher(session as never);
+            await vi.waitFor(() => expect(harness.launches).toHaveLength(1));
+            expect(session.sessionId).toBeNull();
+
+            await writeFile(transcriptPath, [
+                JSON.stringify({
+                    type: 'session_meta',
+                    payload: { id: 'fallback-thread', cwd: '/tmp/effective-codex-cwd' }
+                }),
+                JSON.stringify({
+                    timestamp: new Date().toISOString(),
+                    type: 'event_msg',
+                    payload: { type: 'user_message', message: 'fallback prompt' }
+                })
+            ].join('\n') + '\n');
+
+            await vi.waitFor(
+                () => expect(session.sessionId).toBe('fallback-thread'),
+                { timeout: 3_000, interval: 50 }
+            );
+            if (releaseRunBarrier) releaseRunBarrier();
+            await launcherPromise;
+
+            expect(session.transcriptPath).toBe(transcriptPath);
+            expect(userMessages).toContain('fallback prompt');
+        } finally {
+            if (releaseRunBarrier) releaseRunBarrier();
+            if (originalCodexHome === undefined) {
+                delete process.env.CODEX_HOME;
+            } else {
+                process.env.CODEX_HOME = originalCodexHome;
+            }
+        }
+    });
+
     it('replays existing transcript messages when importing a Codex thread into a new Hapi session', async () => {
         const transcriptPath = join(tempDir, 'codex-import-transcript.jsonl');
         const { session, agentMessages } = createSessionStub('default', undefined, '/tmp/worktree', null, true);
@@ -392,7 +461,7 @@ describe('codexLocalLauncher', () => {
 
     it('replays existing response_item chat messages when importing a Codex thread into a new Hapi session', async () => {
         const transcriptPath = join(tempDir, 'codex-import-response-item-transcript.jsonl');
-        const { session, userMessages, agentMessages } = createSessionStub('default', undefined, '/tmp/worktree', null, true);
+        const { session, userMessages, agentMessages, getUserActivityCount } = createSessionStub('default', undefined, '/tmp/worktree', null, true);
         let releaseRunBarrier: (() => void) | undefined;
         harness.runBarrier = new Promise((resolve) => {
             releaseRunBarrier = resolve;
@@ -417,6 +486,14 @@ describe('codexLocalLauncher', () => {
                         role: 'assistant',
                         content: [{ type: 'output_text', text: 'old response_item assistant message' }]
                     }
+                }),
+                JSON.stringify({
+                    type: 'response_item',
+                    payload: {
+                        type: 'message',
+                        role: 'user',
+                        content: [{ type: 'input_image', image_url: 'data:image/png;base64,abc' }]
+                    }
                 })
             ].join('\n') + '\n'
         );
@@ -435,6 +512,7 @@ describe('codexLocalLauncher', () => {
         await launcherPromise;
 
         expect(userMessages).toContain('old response_item user message');
+        expect(getUserActivityCount()).toBe(1);
         expect(agentMessages).toContainEqual({
             type: 'message',
             message: 'old response_item assistant message',
diff --git a/cli/src/codex/codexLocalLauncher.ts b/cli/src/codex/codexLocalLauncher.ts
index a02e23ed6a..7df3e95511 100644
--- a/cli/src/codex/codexLocalLauncher.ts
+++ b/cli/src/codex/codexLocalLauncher.ts
@@ -1,4 +1,5 @@
 import { logger } from '@/ui/logger';
+import { resolve } from 'node:path';
 import { startHookServer } from '@/claude/utils/startHookServer';
 import { codexLocal } from './codexLocal';
 import type { ReasoningEffort } from './appServerTypes';
@@ -6,9 +7,10 @@ import { CodexSession } from './session';
 import { createCodexSessionScanner, type CodexSessionScanner } from './utils/codexSessionScanner';
 import { convertCodexEvent } from './utils/codexEventConverter';
 import { buildHapiMcpBridge } from './utils/buildHapiMcpBridge';
-import { stripCodexCliOverrides } from './utils/codexCliOverrides';
+import { parseCodexCliOverrides, stripCodexCliOverrides } from './utils/codexCliOverrides';
 import { buildCodexPermissionModeCliArgs } from './utils/permissionModeConfig';
 import { BaseLocalLauncher } from '@/modules/common/launcher/BaseLocalLauncher';
+import { createCodexTranscriptLocator, type CodexTranscriptLocator } from './utils/codexTranscriptLocator';
 
 export async function codexLocalLauncher(session: CodexSession): Promise<'switch' | 'exit'> {
     const resumeSessionId = session.sessionId;
@@ -18,6 +20,7 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch
     let hookReady = false;
     let shuttingDown = false;
     let pendingScannerSetup: Promise | null = null;
+    let transcriptLocator: CodexTranscriptLocator | null = null;
     const permissionMode = session.getPermissionMode();
     const managedPermissionMode = permissionMode === 'read-only' || permissionMode === 'safe-yolo' || permissionMode === 'yolo'
         ? permissionMode
@@ -28,6 +31,8 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch
             ...stripCodexCliOverrides(session.codexArgs)
         ]
         : session.codexArgs;
+    const cwdOverride = parseCodexCliOverrides(session.codexArgs).cwd;
+    const effectiveCodexCwd = cwdOverride ? resolve(session.path, cwdOverride) : session.path;
 
     // Start hapi hub for MCP bridge (same as remote mode)
     const { server: happyServer, mcpServers } = await buildHapiMcpBridge(session.client);
@@ -103,6 +108,8 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch
                 }
                 if (converted?.userMessage) {
                     session.sendUserMessage(converted.userMessage);
+                } else if (converted?.userActivity) {
+                    session.notifyUserActivity();
                 }
                 if (converted?.message) {
                     session.sendAgentMessage(converted.message);
@@ -142,6 +149,12 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch
         const hookSource = typeof data.source === 'string' ? data.source : null;
         const shouldAllowSessionSwitch = hookSource === 'clear';
 
+        if (transcriptPath) {
+            const activeLocator = transcriptLocator;
+            transcriptLocator = null;
+            void activeLocator?.cleanup();
+        }
+
         if (!transcriptPath) {
             handleSessionFound(sessionId, shouldAllowSessionSwitch);
             return;
@@ -160,6 +173,27 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch
     });
     logger.debug(`[codex-local]: Started Codex SessionStart hook server on port ${hookServer.port}`);
 
+    if (session.client.isPending()) {
+        const createdLocator = createCodexTranscriptLocator({
+            cwd: effectiveCodexCwd,
+            startupTimestampMs: Date.now(),
+            resumeSessionId,
+            onLocated: ({ sessionId, transcriptPath }) => {
+                if (shuttingDown || hookReady || primaryTranscriptPath) {
+                    return;
+                }
+                transcriptLocator = null;
+                bindPrimarySession(sessionId, transcriptPath);
+            },
+            onAmbiguous: (paths) => {
+                transcriptLocator = null;
+                logger.warn(`[codex-local]: Transcript fallback was ambiguous (${paths.length} active candidates)`);
+            }
+        });
+        transcriptLocator = createdLocator;
+        await createdLocator.ready;
+    }
+
     const launcher = new BaseLocalLauncher({
         label: 'codex-local',
         failureLabel: 'Local Codex process failed',
@@ -204,6 +238,9 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch
         shuttingDown = true;
         session.removeTranscriptPathCallback(handleTranscriptPathCallback);
         hookServer.stop();
+        const activeLocator = transcriptLocator;
+        transcriptLocator = null;
+        void activeLocator?.cleanup();
         if (pendingScannerSetup) {
             await pendingScannerSetup;
         }
diff --git a/cli/src/codex/runCodex.test.ts b/cli/src/codex/runCodex.test.ts
index 7c138bddb2..8b2ab0a2e6 100644
--- a/cli/src/codex/runCodex.test.ts
+++ b/cli/src/codex/runCodex.test.ts
@@ -32,6 +32,14 @@ vi.mock('@/agent/sessionFactory', () => ({
             sessionInfo: harness.sessionInfo
         }
     }),
+    bootstrapLazySession: vi.fn(async (options: Record) => {
+        harness.bootstrapArgs.push({ ...options, lazy: true })
+        return {
+            api: {},
+            session: harness.session,
+            sessionInfo: harness.sessionInfo
+        }
+    }),
     bootstrapExistingSession: vi.fn(async (options: Record) => {
         harness.bootstrapArgs.push(options)
         return {
@@ -202,6 +210,27 @@ describe('runCodex', () => {
         expect(mockCodexSession.setServiceTier).not.toHaveBeenCalled()
     })
 
+    it('uses lazy bootstrap for a fresh terminal launch', async () => {
+        await runCodexImpl({ workingDirectory: '/tmp/project' })
+
+        expect(harness.bootstrapArgs[0]).toEqual(expect.objectContaining({
+            workingDirectory: '/tmp/project',
+            lazy: true
+        }))
+        expect(harness.loopArgs[0]).toEqual(expect.objectContaining({
+            replayTranscriptHistoryOnStart: true
+        }))
+    })
+
+    it('keeps eager bootstrap for runner launches', async () => {
+        await runCodexImpl({
+            startedBy: 'runner',
+            workingDirectory: '/tmp/project'
+        })
+
+        expect(harness.bootstrapArgs[0]).not.toHaveProperty('lazy')
+    })
+
     it('replays transcript history when attaching a new Hapi session to an existing Codex thread', async () => {
         await runCodexImpl({
             workingDirectory: '/tmp/project',
diff --git a/cli/src/codex/runCodex.ts b/cli/src/codex/runCodex.ts
index de907c2271..fe3a0f7088 100644
--- a/cli/src/codex/runCodex.ts
+++ b/cli/src/codex/runCodex.ts
@@ -7,7 +7,7 @@ import { registerKillSessionHandler } from '@/claude/registerKillSessionHandler'
 import type { AgentState } from '@/api/types';
 import type { CodexSession } from './session';
 import { parseCodexCliOverrides } from './utils/codexCliOverrides';
-import { bootstrapExistingSession, bootstrapSession } from '@/agent/sessionFactory';
+import { bootstrapExistingSession, bootstrapLazySession, bootstrapSession } from '@/agent/sessionFactory';
 import { registerLocalHandoffHandler } from '@/agent/localHandoff';
 import { createModeChangeHandler, createRunnerLifecycle, setControlledByUser } from '@/agent/runnerLifecycle';
 import { isPermissionModeAllowedForFlavor } from '@hapi/protocol';
@@ -44,6 +44,7 @@ export async function runCodex(opts: {
     let state: AgentState = {
         controlledByUser: false
     };
+    const useLazyBootstrap = !opts.existingSessionId && startedBy === 'terminal';
     const bootstrap = opts.existingSessionId
         ? await bootstrapExistingSession({
             sessionId: opts.existingSessionId,
@@ -51,7 +52,7 @@ export async function runCodex(opts: {
             startedBy,
             workingDirectory
         })
-        : await bootstrapSession({
+        : await (useLazyBootstrap ? bootstrapLazySession : bootstrapSession)({
             flavor: 'codex',
             startedBy,
             workingDirectory,
@@ -77,7 +78,7 @@ export async function runCodex(opts: {
     const sessionWrapperRef: { current: CodexSession | null } = { current: null };
     // 中文注释:当用户直接把现成的 Codex thread 导入到一个全新的 Hapi 会话时,
     // 需要在首次附着 transcript 时回放已有历史;恢复已有 Hapi 会话时则保持原来的增量模式,避免重复灌入旧消息。
-    const replayTranscriptHistoryOnStart = Boolean(opts.resumeSessionId && !opts.existingSessionId);
+    const replayTranscriptHistoryOnStart = useLazyBootstrap || Boolean(opts.resumeSessionId && !opts.existingSessionId);
 
     let currentPermissionMode: PermissionMode = opts.permissionMode ?? 'default';
     let currentModel = opts.model;
diff --git a/cli/src/codex/session.ts b/cli/src/codex/session.ts
index c8892419d1..1fdf985f97 100644
--- a/cli/src/codex/session.ts
+++ b/cli/src/codex/session.ts
@@ -144,6 +144,10 @@ export class CodexSession extends AgentSessionBase {
         this.client.sendUserMessage(text);
     };
 
+    notifyUserActivity = (): void => {
+        this.client.notifyUserActivity();
+    };
+
     sendSessionEvent = (event: Parameters[0]): void => {
         this.client.sendSessionEvent(event);
     };
diff --git a/cli/src/codex/utils/codexCliOverrides.test.ts b/cli/src/codex/utils/codexCliOverrides.test.ts
index c236bf86c2..06d14960e9 100644
--- a/cli/src/codex/utils/codexCliOverrides.test.ts
+++ b/cli/src/codex/utils/codexCliOverrides.test.ts
@@ -41,6 +41,16 @@ describe('parseCodexCliOverrides', () => {
         expect(parseCodexCliOverrides(['-a', 'untrusted', '-a', 'on-failure'])).toEqual({
             approvalPolicy: 'on-failure'
         });
+
+        expect(parseCodexCliOverrides(['-C', 'first', '--cd=second'])).toEqual({
+            cwd: 'second'
+        });
+    });
+
+    it('parses cwd overrides before the argument terminator', () => {
+        expect(parseCodexCliOverrides(['--cd', '../project'])).toEqual({ cwd: '../project' });
+        expect(parseCodexCliOverrides(['-C=/tmp/project'])).toEqual({ cwd: '/tmp/project' });
+        expect(parseCodexCliOverrides(['--', '--cd', '/tmp/ignored'])).toEqual({});
     });
 
     it('ignores invalid values and stops at terminator', () => {
diff --git a/cli/src/codex/utils/codexCliOverrides.ts b/cli/src/codex/utils/codexCliOverrides.ts
index f2d6eeed6f..6e06014c56 100644
--- a/cli/src/codex/utils/codexCliOverrides.ts
+++ b/cli/src/codex/utils/codexCliOverrides.ts
@@ -1,6 +1,7 @@
 export type CodexCliOverrides = {
     sandbox?: 'read-only' | 'workspace-write' | 'danger-full-access';
     approvalPolicy?: 'untrusted' | 'on-failure' | 'on-request' | 'never';
+    cwd?: string;
 };
 
 const SANDBOX_VALUES = new Set([
@@ -46,6 +47,23 @@ export function parseCodexCliOverrides(args?: string[]): CodexCliOverrides {
             continue;
         }
 
+        if (arg === '-C' || arg === '--cd') {
+            const value = args[i + 1];
+            if (value && value !== '--') {
+                overrides.cwd = value;
+                i += 1;
+            }
+            continue;
+        }
+
+        if (arg.startsWith('--cd=') || arg.startsWith('-C=')) {
+            const value = arg.slice(arg.indexOf('=') + 1);
+            if (value) {
+                overrides.cwd = value;
+            }
+            continue;
+        }
+
         if (arg === '-s' || arg === '--sandbox') {
             const value = args[i + 1];
             if (SANDBOX_VALUES.has(value as CodexCliOverrides['sandbox'])) {
diff --git a/cli/src/codex/utils/codexEventConverter.test.ts b/cli/src/codex/utils/codexEventConverter.test.ts
index 13afe113a9..adbc3bd30c 100644
--- a/cli/src/codex/utils/codexEventConverter.test.ts
+++ b/cli/src/codex/utils/codexEventConverter.test.ts
@@ -43,10 +43,24 @@ describe('convertCodexEvent', () => {
         });
 
         expect(result).toEqual({
-            userMessage: 'hello from response_item user'
+            userMessage: 'hello from response_item user',
+            userActivity: true
         });
     });
 
+    it('marks image-only response_item messages as user activity', () => {
+        const result = convertCodexEvent({
+            type: 'response_item',
+            payload: {
+                type: 'message',
+                role: 'user',
+                content: [{ type: 'input_image', image_url: 'data:image/png;base64,abc' }]
+            }
+        });
+
+        expect(result).toEqual({ userActivity: true });
+    });
+
     it('converts response_item assistant messages', () => {
         const result = convertCodexEvent({
             type: 'response_item',
diff --git a/cli/src/codex/utils/codexEventConverter.ts b/cli/src/codex/utils/codexEventConverter.ts
index efd7394cc0..3930480582 100644
--- a/cli/src/codex/utils/codexEventConverter.ts
+++ b/cli/src/codex/utils/codexEventConverter.ts
@@ -42,6 +42,7 @@ export type CodexConversionResult = {
     sessionId?: string;
     message?: CodexMessage;
     userMessage?: string;
+    userActivity?: true;
 };
 
 function asRecord(value: unknown): Record | null {
@@ -146,11 +147,9 @@ export function convertCodexEvent(rawEvent: unknown): CodexConversionResult | nu
             const message = asString(payloadRecord.message)
                 ?? asString(payloadRecord.text)
                 ?? asString(payloadRecord.content);
-            if (!message) {
-                return null;
-            }
             return {
-                userMessage: message
+                userActivity: true,
+                ...(message ? { userMessage: message } : {})
             };
         }
 
@@ -221,13 +220,16 @@ export function convertCodexEvent(rawEvent: unknown): CodexConversionResult | nu
         if (itemType === 'message') {
             const role = asString(payloadRecord.role);
             const text = extractCodexText(payloadRecord.content);
-            if (!text) {
-                return null;
-            }
             if (role === 'user') {
-                return { userMessage: text };
+                return {
+                    userActivity: true,
+                    ...(text ? { userMessage: text } : {})
+                };
             }
             if (role === 'assistant') {
+                if (!text) {
+                    return null;
+                }
                 return {
                     message: {
                         type: 'message',
diff --git a/cli/src/codex/utils/codexSessionScanner.test.ts b/cli/src/codex/utils/codexSessionScanner.test.ts
index d6a7db8032..7feddcd129 100644
--- a/cli/src/codex/utils/codexSessionScanner.test.ts
+++ b/cli/src/codex/utils/codexSessionScanner.test.ts
@@ -163,4 +163,30 @@ describe('codexSessionScanner', () => {
         expect(events).toHaveLength(1);
         expect(events[0]?.payload).toEqual({ type: 'agent_message', message: 'after-truncate' });
     });
+
+    it('retries an unterminated final record after it is completed', async () => {
+        await writeFile(
+            transcriptPath,
+            JSON.stringify({ type: 'session_meta', payload: { id: 'session-partial' } }) + '\n'
+        );
+        scanner = await createCodexSessionScanner({
+            transcriptPath,
+            onEvent: (event) => events.push(event)
+        });
+        const event = JSON.stringify({
+            type: 'event_msg',
+            payload: { type: 'agent_message', message: 'completed later' }
+        });
+        const splitAt = Math.floor(event.length / 2);
+
+        await appendFile(transcriptPath, event.slice(0, splitAt));
+        await wait(300);
+        expect(events).toEqual([]);
+
+        await appendFile(transcriptPath, event.slice(splitAt));
+        await wait(700);
+
+        expect(events).toHaveLength(1);
+        expect(events[0]?.payload).toEqual({ type: 'agent_message', message: 'completed later' });
+    });
 });
diff --git a/cli/src/codex/utils/codexSessionScanner.ts b/cli/src/codex/utils/codexSessionScanner.ts
index 06bd667014..2a2be6163d 100644
--- a/cli/src/codex/utils/codexSessionScanner.ts
+++ b/cli/src/codex/utils/codexSessionScanner.ts
@@ -122,6 +122,7 @@ class CodexSessionScannerImpl extends BaseSessionScanner {
         const lines = content.split('\n');
         const hasTrailingEmpty = lines.length > 0 && lines[lines.length - 1] === '';
         const totalLines = hasTrailingEmpty ? lines.length - 1 : lines.length;
+        let nextCursor = totalLines;
         const currentSize = Buffer.byteLength(content);
         const previousSize = this.fileSizeByPath.get(filePath);
         let effectiveStartLine = startLine;
@@ -145,6 +146,9 @@ class CodexSessionScannerImpl extends BaseSessionScanner {
                 parsed = JSON.parse(line);
             } catch (error) {
                 logger.debug(`[codex-session-scanner] Failed to parse transcript line ${filePath}:${lineIndex + 1}: ${error}`);
+                if (!hasTrailingEmpty && lineIndex === totalLines - 1) {
+                    nextCursor = lineIndex;
+                }
                 continue;
             }
 
@@ -169,7 +173,7 @@ class CodexSessionScannerImpl extends BaseSessionScanner {
 
         return {
             events,
-            nextCursor: totalLines
+            nextCursor
         };
     }
 
diff --git a/cli/src/codex/utils/codexTranscriptLocator.test.ts b/cli/src/codex/utils/codexTranscriptLocator.test.ts
new file mode 100644
index 0000000000..b633f51b2d
--- /dev/null
+++ b/cli/src/codex/utils/codexTranscriptLocator.test.ts
@@ -0,0 +1,250 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { appendFile, mkdir, rm, writeFile } from 'node:fs/promises';
+import { join } from 'node:path';
+import { tmpdir } from 'node:os';
+import { createCodexTranscriptLocator, type CodexTranscriptLocator } from './codexTranscriptLocator';
+
+const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
+
+describe('codexTranscriptLocator', () => {
+    let codexHome: string;
+    let sessionDirectory: string;
+    let locator: CodexTranscriptLocator | null = null;
+    const originalCodexHome = process.env.CODEX_HOME;
+
+    beforeEach(async () => {
+        codexHome = join(tmpdir(), `codex-transcript-locator-${Date.now()}-${Math.random()}`);
+        const now = new Date();
+        sessionDirectory = join(
+            codexHome,
+            'sessions',
+            String(now.getUTCFullYear()),
+            String(now.getUTCMonth() + 1).padStart(2, '0'),
+            String(now.getUTCDate()).padStart(2, '0')
+        );
+        await mkdir(sessionDirectory, { recursive: true });
+        process.env.CODEX_HOME = codexHome;
+    });
+
+    afterEach(async () => {
+        await locator?.cleanup();
+        locator = null;
+        if (originalCodexHome === undefined) {
+            delete process.env.CODEX_HOME;
+        } else {
+            process.env.CODEX_HOME = originalCodexHome;
+        }
+        await rm(codexHome, { recursive: true, force: true });
+    });
+
+    it('does not attach for session metadata alone', async () => {
+        const located: string[] = [];
+        locator = createCodexTranscriptLocator({
+            cwd: '/tmp/project',
+            startupTimestampMs: Date.now(),
+            intervalMs: 25,
+            onLocated: (result) => located.push(result.transcriptPath)
+        });
+        await locator.ready;
+        const transcriptPath = await createTranscript('thread-meta-only', '/tmp/project');
+
+        await wait(150);
+        expect(located).toEqual([]);
+        expect(transcriptPath).toContain('thread-meta-only');
+    });
+
+    it('attaches after fresh real user activity in the matching cwd', async () => {
+        const located: string[] = [];
+        locator = createCodexTranscriptLocator({
+            cwd: '/tmp/project',
+            startupTimestampMs: Date.now(),
+            intervalMs: 25,
+            settlementMs: 25,
+            onLocated: (result) => located.push(result.transcriptPath)
+        });
+        await locator.ready;
+        const transcriptPath = await createTranscript('thread-user', '/tmp/project');
+
+        await appendFile(transcriptPath, `${JSON.stringify({
+            timestamp: new Date().toISOString(),
+            type: 'event_msg',
+            payload: { type: 'user_message', message: 'hello' }
+        })}\n`);
+        await wait(150);
+
+        expect(located).toEqual([transcriptPath]);
+    });
+
+    it('attaches after image-only user activity', async () => {
+        const located: string[] = [];
+        locator = createCodexTranscriptLocator({
+            cwd: '/tmp/project',
+            startupTimestampMs: Date.now(),
+            intervalMs: 25,
+            settlementMs: 0,
+            onLocated: (result) => located.push(result.transcriptPath)
+        });
+        await locator.ready;
+        const transcriptPath = await createTranscript('thread-image', '/tmp/project');
+
+        await appendFile(transcriptPath, `${JSON.stringify({
+            timestamp: new Date().toISOString(),
+            type: 'response_item',
+            payload: {
+                type: 'message',
+                role: 'user',
+                content: [{ type: 'input_image', image_url: 'data:image/png;base64,abc' }]
+            }
+        })}\n`);
+        await wait(100);
+
+        expect(located).toEqual([transcriptPath]);
+    });
+
+    it('refuses fallback when fresh activity is ambiguous', async () => {
+        const located: string[] = [];
+        const ambiguous: string[][] = [];
+        locator = createCodexTranscriptLocator({
+            cwd: '/tmp/project',
+            startupTimestampMs: Date.now(),
+            intervalMs: 25,
+            settlementMs: 50,
+            onLocated: (result) => located.push(result.transcriptPath),
+            onAmbiguous: (paths) => ambiguous.push(paths)
+        });
+        await locator.ready;
+        const first = await createTranscript('thread-a', '/tmp/project');
+        const second = await createTranscript('thread-b', '/tmp/project');
+
+        const userEvent = `${JSON.stringify({
+            timestamp: new Date().toISOString(),
+            type: 'event_msg',
+            payload: { type: 'user_message', message: 'hello' }
+        })}\n`;
+        await Promise.all([appendFile(first, userEvent), appendFile(second, userEvent)]);
+        await wait(150);
+
+        expect(located).toEqual([]);
+        expect(ambiguous).toHaveLength(1);
+        expect(new Set(ambiguous[0])).toEqual(new Set([first, second]));
+    });
+
+    it('rejects candidates whose activity arrives in adjacent polling cycles', async () => {
+        const located: string[] = [];
+        const ambiguous: string[][] = [];
+        locator = createCodexTranscriptLocator({
+            cwd: '/tmp/project',
+            startupTimestampMs: Date.now(),
+            intervalMs: 25,
+            settlementMs: 150,
+            onLocated: (result) => located.push(result.transcriptPath),
+            onAmbiguous: (paths) => ambiguous.push(paths)
+        });
+        await locator.ready;
+        const first = await createTranscript('thread-staggered-a', '/tmp/project');
+        const second = await createTranscript('thread-staggered-b', '/tmp/project');
+        const userEvent = (message: string) => JSON.stringify({
+            timestamp: new Date().toISOString(),
+            type: 'event_msg',
+            payload: { type: 'user_message', message }
+        });
+
+        await appendFile(first, `${userEvent('first')}\n`);
+        await wait(60);
+        await appendFile(second, `${userEvent('second')}\n`);
+        await wait(150);
+
+        expect(located).toEqual([]);
+        expect(ambiguous).toHaveLength(1);
+        expect(new Set(ambiguous[0])).toEqual(new Set([first, second]));
+    });
+
+    it('retries an unterminated final JSON record after it is completed', async () => {
+        const located: string[] = [];
+        locator = createCodexTranscriptLocator({
+            cwd: '/tmp/project',
+            startupTimestampMs: Date.now(),
+            intervalMs: 25,
+            settlementMs: 0,
+            onLocated: (result) => located.push(result.transcriptPath)
+        });
+        await locator.ready;
+        const transcriptPath = await createTranscript('thread-partial', '/tmp/project');
+        const userEvent = JSON.stringify({
+            timestamp: new Date().toISOString(),
+            type: 'event_msg',
+            payload: { type: 'user_message', message: 'completed later' }
+        });
+        const splitAt = Math.floor(userEvent.length / 2);
+
+        await appendFile(transcriptPath, userEvent.slice(0, splitAt));
+        await wait(75);
+        expect(located).toEqual([]);
+
+        await appendFile(transcriptPath, userEvent.slice(splitAt));
+        await wait(100);
+        expect(located).toEqual([transcriptPath]);
+    });
+
+    it('ignores pre-existing fresh transcripts even when they receive new activity', async () => {
+        const transcriptPath = await createTranscript('thread-existing', '/tmp/project');
+        const located: string[] = [];
+        locator = createCodexTranscriptLocator({
+            cwd: '/tmp/project',
+            startupTimestampMs: Date.now(),
+            intervalMs: 25,
+            settlementMs: 0,
+            onLocated: (result) => located.push(result.transcriptPath)
+        });
+        await locator.ready;
+
+        await appendFile(transcriptPath, `${JSON.stringify({
+            timestamp: new Date().toISOString(),
+            type: 'event_msg',
+            payload: { type: 'user_message', message: 'other terminal' }
+        })}\n`);
+        await wait(100);
+
+        expect(located).toEqual([]);
+    });
+
+    it('polls only the exact resume transcript once it is found', async () => {
+        const unrelated = await createTranscript('thread-unrelated', '/tmp/project');
+        const target = await createTranscript('thread-resume', '/tmp/original-project');
+        await appendFile(unrelated, `${JSON.stringify({
+            timestamp: new Date().toISOString(),
+            type: 'event_msg',
+            payload: { type: 'user_message', message: 'unrelated activity' }
+        })}\n`);
+        const located: string[] = [];
+        const ambiguous: string[][] = [];
+        locator = createCodexTranscriptLocator({
+            cwd: '/tmp/current-project',
+            startupTimestampMs: Date.now(),
+            resumeSessionId: 'thread-resume',
+            intervalMs: 25,
+            onLocated: (result) => located.push(result.transcriptPath),
+            onAmbiguous: (paths) => ambiguous.push(paths)
+        });
+        await locator.ready;
+
+        await appendFile(target, `${JSON.stringify({
+            timestamp: new Date().toISOString(),
+            type: 'event_msg',
+            payload: { type: 'user_message', message: 'resume activity' }
+        })}\n`);
+        await wait(100);
+
+        expect(located).toEqual([target]);
+        expect(ambiguous).toEqual([]);
+    });
+
+    async function createTranscript(sessionId: string, cwd: string): Promise {
+        const transcriptPath = join(sessionDirectory, `rollout-${sessionId}.jsonl`);
+        await writeFile(transcriptPath, `${JSON.stringify({
+            type: 'session_meta',
+            payload: { id: sessionId, cwd }
+        })}\n`);
+        return transcriptPath;
+    }
+});
diff --git a/cli/src/codex/utils/codexTranscriptLocator.ts b/cli/src/codex/utils/codexTranscriptLocator.ts
new file mode 100644
index 0000000000..6d5bcd5000
--- /dev/null
+++ b/cli/src/codex/utils/codexTranscriptLocator.ts
@@ -0,0 +1,380 @@
+import { homedir } from 'node:os';
+import { join, resolve } from 'node:path';
+import { open, readdir, stat } from 'node:fs/promises';
+import { logger } from '@/ui/logger';
+import { convertCodexEvent, type CodexSessionEvent } from './codexEventConverter';
+
+export type LocatedCodexTranscript = {
+    sessionId: string;
+    transcriptPath: string;
+};
+
+export type CodexTranscriptLocator = {
+    ready: Promise;
+    cleanup: () => Promise;
+};
+
+type TranscriptState = {
+    offset: number;
+    size: number;
+    mtimeMs: number;
+    ino: number;
+    sessionId: string | null;
+    cwd: string | null;
+};
+
+type CodexTranscriptLocatorOptions = {
+    cwd: string;
+    startupTimestampMs: number;
+    resumeSessionId?: string | null;
+    intervalMs?: number;
+    settlementMs?: number;
+    onLocated: (located: LocatedCodexTranscript) => void;
+    onAmbiguous?: (paths: string[]) => void;
+};
+
+const DEFAULT_LOCATOR_INTERVAL_MS = 500;
+
+export function createCodexTranscriptLocator(options: CodexTranscriptLocatorOptions): CodexTranscriptLocator {
+    const locator = new CodexTranscriptLocatorImpl(options);
+    const ready = locator.start().catch((error) => {
+        logger.debug('[codex-transcript-locator] Failed to initialize transcript fallback', error);
+    });
+    return {
+        ready,
+        cleanup: async () => {
+            await locator.cleanup();
+            await ready;
+        }
+    };
+}
+
+class CodexTranscriptLocatorImpl {
+    private readonly sessionsRoot: string;
+    private readonly targetCwd: string;
+    private readonly startupTimestampMs: number;
+    private readonly resumeSessionId: string | null;
+    private readonly intervalMs: number;
+    private readonly settlementMs: number;
+    private readonly onLocated: CodexTranscriptLocatorOptions['onLocated'];
+    private readonly onAmbiguous?: CodexTranscriptLocatorOptions['onAmbiguous'];
+    private readonly states = new Map();
+    private readonly initialFreshPaths = new Set();
+    private readonly pendingCandidates = new Map();
+    private resumeTranscriptPaths: string[] | null = null;
+    private firstCandidateTimestampMs: number | null = null;
+    private interval: ReturnType | null = null;
+    private scanPromise: Promise | null = null;
+    private stopped = false;
+
+    constructor(options: CodexTranscriptLocatorOptions) {
+        const codexHome = process.env.CODEX_HOME || join(homedir(), '.codex');
+        this.sessionsRoot = join(codexHome, 'sessions');
+        this.targetCwd = normalizePath(options.cwd);
+        this.startupTimestampMs = options.startupTimestampMs;
+        this.resumeSessionId = options.resumeSessionId ?? null;
+        this.intervalMs = options.intervalMs ?? DEFAULT_LOCATOR_INTERVAL_MS;
+        this.settlementMs = options.settlementMs ?? this.intervalMs;
+        this.onLocated = options.onLocated;
+        this.onAmbiguous = options.onAmbiguous;
+    }
+
+    async start(): Promise {
+        if (!this.resumeSessionId) {
+            const existingPaths = await this.listNearbyTranscriptFiles();
+            for (const transcriptPath of existingPaths) {
+                this.initialFreshPaths.add(transcriptPath);
+            }
+        }
+        if (this.stopped) return;
+
+        void this.scan();
+        this.interval = setInterval(() => void this.scan(), this.intervalMs);
+        this.interval.unref?.();
+    }
+
+    async cleanup(): Promise {
+        this.stopped = true;
+        if (this.interval) {
+            clearInterval(this.interval);
+            this.interval = null;
+        }
+        await this.scanPromise?.catch(() => {});
+    }
+
+    private async scan(): Promise {
+        if (this.stopped || this.scanPromise) {
+            return this.scanPromise ?? Promise.resolve();
+        }
+
+        this.scanPromise = this.runScan();
+        try {
+            await this.scanPromise;
+        } finally {
+            this.scanPromise = null;
+        }
+    }
+
+    private async runScan(): Promise {
+        const files = await this.listCandidateFiles();
+        for (const transcriptPath of files) {
+            if (this.stopped) return;
+            const candidate = await this.scanFile(transcriptPath);
+            if (candidate) {
+                this.pendingCandidates.set(candidate.transcriptPath, candidate);
+            }
+        }
+
+        if (this.stopped || this.pendingCandidates.size === 0) {
+            return;
+        }
+
+        if (this.pendingCandidates.size > 1) {
+            const paths = [...this.pendingCandidates.keys()];
+            logger.warn('[codex-transcript-locator] Ambiguous Codex transcript activity; refusing fallback attachment', paths);
+            this.stopPolling();
+            this.onAmbiguous?.(paths);
+            return;
+        }
+
+        const [located] = this.pendingCandidates.values();
+        if (!located) return;
+
+        if (!this.resumeSessionId) {
+            if (this.firstCandidateTimestampMs === null) {
+                this.firstCandidateTimestampMs = Date.now();
+            }
+            if (Date.now() - this.firstCandidateTimestampMs < this.settlementMs) {
+                return;
+            }
+        }
+
+        logger.debug(`[codex-transcript-locator] Located ${located.sessionId} at ${located.transcriptPath}`);
+        this.stopPolling();
+        this.onLocated(located);
+    }
+
+    private async scanFile(transcriptPath: string): Promise {
+        let fileStats: Awaited>;
+        try {
+            fileStats = await stat(transcriptPath);
+        } catch {
+            return null;
+        }
+        if (!fileStats.isFile()) return null;
+
+        const previous = this.states.get(transcriptPath);
+        let state: TranscriptState = previous ?? {
+            offset: 0,
+            size: 0,
+            mtimeMs: 0,
+            ino: fileStats.ino,
+            sessionId: null,
+            cwd: null
+        };
+
+        const replaced = previous && previous.ino !== fileStats.ino;
+        const truncated = previous && fileStats.size < previous.offset;
+        const rewrittenAtSameSize = previous
+            && fileStats.size === previous.size
+            && fileStats.mtimeMs !== previous.mtimeMs
+            && previous.offset === previous.size;
+        if (replaced || truncated || rewrittenAtSameSize) {
+            state = {
+                offset: 0,
+                size: 0,
+                mtimeMs: 0,
+                ino: fileStats.ino,
+                sessionId: null,
+                cwd: null
+            };
+        } else if (previous
+            && fileStats.size === previous.size
+            && fileStats.mtimeMs === previous.mtimeMs) {
+            return null;
+        }
+
+        if (fileStats.size <= state.offset) {
+            state.size = fileStats.size;
+            state.mtimeMs = fileStats.mtimeMs;
+            state.ino = fileStats.ino;
+            this.states.set(transcriptPath, state);
+            return null;
+        }
+
+        let content: Buffer;
+        try {
+            content = await readBytes(transcriptPath, state.offset, fileStats.size - state.offset);
+        } catch {
+            return null;
+        }
+
+        const startOffset = state.offset;
+        const text = content.toString('utf8');
+        const lines = text.split('\n');
+        let consumedBytes = 0;
+        let sawFreshUserActivity = false;
+
+        for (let index = 0; index < lines.length; index += 1) {
+            const line = lines[index] ?? '';
+            const terminated = index < lines.length - 1;
+            const recordBytes = Buffer.byteLength(line) + (terminated ? 1 : 0);
+
+            if (!line.trim()) {
+                if (terminated) consumedBytes += recordBytes;
+                continue;
+            }
+
+            let event: CodexSessionEvent;
+            try {
+                event = JSON.parse(line) as CodexSessionEvent;
+            } catch {
+                if (terminated) consumedBytes += recordBytes;
+                continue;
+            }
+
+            if (event.type === 'session_meta') {
+                const metadata = asRecord(event.payload);
+                state.sessionId = asString(metadata?.id) ?? state.sessionId;
+                const eventCwd = asString(metadata?.cwd);
+                state.cwd = eventCwd ? normalizePath(eventCwd) : state.cwd;
+            }
+
+            if (convertCodexEvent(event)?.userActivity) {
+                const eventTimestamp = parseTimestamp(event.timestamp);
+                if (eventTimestamp !== null && eventTimestamp >= this.startupTimestampMs) {
+                    sawFreshUserActivity = true;
+                }
+            }
+
+            consumedBytes += recordBytes;
+        }
+
+        state.offset = startOffset + consumedBytes;
+        state.size = startOffset + content.length;
+        state.mtimeMs = fileStats.mtimeMs;
+        state.ino = fileStats.ino;
+        this.states.set(transcriptPath, state);
+
+        if (!sawFreshUserActivity || !state.sessionId) {
+            return null;
+        }
+        if (this.resumeSessionId) {
+            if (state.sessionId !== this.resumeSessionId) {
+                return null;
+            }
+        } else if (state.cwd !== this.targetCwd) {
+            return null;
+        }
+
+        return { sessionId: state.sessionId, transcriptPath };
+    }
+
+    private async listCandidateFiles(): Promise {
+        if (this.resumeSessionId) {
+            if (this.resumeTranscriptPaths) {
+                return this.resumeTranscriptPaths;
+            }
+            const suffix = `-${this.resumeSessionId}.jsonl`;
+            const matches = await listJsonlFiles(this.sessionsRoot, (name) => name.endsWith(suffix));
+            if (matches.length > 0) {
+                this.resumeTranscriptPaths = matches;
+            }
+            return matches;
+        }
+
+        const files = await this.listNearbyTranscriptFiles();
+        return files.filter((transcriptPath) => !this.initialFreshPaths.has(transcriptPath));
+    }
+
+    private async listNearbyTranscriptFiles(): Promise {
+        const roots = getNearbyDateRoots(this.sessionsRoot, this.startupTimestampMs);
+        const groups = await Promise.all(roots.map((root) => listJsonlFiles(root)));
+        return groups.flat();
+    }
+
+    private stopPolling(): void {
+        this.stopped = true;
+        if (this.interval) {
+            clearInterval(this.interval);
+            this.interval = null;
+        }
+    }
+}
+
+async function readBytes(filePath: string, offset: number, length: number): Promise {
+    const handle = await open(filePath, 'r');
+    try {
+        const buffer = Buffer.alloc(length);
+        let totalBytesRead = 0;
+        while (totalBytesRead < length) {
+            const { bytesRead } = await handle.read(
+                buffer,
+                totalBytesRead,
+                length - totalBytesRead,
+                offset + totalBytesRead
+            );
+            if (bytesRead === 0) break;
+            totalBytesRead += bytesRead;
+        }
+        return buffer.subarray(0, totalBytesRead);
+    } finally {
+        await handle.close();
+    }
+}
+
+async function listJsonlFiles(
+    directory: string,
+    matchesName: (name: string) => boolean = () => true
+): Promise {
+    try {
+        const entries = await readdir(directory, { withFileTypes: true });
+        const groups = await Promise.all(entries.map(async (entry) => {
+            const fullPath = join(directory, entry.name);
+            if (entry.isDirectory()) {
+                return await listJsonlFiles(fullPath, matchesName);
+            }
+            return entry.isFile() && entry.name.endsWith('.jsonl') && matchesName(entry.name)
+                ? [fullPath]
+                : [];
+        }));
+        return groups.flat();
+    } catch {
+        return [];
+    }
+}
+
+function getNearbyDateRoots(sessionsRoot: string, timestampMs: number): string[] {
+    const roots: string[] = [];
+    for (const offsetDays of [-1, 0, 1]) {
+        const date = new Date(timestampMs + offsetDays * 24 * 60 * 60 * 1000);
+        roots.push(join(
+            sessionsRoot,
+            String(date.getUTCFullYear()),
+            String(date.getUTCMonth() + 1).padStart(2, '0'),
+            String(date.getUTCDate()).padStart(2, '0')
+        ));
+    }
+    return roots;
+}
+
+function asRecord(value: unknown): Record | null {
+    return value && typeof value === 'object' && !Array.isArray(value)
+        ? value as Record
+        : null;
+}
+
+function asString(value: unknown): string | null {
+    return typeof value === 'string' && value.length > 0 ? value : null;
+}
+
+function parseTimestamp(value: unknown): number | null {
+    if (typeof value !== 'string') return null;
+    const parsed = Date.parse(value);
+    return Number.isNaN(parsed) ? null : parsed;
+}
+
+function normalizePath(value: string): string {
+    const normalized = resolve(value);
+    return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
+}
diff --git a/cli/src/codex/utils/codexVersion.test.ts b/cli/src/codex/utils/codexVersion.test.ts
index e2b5757103..47efcbbd2f 100644
--- a/cli/src/codex/utils/codexVersion.test.ts
+++ b/cli/src/codex/utils/codexVersion.test.ts
@@ -18,6 +18,7 @@ vi.mock('cross-spawn', () => ({
 
 import {
     assertCodexLocalSupported,
+    CODEX_VERSION_TIMEOUT_MS,
     isCodexVersionAtLeast,
     MIN_CODEX_HOOKS_VERSION,
     parseCodexVersion
@@ -74,7 +75,8 @@ describe('codexVersion', () => {
                 'node',
                 [codexScriptPath, '--version'],
                 expect.objectContaining({
-                    encoding: 'utf8'
+                    encoding: 'utf8',
+                    timeout: CODEX_VERSION_TIMEOUT_MS
                 })
             )
         })
diff --git a/cli/src/codex/utils/codexVersion.ts b/cli/src/codex/utils/codexVersion.ts
index aa976b78c8..deeb5b0b66 100644
--- a/cli/src/codex/utils/codexVersion.ts
+++ b/cli/src/codex/utils/codexVersion.ts
@@ -3,6 +3,7 @@ import { withBunRuntimeEnv } from '@/utils/bunRuntime'
 import { resolveCodexCommand } from './codexExecutable'
 
 export const MIN_CODEX_HOOKS_VERSION = '0.124.0'
+export const CODEX_VERSION_TIMEOUT_MS = 3_000
 
 const SEMVER_PATTERN = /\b(\d+)\.(\d+)\.(\d+)\b/
 
@@ -59,6 +60,7 @@ export function assertCodexLocalSupported(): void {
     const result = spawn.sync(codexCommand.command, [...codexCommand.args, '--version'], {
         encoding: 'utf8',
         env: withBunRuntimeEnv(),
+        timeout: CODEX_VERSION_TIMEOUT_MS,
         windowsHide: process.platform === 'win32'
     })
 
diff --git a/cli/src/commands/codex.test.ts b/cli/src/commands/codex.test.ts
index 7e04fc33b6..1e5fba1ccf 100644
--- a/cli/src/commands/codex.test.ts
+++ b/cli/src/commands/codex.test.ts
@@ -62,6 +62,20 @@ describe('codexCommand', () => {
         expect(runCodexMock).toHaveBeenCalledWith({})
     })
 
+    it('does not block local Codex startup on Hub auto-start readiness', async () => {
+        maybeAutoStartServerMock.mockImplementationOnce(async () => {
+            await new Promise(() => {})
+        })
+
+        await codexCommand.run(createCommandContext([]))
+
+        expect(runCodexMock).toHaveBeenCalledOnce()
+        expect(maybeAutoStartServerMock).toHaveBeenCalledWith({
+            waitForReady: false,
+            quiet: true
+        })
+    })
+
     it('checks Codex version before resuming a local session', async () => {
         await codexCommand.run(createCommandContext(['resume', 'session-123']))
 
diff --git a/cli/src/commands/codex.ts b/cli/src/commands/codex.ts
index 196adc9457..579bf5cb3d 100644
--- a/cli/src/commands/codex.ts
+++ b/cli/src/commands/codex.ts
@@ -106,7 +106,11 @@ export const codexCommand: CommandDefinition = {
             }
 
             await initializeToken()
-            await maybeAutoStartServer()
+            if (options.startedBy === 'runner') {
+                await maybeAutoStartServer()
+            } else {
+                void maybeAutoStartServer({ waitForReady: false, quiet: true })
+            }
             await authAndSetupMachineIfNeeded()
             await runCodex(options)
         } catch (error) {
diff --git a/cli/src/utils/autoStartServer.ts b/cli/src/utils/autoStartServer.ts
index c565d9621f..3cf20814ab 100644
--- a/cli/src/utils/autoStartServer.ts
+++ b/cli/src/utils/autoStartServer.ts
@@ -142,7 +142,10 @@ function startServerAsChild(): void {
 /**
  * Main entry point: auto-start hub if conditions are met
  */
-export async function maybeAutoStartServer(): Promise {
+export async function maybeAutoStartServer(options?: {
+    waitForReady?: boolean
+    quiet?: boolean
+}): Promise {
     try {
         const shouldStart = await shouldAutoStartServer()
         if (!shouldStart) {
@@ -150,24 +153,36 @@ export async function maybeAutoStartServer(): Promise {
         }
 
         logger.debug('[AUTO-START] Starting hub automatically...')
-        console.log(chalk.gray('Starting HAPI hub in background...'))
+        if (!options?.quiet) {
+            console.log(chalk.gray('Starting HAPI hub in background...'))
+        }
 
         startServerAsChild()
 
+        if (options?.waitForReady === false) {
+            return
+        }
+
         const isReady = await waitForServerReady(configuration.apiUrl)
 
         if (!isReady) {
-            console.log(chalk.yellow('Warning: Hub did not start within expected time'))
-            console.log(chalk.gray('  Try running `hapi hub` manually to see errors'))
+            if (!options?.quiet) {
+                console.log(chalk.yellow('Warning: Hub did not start within expected time'))
+                console.log(chalk.gray('  Try running `hapi hub` manually to see errors'))
+            }
             return
         }
 
-        console.log(chalk.green('HAPI hub started'))
+        if (!options?.quiet) {
+            console.log(chalk.green('HAPI hub started'))
+        }
     } catch (error) {
         logger.debug('[AUTO-START] Error during hub auto-start', error)
-        console.log(chalk.yellow('Warning: Failed to auto-start hub'))
-        if (error instanceof Error) {
-            console.log(chalk.gray(`  Error: ${error.message}`))
+        if (!options?.quiet) {
+            console.log(chalk.yellow('Warning: Failed to auto-start hub'))
+            if (error instanceof Error) {
+                console.log(chalk.gray(`  Error: ${error.message}`))
+            }
         }
     }
 }
diff --git a/hub/src/store/sessionStore.ts b/hub/src/store/sessionStore.ts
index 0e18a859dd..6b12365897 100644
--- a/hub/src/store/sessionStore.ts
+++ b/hub/src/store/sessionStore.ts
@@ -33,9 +33,10 @@ export class SessionStore {
         namespace: string,
         model?: string,
         effort?: string,
-        modelReasoningEffort?: string
+        modelReasoningEffort?: string,
+        requestedId?: string
     ): StoredSession {
-        return getOrCreateSession(this.db, tag, metadata, agentState, namespace, model, effort, modelReasoningEffort)
+        return getOrCreateSession(this.db, tag, metadata, agentState, namespace, model, effort, modelReasoningEffort, requestedId)
     }
 
     updateSessionMetadata(
diff --git a/hub/src/store/sessions.test.ts b/hub/src/store/sessions.test.ts
index f00a037793..90a89f8746 100644
--- a/hub/src/store/sessions.test.ts
+++ b/hub/src/store/sessions.test.ts
@@ -1,5 +1,7 @@
 import { describe, expect, it } from 'bun:test'
 import { Store } from './index'
+import { randomUUID } from 'node:crypto'
+import { SessionIdentityConflictError } from './sessions'
 
 function makeStore(): Store {
     return new Store(':memory:')
@@ -10,6 +12,65 @@ function getMetadata(store: Store, id: string): Record | null {
     return (row?.metadata ?? null) as Record | null
 }
 
+describe('getOrCreateSession: requested identity', () => {
+    it('creates and idempotently reloads a client-requested id', () => {
+        const store = makeStore()
+        const requestedId = randomUUID()
+
+        const created = store.sessions.getOrCreateSession(
+            'lazy-session-tag',
+            { path: '/tmp/project' },
+            { controlledByUser: true },
+            'default',
+            undefined,
+            undefined,
+            undefined,
+            requestedId
+        )
+        const reloaded = store.sessions.getOrCreateSession(
+            'lazy-session-tag',
+            { path: '/tmp/ignored' },
+            null,
+            'default',
+            undefined,
+            undefined,
+            undefined,
+            requestedId
+        )
+
+        expect(created.id).toBe(requestedId)
+        expect(reloaded.id).toBe(requestedId)
+        expect(store.sessions.getSessionsByNamespace('default')).toHaveLength(1)
+        store.close()
+    })
+
+    it('rejects a tag already bound to another requested id', () => {
+        const store = makeStore()
+        const firstId = randomUUID()
+        store.sessions.getOrCreateSession(
+            'conflicting-tag', {}, null, 'default', undefined, undefined, undefined, firstId
+        )
+
+        expect(() => store.sessions.getOrCreateSession(
+            'conflicting-tag', {}, null, 'default', undefined, undefined, undefined, randomUUID()
+        )).toThrow(SessionIdentityConflictError)
+        store.close()
+    })
+
+    it('rejects a requested id already bound to another tag', () => {
+        const store = makeStore()
+        const requestedId = randomUUID()
+        store.sessions.getOrCreateSession(
+            'first-tag', {}, null, 'default', undefined, undefined, undefined, requestedId
+        )
+
+        expect(() => store.sessions.getOrCreateSession(
+            'second-tag', {}, null, 'default', undefined, undefined, undefined, requestedId
+        )).toThrow(SessionIdentityConflictError)
+        store.close()
+    })
+})
+
 describe('updateSessionMetadata: protocol resume token preservation', () => {
     it('preserves cursorSessionId when archive payload omits it (Cursor crash-archive)', () => {
         const store = makeStore()
diff --git a/hub/src/store/sessions.ts b/hub/src/store/sessions.ts
index 537216c0ca..a5f80dcdd5 100644
--- a/hub/src/store/sessions.ts
+++ b/hub/src/store/sessions.ts
@@ -187,18 +187,32 @@ export function getOrCreateSession(
     namespace: string,
     model?: string,
     effort?: string,
-    modelReasoningEffort?: string
+    modelReasoningEffort?: string,
+    requestedId?: string
 ): StoredSession {
     const existing = db.prepare(
         'SELECT * FROM sessions WHERE tag = ? AND namespace = ? ORDER BY created_at DESC LIMIT 1'
     ).get(tag, namespace) as DbSessionRow | undefined
 
     if (existing) {
+        if (requestedId && existing.id !== requestedId) {
+            throw new SessionIdentityConflictError('Session tag is already bound to a different id')
+        }
         return toStoredSession(existing)
     }
 
     const now = Date.now()
-    const id = randomUUID()
+    const id = requestedId ?? randomUUID()
+
+    if (requestedId) {
+        const existingById = getSession(db, requestedId)
+        if (existingById) {
+            if (existingById.namespace === namespace && existingById.tag === tag) {
+                return existingById
+            }
+            throw new SessionIdentityConflictError('Session id is already bound to a different session')
+        }
+    }
 
     const metadataJson = JSON.stringify(metadata)
     const agentStateJson = agentState === null || agentState === undefined ? null : JSON.stringify(agentState)
@@ -243,6 +257,13 @@ export function getOrCreateSession(
     return row
 }
 
+export class SessionIdentityConflictError extends Error {
+    constructor(message: string) {
+        super(message)
+        this.name = 'SessionIdentityConflictError'
+    }
+}
+
 export function updateSessionMetadata(
     db: Database,
     id: string,
diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts
index 304b01ccae..bdfafecd70 100644
--- a/hub/src/sync/sessionCache.ts
+++ b/hub/src/sync/sessionCache.ts
@@ -75,9 +75,19 @@ export class SessionCache {
         namespace: string,
         model?: string,
         effort?: string,
-        modelReasoningEffort?: string
+        modelReasoningEffort?: string,
+        requestedId?: string
     ): Session {
-        const stored = this.store.sessions.getOrCreateSession(tag, metadata, agentState, namespace, model, effort, modelReasoningEffort)
+        const stored = this.store.sessions.getOrCreateSession(
+            tag,
+            metadata,
+            agentState,
+            namespace,
+            model,
+            effort,
+            modelReasoningEffort,
+            requestedId
+        )
         return this.refreshSession(stored.id) ?? (() => { throw new Error('Failed to load session') })()
     }
 
diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts
index 7986a81db4..414da24720 100644
--- a/hub/src/sync/syncEngine.ts
+++ b/hub/src/sync/syncEngine.ts
@@ -387,9 +387,19 @@ export class SyncEngine {
         namespace: string,
         model?: string,
         effort?: string,
-        modelReasoningEffort?: string
+        modelReasoningEffort?: string,
+        requestedId?: string
     ): Session {
-        return this.sessionCache.getOrCreateSession(tag, metadata, agentState, namespace, model, effort, modelReasoningEffort)
+        return this.sessionCache.getOrCreateSession(
+            tag,
+            metadata,
+            agentState,
+            namespace,
+            model,
+            effort,
+            modelReasoningEffort,
+            requestedId
+        )
     }
 
     getOrCreateMachine(id: string, metadata: unknown, runnerState: unknown, namespace: string): Machine {
diff --git a/hub/src/web/routes/cli.test.ts b/hub/src/web/routes/cli.test.ts
index 448a924968..637f95bb93 100644
--- a/hub/src/web/routes/cli.test.ts
+++ b/hub/src/web/routes/cli.test.ts
@@ -1,8 +1,9 @@
-import { beforeAll, describe, expect, it } from 'bun:test'
+import { beforeAll, describe, expect, it, mock } from 'bun:test'
 import { Hono } from 'hono'
 import type { SyncEngine } from '../../sync/syncEngine'
 import { createConfiguration } from '../../configuration'
 import { createCliRoutes } from './cli'
+import { SessionIdentityConflictError } from '../../store/sessions'
 
 function createApp(engine: Partial) {
     const app = new Hono()
@@ -114,3 +115,104 @@ describe('cli resume routes', () => {
         })
     })
 })
+
+describe('cli lazy session creation', () => {
+    const sessionId = '11111111-1111-4111-8111-111111111111'
+
+    it('creates the machine and requested session identity in one request', async () => {
+        const getOrCreateMachine = mock(() => ({ id: 'machine-1' }))
+        const getOrCreateSession = mock(() => ({ id: sessionId }))
+        const app = createApp({
+            getMachine: () => null,
+            getOrCreateMachine,
+            getOrCreateSession
+        } as never)
+
+        const response = await app.request('/cli/sessions', {
+            method: 'POST',
+            headers: {
+                ...authHeaders(),
+                'content-type': 'application/json'
+            },
+            body: JSON.stringify({
+                id: sessionId,
+                tag: 'lazy-tag',
+                metadata: { path: '/tmp/project' },
+                agentState: { controlledByUser: true },
+                machine: {
+                    id: 'machine-1',
+                    metadata: { host: 'localhost' }
+                }
+            })
+        })
+
+        expect(response.status).toBe(200)
+        expect(getOrCreateMachine).toHaveBeenCalledWith(
+            'machine-1',
+            { host: 'localhost' },
+            null,
+            'default'
+        )
+        expect(getOrCreateSession).toHaveBeenCalledWith(
+            'lazy-tag',
+            { path: '/tmp/project' },
+            { controlledByUser: true },
+            'default',
+            undefined,
+            undefined,
+            undefined,
+            sessionId
+        )
+    })
+
+    it('rejects an embedded machine owned by another namespace', async () => {
+        const getOrCreateMachine = mock(() => ({ id: 'machine-1' }))
+        const getOrCreateSession = mock(() => ({ id: sessionId }))
+        const app = createApp({
+            getMachine: () => ({ id: 'machine-1', namespace: 'other' }),
+            getOrCreateMachine,
+            getOrCreateSession
+        } as never)
+
+        const response = await app.request('/cli/sessions', {
+            method: 'POST',
+            headers: {
+                ...authHeaders(),
+                'content-type': 'application/json'
+            },
+            body: JSON.stringify({
+                id: sessionId,
+                tag: 'lazy-tag',
+                metadata: {},
+                machine: { id: 'machine-1', metadata: {} }
+            })
+        })
+
+        expect(response.status).toBe(403)
+        expect(getOrCreateMachine).not.toHaveBeenCalled()
+        expect(getOrCreateSession).not.toHaveBeenCalled()
+    })
+
+    it('returns 409 for a requested identity conflict', async () => {
+        const app = createApp({
+            getOrCreateSession: () => {
+                throw new SessionIdentityConflictError('Session tag is already bound to a different id')
+            }
+        })
+
+        const response = await app.request('/cli/sessions', {
+            method: 'POST',
+            headers: {
+                ...authHeaders(),
+                'content-type': 'application/json'
+            },
+            body: JSON.stringify({
+                id: sessionId,
+                tag: 'lazy-tag',
+                metadata: {}
+            })
+        })
+
+        expect(response.status).toBe(409)
+    })
+})
diff --git a/hub/src/web/routes/cli.ts b/hub/src/web/routes/cli.ts
index 30a24124bc..fc0abe4372 100644
--- a/hub/src/web/routes/cli.ts
+++ b/hub/src/web/routes/cli.ts
@@ -10,6 +10,7 @@ import { getConfiguration } from '../../configuration'
 import { constantTimeEquals } from '../../utils/crypto'
 import { parseAccessToken } from '../../utils/accessToken'
 import type { Machine, Session, SyncEngine } from '../../sync/syncEngine'
+import { SessionIdentityConflictError } from '../../store/sessions'
 
 const bearerSchema = z.string().regex(/^Bearer\s+(.+)$/i)
 
@@ -94,16 +95,38 @@ export function createCliRoutes(getSyncEngine: () => SyncEngine | null): Hono {
diff --git a/shared/src/apiTypes.ts b/shared/src/apiTypes.ts
index e6381da723..0167c12b94 100644
--- a/shared/src/apiTypes.ts
+++ b/shared/src/apiTypes.ts
@@ -15,25 +15,27 @@ import type {
 } from './schemas'
 import type { SessionSummary } from './sessionSummary'
 
+export const CreateOrLoadMachineRequestSchema = z.object({
+    id: z.string().min(1),
+    metadata: z.unknown(),
+    runnerState: z.unknown().nullable().optional()
+})
+
+export type CreateOrLoadMachineRequest = z.infer
+
 export const CreateOrLoadSessionRequestSchema = z.object({
+    id: z.string().uuid().optional(),
     tag: z.string().min(1),
     metadata: z.unknown(),
     agentState: z.unknown().nullable().optional(),
     model: z.string().optional(),
     modelReasoningEffort: z.string().optional(),
-    effort: z.string().optional()
+    effort: z.string().optional(),
+    machine: CreateOrLoadMachineRequestSchema.optional()
 })
 
 export type CreateOrLoadSessionRequest = z.infer
 
-export const CreateOrLoadMachineRequestSchema = z.object({
-    id: z.string().min(1),
-    metadata: z.unknown(),
-    runnerState: z.unknown().nullable().optional()
-})
-
-export type CreateOrLoadMachineRequest = z.infer
-
 export const CliMessagesResponseSchema = z.object({
     messages: z.array(z.object({
         id: z.string(),

From 942a1dfff8139f7dc68ca8025a5459bb7a13cf5f Mon Sep 17 00:00:00 2001
From: weishu 
Date: Sun, 12 Jul 2026 11:05:13 +0800
Subject: [PATCH 10/28] Release version 0.21.0

---
 bun.lock                | 22 ++++++++++------------
 cli/package.json        | 14 +++++++-------
 shared/src/buildInfo.ts |  2 +-
 3 files changed, 18 insertions(+), 20 deletions(-)

diff --git a/bun.lock b/bun.lock
index 931356b579..7d0875c9c6 100644
--- a/bun.lock
+++ b/bun.lock
@@ -14,7 +14,7 @@
     },
     "cli": {
       "name": "@twsxtd/hapi",
-      "version": "0.20.2",
+      "version": "0.21.0",
       "bin": {
         "hapi": "bin/hapi.cjs",
       },
@@ -46,11 +46,11 @@
         "vitest": "^4.0.16",
       },
       "optionalDependencies": {
-        "@twsxtd/hapi-darwin-arm64": "0.20.2",
-        "@twsxtd/hapi-darwin-x64": "0.20.2",
-        "@twsxtd/hapi-linux-arm64": "0.20.2",
-        "@twsxtd/hapi-linux-x64": "0.20.2",
-        "@twsxtd/hapi-win32-x64": "0.20.2",
+        "@twsxtd/hapi-darwin-arm64": "0.21.0",
+        "@twsxtd/hapi-darwin-x64": "0.21.0",
+        "@twsxtd/hapi-linux-arm64": "0.21.0",
+        "@twsxtd/hapi-linux-x64": "0.21.0",
+        "@twsxtd/hapi-win32-x64": "0.21.0",
       },
     },
     "docs": {
@@ -1067,15 +1067,13 @@
 
     "@twsxtd/hapi": ["@twsxtd/hapi@workspace:cli"],
 
-    "@twsxtd/hapi-darwin-arm64": ["@twsxtd/hapi-darwin-arm64@0.20.2", "", { "os": "darwin", "cpu": "arm64", "bin": { "hapi": "bin/hapi" } }, "sha512-gV9qqvfNjmWcIRWscO20MtbfpHERgPm7gTiK3kLaylCObH+38UBi+4IqI64k9JuVDZ+XCf0b7h77iyLEUg91hg=="],
+    "@twsxtd/hapi-darwin-arm64": ["@twsxtd/hapi-darwin-arm64@0.21.0", "", { "os": "darwin", "cpu": "arm64", "bin": { "hapi": "bin/hapi" } }, "sha512-wlhiU1EpB8A333yQ2dp8uozcNKsZ6UvYG+TDgwglCvCQyyO2x8B68zVTA1TBzp8h4TiYJNqM/zJMKCbpkuIgow=="],
 
-    "@twsxtd/hapi-darwin-x64": ["@twsxtd/hapi-darwin-x64@0.20.2", "", { "os": "darwin", "cpu": "x64", "bin": { "hapi": "bin/hapi" } }, "sha512-I3apIM91dJ8vpS4HgUkw2xo2o8ryUCDqvM6hNCgE9Ha/KEAXQ8bDxc1LcE5ga+LupluBiBeYpQ+XAHUKRVOARA=="],
+    "@twsxtd/hapi-darwin-x64": ["@twsxtd/hapi-darwin-x64@0.21.0", "", { "os": "darwin", "cpu": "x64", "bin": { "hapi": "bin/hapi" } }, "sha512-9kGe8hufOPaTOp7PX4uVdFtyly/lN5WZd19ouFoMjcJtmHTWR8tSisNuObC8fMi/z42g8PyMAOWJ5rsLs9RaJQ=="],
 
-    "@twsxtd/hapi-linux-arm64": ["@twsxtd/hapi-linux-arm64@0.20.2", "", { "os": "linux", "cpu": "arm64", "bin": { "hapi": "bin/hapi" } }, "sha512-efY3KuvPpHfUaVDaHYSL1NC9OFfm0GnC6r4V8aOHkFUc62MPytMcx6zHBKhMABb58dUTT7Xc264mdLqy+2OMMQ=="],
+    "@twsxtd/hapi-linux-arm64": ["@twsxtd/hapi-linux-arm64@0.21.0", "", { "os": "linux", "cpu": "arm64", "bin": { "hapi": "bin/hapi" } }, "sha512-KCefkZQgjeAq43Nx/69G71p9ZoEkcfXiEh6L7DiUsBRTEIIeHtDb4xVAvID66qgfXJXbKIZRqDZxi6gJl3djXQ=="],
 
-    "@twsxtd/hapi-linux-x64": ["@twsxtd/hapi-linux-x64@0.20.2", "", { "os": "linux", "cpu": "x64", "bin": { "hapi": "bin/hapi" } }, "sha512-AWFK3ERb6oY0tOzGaNrKEOqSFWBb/HjJ90Q8TOOLZIlckSVFSa5l5ortDOpiTlLf5fTIgfx3hRlR56eOrVfP4Q=="],
-
-    "@twsxtd/hapi-win32-x64": ["@twsxtd/hapi-win32-x64@0.20.2", "", { "os": "win32", "cpu": "x64", "bin": { "hapi": "bin/hapi.exe" } }, "sha512-o4O/q+vvVrOt4kLy2uBcR/ubQChQeDvq1TtybGkyPq9u1Y4LZkBbM36++TBzAXXaCNn86hQDOUjZs9seXoi18A=="],
+    "@twsxtd/hapi-linux-x64": ["@twsxtd/hapi-linux-x64@0.21.0", "", { "os": "linux", "cpu": "x64", "bin": { "hapi": "bin/hapi" } }, "sha512-NUACwvFDAadERrUwhHH91IQUPLCOMYbZyzfAsdxltgzz+2Zb8m8O3kRyHjsFHXIneIvyeMr08fFubczxL4zuTw=="],
 
     "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
 
diff --git a/cli/package.json b/cli/package.json
index 0cfc660224..e2767b2ca4 100644
--- a/cli/package.json
+++ b/cli/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@twsxtd/hapi",
-  "version": "0.20.2",
+  "version": "0.21.0",
   "description": "App for agentic coding - access coding agent anywhere",
   "author": "Kirill Dubovitskiy & weishu",
   "license": "AGPL-3.0-only",
@@ -26,11 +26,11 @@
     }
   },
   "optionalDependencies": {
-    "@twsxtd/hapi-darwin-arm64": "0.20.2",
-    "@twsxtd/hapi-darwin-x64": "0.20.2",
-    "@twsxtd/hapi-linux-arm64": "0.20.2",
-    "@twsxtd/hapi-linux-x64": "0.20.2",
-    "@twsxtd/hapi-win32-x64": "0.20.2"
+    "@twsxtd/hapi-darwin-arm64": "0.21.0",
+    "@twsxtd/hapi-darwin-x64": "0.21.0",
+    "@twsxtd/hapi-linux-arm64": "0.21.0",
+    "@twsxtd/hapi-linux-x64": "0.21.0",
+    "@twsxtd/hapi-win32-x64": "0.21.0"
   },
   "scripts": {
     "postinstall": "node -e \"try{require('fs').chmodSync(require('path').join(__dirname,'bin','hapi.cjs'),0o755)}catch(e){}\"",
@@ -83,4 +83,4 @@
     "@types/parse-path": "7.0.3"
   },
   "packageManager": "bun@1.3.14"
-}
\ No newline at end of file
+}
diff --git a/shared/src/buildInfo.ts b/shared/src/buildInfo.ts
index 0f94540b54..bd6ad5e50e 100644
--- a/shared/src/buildInfo.ts
+++ b/shared/src/buildInfo.ts
@@ -1 +1 @@
-export const APP_VERSION = '0.20.2'
+export const APP_VERSION = '0.21.0'

From 8782b8a110f1425a7bdc083d587ffce590f34ad7 Mon Sep 17 00:00:00 2001
From: weishu 
Date: Sun, 12 Jul 2026 14:33:02 +0800
Subject: [PATCH 11/28] fix(codex): align local transcript message projection

Use semantic events as the visible text source and preserve completed plans as ordered plan proposal cards.
---
 cli/src/codex/codexLocalLauncher.test.ts      | 122 ++++++++++++++++--
 cli/src/codex/codexLocalLauncher.ts           |  54 +++++++-
 .../codex/utils/codexEventConverter.test.ts   |  86 +++++++++---
 cli/src/codex/utils/codexEventConverter.ts    |  74 +++++------
 .../utils/codexTranscriptLocator.test.ts      |   8 +-
 web/src/chat/reducer.test.ts                  |  45 +++++++
 6 files changed, 310 insertions(+), 79 deletions(-)

diff --git a/cli/src/codex/codexLocalLauncher.test.ts b/cli/src/codex/codexLocalLauncher.test.ts
index 2594ca5a98..345b69680c 100644
--- a/cli/src/codex/codexLocalLauncher.test.ts
+++ b/cli/src/codex/codexLocalLauncher.test.ts
@@ -459,7 +459,7 @@ describe('codexLocalLauncher', () => {
         });
     });
 
-    it('replays existing response_item chat messages when importing a Codex thread into a new Hapi session', async () => {
+    it('replays semantic chat events once and keeps a same-turn preface before its plan', async () => {
         const transcriptPath = join(tempDir, 'codex-import-response-item-transcript.jsonl');
         const { session, userMessages, agentMessages, getUserActivityCount } = createSessionStub('default', undefined, '/tmp/worktree', null, true);
         let releaseRunBarrier: (() => void) | undefined;
@@ -476,7 +476,23 @@ describe('codexLocalLauncher', () => {
                     payload: {
                         type: 'message',
                         role: 'user',
-                        content: [{ type: 'input_text', text: 'old response_item user message' }]
+                        content: [{ type: 'input_text', text: 'visible user message' }]
+                    }
+                }),
+                JSON.stringify({
+                    type: 'event_msg',
+                    payload: { type: 'user_message', message: 'visible user message' }
+                }),
+                JSON.stringify({
+                    type: 'event_msg',
+                    payload: {
+                        type: 'item_completed',
+                        turn_id: 'turn-with-preface',
+                        item: {
+                            type: 'Plan',
+                            id: 'plan-1',
+                            text: '## Proposed plan\n\n1. Inspect\n2. Implement'
+                        }
                     }
                 }),
                 JSON.stringify({
@@ -484,15 +500,31 @@ describe('codexLocalLauncher', () => {
                     payload: {
                         type: 'message',
                         role: 'assistant',
-                        content: [{ type: 'output_text', text: 'old response_item assistant message' }]
+                        content: [{
+                            type: 'output_text',
+                            text: 'visible assistant preface\n\n## Proposed plan\n\n1. Inspect\n2. Implement'
+                        }],
+                        internal_chat_message_metadata_passthrough: { turn_id: 'turn-with-preface' }
                     }
                 }),
+                JSON.stringify({
+                    type: 'event_msg',
+                    payload: {
+                        type: 'agent_message',
+                        message: 'visible assistant preface',
+                        phase: 'final_answer'
+                    }
+                }),
+                JSON.stringify({
+                    type: 'event_msg',
+                    payload: { type: 'task_complete', turn_id: 'turn-with-preface' }
+                }),
                 JSON.stringify({
                     type: 'response_item',
                     payload: {
                         type: 'message',
                         role: 'user',
-                        content: [{ type: 'input_image', image_url: 'data:image/png;base64,abc' }]
+                        content: [{ type: 'input_text', text: 'hidden context' }]
                     }
                 })
             ].join('\n') + '\n'
@@ -511,13 +543,87 @@ describe('codexLocalLauncher', () => {
         }
         await launcherPromise;
 
-        expect(userMessages).toContain('old response_item user message');
-        expect(getUserActivityCount()).toBe(1);
-        expect(agentMessages).toContainEqual({
+        expect(userMessages).toEqual(['visible user message']);
+        expect(getUserActivityCount()).toBe(0);
+        expect(agentMessages).toEqual([{
             type: 'message',
-            message: 'old response_item assistant message',
+            message: 'visible assistant preface',
             id: expect.any(String)
+        }, {
+            type: 'tool-call',
+            name: 'ExitPlanMode',
+            callId: 'codex-proposed-plan:plan-1',
+            input: { plan: '## Proposed plan\n\n1. Inspect\n2. Implement' },
+            id: 'plan-1'
+        }, {
+            type: 'tool-call-result',
+            callId: 'codex-proposed-plan:plan-1',
+            output: null,
+            id: 'plan-1:result'
+        }]);
+    });
+
+    it('replays a plan-only turn when the turn completes', async () => {
+        const transcriptPath = join(tempDir, 'codex-import-plan-only-transcript.jsonl');
+        const { session, agentMessages } = createSessionStub('default', undefined, '/tmp/worktree', null, true);
+        let releaseRunBarrier: (() => void) | undefined;
+        harness.runBarrier = new Promise((resolve) => {
+            releaseRunBarrier = resolve;
         });
+
+        await writeFile(
+            transcriptPath,
+            [
+                JSON.stringify({ type: 'session_meta', payload: { id: 'codex-thread-plan-only' } }),
+                JSON.stringify({
+                    type: 'event_msg',
+                    payload: {
+                        type: 'item_completed',
+                        turn_id: 'turn-plan-only',
+                        item: { type: 'Plan', id: 'plan-only', text: '## Plan only' }
+                    }
+                }),
+                JSON.stringify({
+                    type: 'response_item',
+                    payload: {
+                        type: 'message',
+                        role: 'assistant',
+                        content: [{ type: 'output_text', text: '## Plan only' }],
+                        internal_chat_message_metadata_passthrough: { turn_id: 'turn-plan-only' }
+                    }
+                }),
+                JSON.stringify({
+                    type: 'event_msg',
+                    payload: { type: 'task_complete', turn_id: 'turn-plan-only' }
+                })
+            ].join('\n') + '\n'
+        );
+
+        const launcherPromise = codexLocalLauncher(session as never);
+        await wait(50);
+
+        harness.sessionHookHandlers[0]?.('codex-thread-plan-only', {
+            transcript_path: transcriptPath
+        });
+        await wait(300);
+
+        if (releaseRunBarrier) {
+            releaseRunBarrier();
+        }
+        await launcherPromise;
+
+        expect(agentMessages).toEqual([{
+            type: 'tool-call',
+            name: 'ExitPlanMode',
+            callId: 'codex-proposed-plan:plan-only',
+            input: { plan: '## Plan only' },
+            id: 'plan-only'
+        }, {
+            type: 'tool-call-result',
+            callId: 'codex-proposed-plan:plan-only',
+            output: null,
+            id: 'plan-only:result'
+        }]);
     });
 
     it('does not let a later non-clear hook replace the primary session', async () => {
diff --git a/cli/src/codex/codexLocalLauncher.ts b/cli/src/codex/codexLocalLauncher.ts
index 7df3e95511..4589a259c4 100644
--- a/cli/src/codex/codexLocalLauncher.ts
+++ b/cli/src/codex/codexLocalLauncher.ts
@@ -5,13 +5,15 @@ import { codexLocal } from './codexLocal';
 import type { ReasoningEffort } from './appServerTypes';
 import { CodexSession } from './session';
 import { createCodexSessionScanner, type CodexSessionScanner } from './utils/codexSessionScanner';
-import { convertCodexEvent } from './utils/codexEventConverter';
+import { convertCodexEvent, type CodexMessage } from './utils/codexEventConverter';
 import { buildHapiMcpBridge } from './utils/buildHapiMcpBridge';
 import { parseCodexCliOverrides, stripCodexCliOverrides } from './utils/codexCliOverrides';
 import { buildCodexPermissionModeCliArgs } from './utils/permissionModeConfig';
 import { BaseLocalLauncher } from '@/modules/common/launcher/BaseLocalLauncher';
 import { createCodexTranscriptLocator, type CodexTranscriptLocator } from './utils/codexTranscriptLocator';
 
+type ProposedPlanMessage = Extract;
+
 export async function codexLocalLauncher(session: CodexSession): Promise<'switch' | 'exit'> {
     const resumeSessionId = session.sessionId;
     let primarySessionId = resumeSessionId;
@@ -21,6 +23,8 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch
     let shuttingDown = false;
     let pendingScannerSetup: Promise | null = null;
     let transcriptLocator: CodexTranscriptLocator | null = null;
+    let scannerTranscriptPath: string | null = null;
+    const pendingPlansByTurnId = new Map();
     const permissionMode = session.getPermissionMode();
     const managedPermissionMode = permissionMode === 'read-only' || permissionMode === 'safe-yolo' || permissionMode === 'yolo'
         ? permissionMode
@@ -61,6 +65,38 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch
         return primarySessionId === null || primarySessionId === sessionId;
     };
 
+    const sendProposedPlan = (message: ProposedPlanMessage): void => {
+        const callId = `codex-proposed-plan:${message.id}`;
+        session.sendAgentMessage({
+            type: 'tool-call',
+            name: 'ExitPlanMode',
+            callId,
+            input: { plan: message.plan },
+            id: message.id
+        });
+        session.sendAgentMessage({
+            type: 'tool-call-result',
+            callId,
+            output: null,
+            id: `${message.id}:result`
+        });
+    };
+
+    const flushPendingPlan = (turnId: string): void => {
+        const message = pendingPlansByTurnId.get(turnId);
+        if (!message) {
+            return;
+        }
+        pendingPlansByTurnId.delete(turnId);
+        sendProposedPlan(message);
+    };
+
+    const flushAllPendingPlans = (): void => {
+        for (const turnId of pendingPlansByTurnId.keys()) {
+            flushPendingPlan(turnId);
+        }
+    };
+
     const bindPrimarySession = (sessionId: string, transcriptPath: string, allowSwitch = false): void => {
         if (primarySessionId && primarySessionId !== sessionId && !allowSwitch) {
             logger.debug(`[codex-local]: Ignoring non-primary SessionStart hook ${sessionId}; primary is ${primarySessionId}`);
@@ -83,7 +119,11 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch
             return;
         }
         if (scanner) {
+            if (scannerTranscriptPath !== transcriptPath) {
+                flushAllPendingPlans();
+            }
             await scanner.setTranscriptPath(transcriptPath);
+            scannerTranscriptPath = transcriptPath;
             return;
         }
         const createdScanner = await createCodexSessionScanner({
@@ -112,7 +152,15 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch
                     session.notifyUserActivity();
                 }
                 if (converted?.message) {
-                    session.sendAgentMessage(converted.message);
+                    if (converted.message.type === 'proposed_plan') {
+                        // Codex may complete the Plan item before emitting its final text preface.
+                        pendingPlansByTurnId.set(converted.message.turnId, converted.message);
+                    } else {
+                        session.sendAgentMessage(converted.message);
+                    }
+                }
+                if (converted?.finishedTurnId) {
+                    flushPendingPlan(converted.finishedTurnId);
                 }
             }
         });
@@ -121,6 +169,7 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch
             return;
         }
         scanner = createdScanner;
+        scannerTranscriptPath = transcriptPath;
     };
 
     const handleTranscriptPath = (transcriptPath: string): Promise => {
@@ -248,6 +297,7 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch
         if (activeScanner) {
             await activeScanner.cleanup();
         }
+        flushAllPendingPlans();
         happyServer.stop();
         if (!hookReady) {
             logger.debug('[codex-local]: SessionStart hook did not provide transcript path before shutdown');
diff --git a/cli/src/codex/utils/codexEventConverter.test.ts b/cli/src/codex/utils/codexEventConverter.test.ts
index adbc3bd30c..d7cf10081a 100644
--- a/cli/src/codex/utils/codexEventConverter.test.ts
+++ b/cli/src/codex/utils/codexEventConverter.test.ts
@@ -32,49 +32,93 @@ describe('convertCodexEvent', () => {
         expect(result?.userMessage).toBe('hello user');
     });
 
-    it('converts response_item user messages', () => {
+    it('converts completed plan items into proposed plan messages', () => {
         const result = convertCodexEvent({
-            type: 'response_item',
+            type: 'event_msg',
             payload: {
-                type: 'message',
-                role: 'user',
-                content: [{ type: 'input_text', text: 'hello from response_item user' }]
+                type: 'item_completed',
+                turn_id: 'turn-1',
+                item: { type: 'Plan', id: 'plan-1', text: '## Plan\n\n1. Inspect\n2. Implement' }
             }
         });
 
-        expect(result).toEqual({
-            userMessage: 'hello from response_item user',
-            userActivity: true
+        expect(result?.message).toMatchObject({
+            type: 'proposed_plan',
+            plan: '## Plan\n\n1. Inspect\n2. Implement',
+            id: 'plan-1',
+            turnId: 'turn-1'
         });
     });
 
-    it('marks image-only response_item messages as user activity', () => {
+    it('ignores empty completed plan items', () => {
         const result = convertCodexEvent({
-            type: 'response_item',
+            type: 'event_msg',
             payload: {
-                type: 'message',
-                role: 'user',
-                content: [{ type: 'input_image', image_url: 'data:image/png;base64,abc' }]
+                type: 'item_completed',
+                turn_id: 'turn-1',
+                item: { type: 'Plan', id: 'plan-1', text: '   ' }
             }
         });
 
-        expect(result).toEqual({ userActivity: true });
+        expect(result).toBeNull();
     });
 
-    it('converts response_item assistant messages', () => {
+    it('ignores completed plan items without a turn id', () => {
         const result = convertCodexEvent({
+            type: 'event_msg',
+            payload: {
+                type: 'item_completed',
+                item: { type: 'Plan', id: 'plan-1', text: '## Plan' }
+            }
+        });
+
+        expect(result).toBeNull();
+    });
+
+    it.each(['task_complete', 'turn_aborted', 'task_failed'])('converts %s into a turn boundary', (type) => {
+        const result = convertCodexEvent({
+            type: 'event_msg',
+            payload: { type, turn_id: 'turn-1' }
+        });
+
+        expect(result).toEqual({ finishedTurnId: 'turn-1' });
+    });
+
+    it.each([
+        ['user text', {
+            type: 'response_item',
+            payload: {
+                type: 'message',
+                role: 'user',
+                content: [{ type: 'input_text', text: 'hello from response_item user' }]
+            }
+        }],
+        ['user image', {
+            type: 'response_item',
+            payload: {
+                type: 'message',
+                role: 'user',
+                content: [{ type: 'input_image', image_url: 'data:image/png;base64,abc' }]
+            }
+        }],
+        ['assistant text', {
             type: 'response_item',
             payload: {
                 type: 'message',
                 role: 'assistant',
                 content: [{ type: 'output_text', text: 'hello from response_item assistant' }]
             }
-        });
-
-        expect(result?.message).toMatchObject({
-            type: 'message',
-            message: 'hello from response_item assistant'
-        });
+        }],
+        ['injected user context', {
+            type: 'response_item',
+            payload: {
+                type: 'message',
+                role: 'user',
+                content: [{ type: 'input_text', text: '# AGENTS.md\nhidden context' }]
+            }
+        }]
+    ])('ignores %s response_item messages', (_name, event) => {
+        expect(convertCodexEvent(event)).toBeNull();
     });
 
     it('converts reasoning events', () => {
diff --git a/cli/src/codex/utils/codexEventConverter.ts b/cli/src/codex/utils/codexEventConverter.ts
index 3930480582..11024ccc2d 100644
--- a/cli/src/codex/utils/codexEventConverter.ts
+++ b/cli/src/codex/utils/codexEventConverter.ts
@@ -14,6 +14,11 @@ export type CodexMessage = {
     type: 'message';
     message: string;
     id: string;
+} | {
+    type: 'proposed_plan';
+    plan: string;
+    id: string;
+    turnId: string;
 } | {
     type: 'reasoning';
     message: string;
@@ -43,6 +48,7 @@ export type CodexConversionResult = {
     message?: CodexMessage;
     userMessage?: string;
     userActivity?: true;
+    finishedTurnId?: string;
 };
 
 function asRecord(value: unknown): Record | null {
@@ -56,30 +62,6 @@ function asString(value: unknown): string | null {
     return typeof value === 'string' && value.length > 0 ? value : null;
 }
 
-function extractCodexText(value: unknown): string {
-    if (typeof value === 'string') {
-        return value.trim();
-    }
-    if (Array.isArray(value)) {
-        return value
-            .map((item) => {
-                const record = asRecord(item);
-                if (record?.type === 'input_text' && typeof record.text === 'string') return record.text;
-                if (record?.type === 'output_text' && typeof record.text === 'string') return record.text;
-                if (record?.type === 'text' && typeof record.text === 'string') return record.text;
-                return null;
-            })
-            .filter((part): part is string => Boolean(part))
-            .join(' ')
-            .trim();
-    }
-    const record = asRecord(value);
-    if (record?.type === 'input_text' && typeof record.text === 'string') return record.text.trim();
-    if (record?.type === 'output_text' && typeof record.text === 'string') return record.text.trim();
-    if (record?.type === 'text' && typeof record.text === 'string') return record.text.trim();
-    return '';
-}
-
 function parseArguments(value: unknown): unknown {
     if (typeof value !== 'string') {
         return value;
@@ -167,6 +149,29 @@ export function convertCodexEvent(rawEvent: unknown): CodexConversionResult | nu
             };
         }
 
+        if (eventType === 'item_completed') {
+            const item = asRecord(payloadRecord.item);
+            const itemType = asString(item?.type)?.toLowerCase();
+            const message = itemType === 'plan' ? asString(item?.text) : null;
+            const turnId = asString(payloadRecord.turn_id);
+            if (!message || message.trim().length === 0 || !turnId) {
+                return null;
+            }
+            return {
+                message: {
+                    type: 'proposed_plan',
+                    plan: message,
+                    id: asString(item?.id) ?? randomUUID(),
+                    turnId
+                }
+            };
+        }
+
+        if (eventType === 'task_complete' || eventType === 'turn_aborted' || eventType === 'task_failed') {
+            const turnId = asString(payloadRecord.turn_id);
+            return turnId ? { finishedTurnId: turnId } : null;
+        }
+
         if (eventType === 'agent_reasoning') {
             const message = asString(payloadRecord.text) ?? asString(payloadRecord.message);
             if (!message) {
@@ -218,26 +223,7 @@ export function convertCodexEvent(rawEvent: unknown): CodexConversionResult | nu
         }
 
         if (itemType === 'message') {
-            const role = asString(payloadRecord.role);
-            const text = extractCodexText(payloadRecord.content);
-            if (role === 'user') {
-                return {
-                    userActivity: true,
-                    ...(text ? { userMessage: text } : {})
-                };
-            }
-            if (role === 'assistant') {
-                if (!text) {
-                    return null;
-                }
-                return {
-                    message: {
-                        type: 'message',
-                        message: text,
-                        id: randomUUID()
-                    }
-                };
-            }
+            // Response messages are model conversation state; event_msg carries visible chat.
             return null;
         }
 
diff --git a/cli/src/codex/utils/codexTranscriptLocator.test.ts b/cli/src/codex/utils/codexTranscriptLocator.test.ts
index b633f51b2d..0a75bc14f9 100644
--- a/cli/src/codex/utils/codexTranscriptLocator.test.ts
+++ b/cli/src/codex/utils/codexTranscriptLocator.test.ts
@@ -89,11 +89,11 @@ describe('codexTranscriptLocator', () => {
 
         await appendFile(transcriptPath, `${JSON.stringify({
             timestamp: new Date().toISOString(),
-            type: 'response_item',
+            type: 'event_msg',
             payload: {
-                type: 'message',
-                role: 'user',
-                content: [{ type: 'input_image', image_url: 'data:image/png;base64,abc' }]
+                type: 'user_message',
+                message: '',
+                images: ['data:image/png;base64,abc']
             }
         })}\n`);
         await wait(100);
diff --git a/web/src/chat/reducer.test.ts b/web/src/chat/reducer.test.ts
index 6000d70c73..93e1d3db52 100644
--- a/web/src/chat/reducer.test.ts
+++ b/web/src/chat/reducer.test.ts
@@ -80,6 +80,51 @@ function decryptedMessage(id: string, content: unknown, createdAt: number): Decr
 }
 
 describe('reduceChatBlocks', () => {
+    it('renders Codex proposed plan tool messages as a completed plan card', () => {
+        const plan = '# Plan\n\n1. Inspect\n2. Implement'
+        const messages = [
+            decryptedMessage('plan-call', {
+                role: 'agent',
+                content: {
+                    type: 'codex',
+                    data: {
+                        type: 'tool-call',
+                        name: 'ExitPlanMode',
+                        callId: 'codex-proposed-plan:plan-1',
+                        input: { plan },
+                        id: 'plan-1'
+                    }
+                }
+            }, 1),
+            decryptedMessage('plan-result', {
+                role: 'agent',
+                content: {
+                    type: 'codex',
+                    data: {
+                        type: 'tool-call-result',
+                        callId: 'codex-proposed-plan:plan-1',
+                        output: null,
+                        id: 'plan-1:result'
+                    }
+                }
+            }, 2)
+        ].map(message => normalizeDecryptedMessage(message))
+            .filter((message): message is NormalizedMessage => message !== null)
+
+        const reduced = reduceChatBlocks(messages, null)
+
+        expect(reduced.blocks).toContainEqual(expect.objectContaining({
+            kind: 'tool-call',
+            id: 'codex-proposed-plan:plan-1',
+            tool: expect.objectContaining({
+                name: 'ExitPlanMode',
+                state: 'completed',
+                input: { plan },
+                result: null
+            })
+        }))
+    })
+
     it('ignores child agent usage when calculating parent latest usage', () => {
         const messages: NormalizedMessage[] = [
             {

From 4c76668a6cb431a17e23132d287349ebc1845927 Mon Sep 17 00:00:00 2001
From: weishu 
Date: Sun, 12 Jul 2026 18:36:43 +0800
Subject: [PATCH 12/28] refine message actions and metadata

---
 bun.lock                                      |   1 +
 web/package.json                              |   1 +
 .../messages/AssistantMessage.tsx             | 183 +-----------------
 .../messages/MessageActions.test.tsx          |  70 +++++++
 .../AssistantChat/messages/MessageActions.tsx |  82 ++++++++
 .../messages/MessageMetadata.test.ts          |  19 +-
 .../messages/MessageMetadata.tsx              |  26 +--
 .../AssistantChat/messages/UserMessage.tsx    |  66 +------
 web/src/components/icons.tsx                  |  12 ++
 web/src/index.css                             |  27 +++
 web/src/lib/locales/en.ts                     |   3 +
 web/src/lib/locales/zh-CN.ts                  |   3 +
 12 files changed, 222 insertions(+), 271 deletions(-)
 create mode 100644 web/src/components/AssistantChat/messages/MessageActions.test.tsx
 create mode 100644 web/src/components/AssistantChat/messages/MessageActions.tsx

diff --git a/bun.lock b/bun.lock
index 7d0875c9c6..cda410b40b 100644
--- a/bun.lock
+++ b/bun.lock
@@ -99,6 +99,7 @@
         "@hapi/protocol": "workspace:*",
         "@lobehub/icons": "^5.4.0",
         "@radix-ui/react-dialog": "^1.1.15",
+        "@radix-ui/react-popover": "^1.1.15",
         "@radix-ui/react-slot": "^1.2.4",
         "@shikijs/langs": "^3.20.0",
         "@shikijs/themes": "^3.20.0",
diff --git a/web/package.json b/web/package.json
index f6c8904e85..c6822aba3b 100644
--- a/web/package.json
+++ b/web/package.json
@@ -19,6 +19,7 @@
         "@hapi/protocol": "workspace:*",
         "@lobehub/icons": "^5.4.0",
         "@radix-ui/react-dialog": "^1.1.15",
+        "@radix-ui/react-popover": "^1.1.15",
         "@radix-ui/react-slot": "^1.2.4",
         "@shikijs/langs": "^3.20.0",
         "@shikijs/themes": "^3.20.0",
diff --git a/web/src/components/AssistantChat/messages/AssistantMessage.tsx b/web/src/components/AssistantChat/messages/AssistantMessage.tsx
index 3cb2baf577..4391f9b99d 100644
--- a/web/src/components/AssistantChat/messages/AssistantMessage.tsx
+++ b/web/src/components/AssistantChat/messages/AssistantMessage.tsx
@@ -1,17 +1,13 @@
-import { useState } from 'react'
 import { MessagePrimitive, useAssistantState } from '@assistant-ui/react'
 import { MarkdownText } from '@/components/assistant-ui/markdown-text'
 import { Reasoning, ReasoningGroup } from '@/components/assistant-ui/reasoning'
 import { HappyToolMessage } from '@/components/AssistantChat/messages/ToolMessage'
 import { CliOutputBlock } from '@/components/CliOutputBlock'
-import { CopyIcon, CheckIcon } from '@/components/icons'
-import { useCopyToClipboard } from '@/hooks/useCopyToClipboard'
 import type { HappyChatMessageMetadata } from '@/lib/assistant-runtime'
 import { getAssistantCopyText } from '@/components/AssistantChat/messages/assistantCopyText'
 import { getConversationMessageAnchorId } from '@/chat/outline'
-import { MessageMetadata } from '@/components/AssistantChat/messages/MessageMetadata'
 import { CodexReviewCard } from '@/components/AssistantChat/messages/CodexReviewCard'
-import { MessageTimestamp } from '@/components/AssistantChat/messages/MessageTimestamp'
+import { MessageActions } from '@/components/AssistantChat/messages/MessageActions'
 
 const TOOL_COMPONENTS = {
     Fallback: HappyToolMessage
@@ -25,8 +21,6 @@ const MESSAGE_PART_COMPONENTS = {
 } as const
 
 export function HappyAssistantMessage() {
-    const { copied, copy } = useCopyToClipboard()
-    const [showMetadata, setShowMetadata] = useState(false)
     const messageId = useAssistantState(({ message }) => message.id)
     const isCliOutput = useAssistantState(({ message }) => {
         const custom = message.metadata.custom as Partial | undefined
@@ -51,187 +45,28 @@ export function HappyAssistantMessage() {
         return getAssistantCopyText(message.content)
     })
 
-    const invokedAt = useAssistantState(({ message }) => (message.metadata.custom as Partial | undefined)?.invokedAt)
     const durationMs = useAssistantState(({ message }) => (message.metadata.custom as Partial | undefined)?.durationMs)
     const usage = useAssistantState(({ message }) => (message.metadata.custom as Partial | undefined)?.usage)
     const messageModel = useAssistantState(({ message }) => (message.metadata.custom as Partial | undefined)?.model)
     const turnCount = useAssistantState(({ message }) => (message.metadata.custom as Partial | undefined)?.turnCount)
 
-    const hasMetadata = invokedAt != null
-        || (typeof durationMs === 'number' && durationMs >= 0)
-        || usage != null
-        || (messageModel != null && messageModel !== '')
-        || (typeof turnCount === 'number' && turnCount >= 2)
+    const metadata = { durationMs, usage, model: messageModel ?? null, turnCount }
 
     const rootClass = toolOnly
         ? 'py-1 min-w-0 max-w-full overflow-x-hidden'
         : 'px-1 min-w-0 max-w-full overflow-x-hidden'
 
-    if (isCliOutput) {
-        return (
-            
-                
-                
- - {hasMetadata && ( - - )} -
- {showMetadata && ( - - )} -
- ) - } - - if (codexReview) { - return ( - -
-
- -
- - {hasMetadata && ( - - )} -
- {showMetadata && ( - - )} -
- {copyText ? ( -
- -
- ) : null} -
-
- ) - } - - if (toolOnly) { - return ( - -
- -
- - {hasMetadata && ( - - )} -
- {showMetadata && ( - - )} -
-
- ) - } - return ( -
-
- -
- - {hasMetadata && ( - - )} -
- {showMetadata && ( - - )} -
- {copyText ? ( -
- -
- ) : null} -
+ {isCliOutput + ? + : codexReview + ? + : } +
) } diff --git a/web/src/components/AssistantChat/messages/MessageActions.test.tsx b/web/src/components/AssistantChat/messages/MessageActions.test.tsx new file mode 100644 index 0000000000..89117c1db1 --- /dev/null +++ b/web/src/components/AssistantChat/messages/MessageActions.test.tsx @@ -0,0 +1,70 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import type { ComponentProps, PropsWithChildren } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { I18nProvider } from '@/lib/i18n-context' +import { MessageActions } from './MessageActions' + +const copy = vi.fn() + +vi.mock('@assistant-ui/react', () => ({ + useAssistantState: (selector: (state: { message: { createdAt: Date } }) => unknown) => selector({ + message: { createdAt: new Date(2026, 6, 12, 10, 30) } + }) +})) + +vi.mock('@radix-ui/react-popover', () => ({ + Root: ({ children }: PropsWithChildren) => <>{children}, + Trigger: ({ children }: PropsWithChildren) => <>{children}, + Portal: ({ children }: PropsWithChildren) => <>{children}, + Content: ({ children }: PropsWithChildren) =>
{children}
+})) + +vi.mock('@/hooks/useCopyToClipboard', () => ({ + useCopyToClipboard: () => ({ copied: false, copy }) +})) + +function renderActions(props: ComponentProps) { + return render( + + + + ) +} + +describe('MessageActions', () => { + beforeEach(() => { + copy.mockReset() + localStorage.clear() + }) + + it('copies the supplied message text', () => { + renderActions({ align: 'start', copyText: 'message body' }) + + fireEvent.click(screen.getByRole('button', { name: 'Copy' })) + + expect(copy).toHaveBeenCalledWith('message body') + }) + + it('shows meaningful assistant metadata in a popover without invoke time', () => { + renderActions({ + align: 'start', + metadata: { + durationMs: 1250, + model: 'gpt-5.2-codex', + usage: { input_tokens: 100, output_tokens: 25 } + } + }) + + expect(screen.getByRole('button', { name: 'Message details' })).toBeTruthy() + expect(screen.getByText('Duration: 1.3s')).toBeTruthy() + expect(screen.getByText('Model: gpt-5.2-codex')).toBeTruthy() + expect(screen.getByText('Usage: 125 billable tokens (100 in / 25 out)')).toBeTruthy() + expect(screen.queryByText(/^Invoke:/)).toBeNull() + }) + + it('omits the info action when no display metadata exists', () => { + renderActions({ align: 'end', copyText: 'message body', metadata: {} }) + + expect(screen.queryByRole('button', { name: 'Message details' })).toBeNull() + }) +}) diff --git a/web/src/components/AssistantChat/messages/MessageActions.tsx b/web/src/components/AssistantChat/messages/MessageActions.tsx new file mode 100644 index 0000000000..1238a4d442 --- /dev/null +++ b/web/src/components/AssistantChat/messages/MessageActions.tsx @@ -0,0 +1,82 @@ +import * as Popover from '@radix-ui/react-popover' +import { CheckIcon, CopyIcon, InfoIcon } from '@/components/icons' +import { useCopyToClipboard } from '@/hooks/useCopyToClipboard' +import { useTranslation } from '@/lib/use-translation' +import { MessageMetadata, buildMessageMetadataLabels, type MessageMetadataProps } from './MessageMetadata' +import { MessageTimestamp } from './MessageTimestamp' +import { cn } from '@/lib/utils' + +type MessageActionsProps = { + align: 'start' | 'end' + copyText?: string + metadata?: Omit +} + +export function MessageActions({ align, copyText, metadata }: MessageActionsProps) { + const { copied, copy } = useCopyToClipboard() + const { t } = useTranslation() + const canCopy = Boolean(copyText) + const hasMetadata = metadata ? buildMessageMetadataLabels(metadata).length > 0 : false + + return ( +
+ {align === 'end' ? : null} + {canCopy ? ( + + ) : null} + {hasMetadata && metadata ? : null} + {align === 'start' ? : null} +
+ ) +} + +function DesktopTimestamp() { + return ( + + + + ) +} + +function MessageInfoPopover({ metadata }: { metadata: Omit }) { + const { t } = useTranslation() + return ( + + + + + + + + + + + ) +} diff --git a/web/src/components/AssistantChat/messages/MessageMetadata.test.ts b/web/src/components/AssistantChat/messages/MessageMetadata.test.ts index e06946cdc2..f44274b526 100644 --- a/web/src/components/AssistantChat/messages/MessageMetadata.test.ts +++ b/web/src/components/AssistantChat/messages/MessageMetadata.test.ts @@ -54,22 +54,11 @@ describe('buildMessageMetadataLabels', () => { expect(parts).toContain('Duration: 0.0s') }) - it('does not drop an Invoke line when invokedAt is the unix epoch', () => { - const parts = buildMessageMetadataLabels({ invokedAt: 0 }) - expect(parts.some(p => p.startsWith('Invoke:'))).toBe(true) - }) - - it('omits the Invoke line when invokedAt is null or undefined', () => { - expect(buildMessageMetadataLabels({ invokedAt: null }).some(p => p.startsWith('Invoke:'))).toBe(false) - expect(buildMessageMetadataLabels({}).some(p => p.startsWith('Invoke:'))).toBe(false) - }) - // Proof of Invariance — single-turn inputs (turnCount omitted, or < 2) // must produce byte-identical output to the pre-aggregate footer so // existing single-turn cards do not regress visually. it('single-turn input is byte-identical with or without turnCount=1', () => { const base = { - invokedAt: 1700000000000, durationMs: 1234, model: 'claude-sonnet-4-6', usage: { input_tokens: 3, output_tokens: 19, service_tier: 'standard' } @@ -84,7 +73,6 @@ describe('buildMessageMetadataLabels', () => { // formatting would surface visually in single-turn cards. it('pre-aggregate single-turn call produces the exact label sequence', () => { const parts = buildMessageMetadataLabels({ - invokedAt: 1700000000000, durationMs: 1234, model: 'claude-sonnet-4-6', usage: { input_tokens: 3, output_tokens: 19, service_tier: 'standard' } @@ -92,9 +80,7 @@ describe('buildMessageMetadataLabels', () => { // The Invoke value depends on the runner's timezone, so match its // shape rather than a literal time string. The remaining labels are // timezone-independent and locked exactly. - expect(parts).toHaveLength(4) - expect(parts[0]).toMatch(/^Invoke: \d{2}:\d{2}:\d{2}$/) - expect(parts.slice(1)).toEqual([ + expect(parts).toEqual([ 'Duration: 1.2s', 'Model: claude-sonnet-4-6', 'Usage: 22 billable tokens (3 in / 19 out)' @@ -103,7 +89,6 @@ describe('buildMessageMetadataLabels', () => { it('switches to Models/Total/N turns labels only when turnCount >= 2', () => { const parts = buildMessageMetadataLabels({ - invokedAt: 1700000000000, model: 'claude-sonnet-4-6, claude-haiku-4-5-20251001', usage: { input_tokens: 100, output_tokens: 200, service_tier: 'standard' }, turnCount: 3 @@ -119,7 +104,6 @@ describe('buildMessageMetadataLabels', () => { // Mid-session model switch is rare; the common multi-turn case is one // model repeated across N turns. The label must stay singular then. const parts = buildMessageMetadataLabels({ - invokedAt: 1700000000000, model: 'claude-sonnet-4-6', usage: { input_tokens: 10, output_tokens: 20, service_tier: 'standard' }, turnCount: 2 @@ -131,7 +115,6 @@ describe('buildMessageMetadataLabels', () => { it('omits Duration on aggregated footers when durationMs is undefined', () => { const parts = buildMessageMetadataLabels({ - invokedAt: 1700000000000, model: 'claude-sonnet-4-6, claude-haiku-4-5-20251001', usage: { input_tokens: 10, output_tokens: 20, service_tier: 'standard' }, turnCount: 2 diff --git a/web/src/components/AssistantChat/messages/MessageMetadata.tsx b/web/src/components/AssistantChat/messages/MessageMetadata.tsx index 3135814750..bb73aca307 100644 --- a/web/src/components/AssistantChat/messages/MessageMetadata.tsx +++ b/web/src/components/AssistantChat/messages/MessageMetadata.tsx @@ -1,20 +1,18 @@ import type { UsageData } from '@/chat/types' export type MessageMetadataProps = { - invokedAt?: number | null durationMs?: number usage?: UsageData model?: string | null /** * Distinct turn count for the surrounding response group. Single-turn - * footers pass `undefined` (or any value < 2) so the existing - * `Invoke · Model · Usage` output is preserved byte-for-byte. + * footers pass `undefined` (or any value < 2). */ turnCount?: number className?: string } -export function buildMessageMetadataLabels({ invokedAt, durationMs, usage, model, turnCount }: Omit): string[] { +export function buildMessageMetadataLabels({ durationMs, usage, model, turnCount }: Omit): string[] { const parts: string[] = [] // Aggregated footers represent a response group with multiple distinct // turns. When the caller passes `turnCount >= 2` they have already @@ -22,18 +20,6 @@ export function buildMessageMetadataLabels({ invokedAt, durationMs, usage, model // across turns; we adjust the labels to reflect that. const isAggregated = typeof turnCount === 'number' && turnCount >= 2 - // Explicit nullish checks — `if (invokedAt)` would drop epoch 0, and - // `if (durationMs)` would drop legitimate 0 ms turns. - if (invokedAt != null) { - const time = new Date(invokedAt).toLocaleTimeString([], { - hour12: false, - hour: '2-digit', - minute: '2-digit', - second: '2-digit' - }) - parts.push(`Invoke: ${time}`) - } - if (typeof durationMs === 'number' && durationMs >= 0) { parts.push(`Duration: ${(durationMs / 1000).toFixed(1)}s`) } @@ -68,14 +54,14 @@ export function buildMessageMetadataLabels({ invokedAt, durationMs, usage, model return parts } -export function MessageMetadata({ invokedAt, durationMs, usage, model, turnCount, className }: MessageMetadataProps) { - const parts = buildMessageMetadataLabels({ invokedAt, durationMs, usage, model, turnCount }) +export function MessageMetadata({ durationMs, usage, model, turnCount, className }: MessageMetadataProps) { + const parts = buildMessageMetadataLabels({ durationMs, usage, model, turnCount }) if (parts.length === 0) return null return ( -
+
{parts.map((part, i) => ( - {part} + {part} ))}
) diff --git a/web/src/components/AssistantChat/messages/UserMessage.tsx b/web/src/components/AssistantChat/messages/UserMessage.tsx index 8ebe3dc91e..a8ad631dfa 100644 --- a/web/src/components/AssistantChat/messages/UserMessage.tsx +++ b/web/src/components/AssistantChat/messages/UserMessage.tsx @@ -1,4 +1,3 @@ -import { useState } from 'react' import { MessagePrimitive, useAssistantState } from '@assistant-ui/react' import { useHappyChatContext } from '@/components/AssistantChat/context' import type { HappyChatMessageMetadata } from '@/lib/assistant-runtime' @@ -6,16 +5,11 @@ import { MessageStatusIndicator } from '@/components/AssistantChat/messages/Mess import { MessageAttachments } from '@/components/AssistantChat/messages/MessageAttachments' import { UserBubbleContent, getUserBubbleClassName, shouldShowMessageStatus } from '@/components/AssistantChat/messages/user-bubble' import { CliOutputBlock } from '@/components/CliOutputBlock' -import { CopyIcon, CheckIcon } from '@/components/icons' -import { useCopyToClipboard } from '@/hooks/useCopyToClipboard' import { getConversationMessageAnchorId } from '@/chat/outline' -import { MessageMetadata } from '@/components/AssistantChat/messages/MessageMetadata' -import { MessageTimestamp } from '@/components/AssistantChat/messages/MessageTimestamp' +import { MessageActions } from '@/components/AssistantChat/messages/MessageActions' export function HappyUserMessage() { const ctx = useHappyChatContext() - const { copied, copy } = useCopyToClipboard() - const [showMetadata, setShowMetadata] = useState(false) const role = useAssistantState(({ message }) => message.role) const messageId = useAssistantState(({ message }) => message.id) const text = useAssistantState(({ message }) => { @@ -46,10 +40,6 @@ export function HappyUserMessage() { if (custom?.kind !== 'cli-output') return '' return message.content.find((part) => part.type === 'text')?.text ?? '' }) - const invokedAt = useAssistantState(({ message }) => (message.metadata.custom as Partial | undefined)?.invokedAt) - - const hasMetadata = invokedAt != null - if (role !== 'user') return null const canRetry = status === 'failed' && typeof localId === 'string' && Boolean(ctx.onRetryMessage) const onRetry = canRetry ? () => ctx.onRetryMessage!(localId) : undefined @@ -59,26 +49,11 @@ export function HappyUserMessage() { return (
-
- - {hasMetadata && ( - - )} -
- {showMetadata && invokedAt != null && ( - - )} +
) @@ -90,49 +65,22 @@ export function HappyUserMessage() { return ( -
+
{hasText ? : null} {hasAttachments ? : null}
- {(hasText || showStatus) && ( + {showStatus && (
- {hasText && ( - - )} {showStatus ? : null}
)}
-
- - {hasMetadata && ( - - )} -
- {showMetadata && invokedAt != null && ( - - )}
+ ) } diff --git a/web/src/components/icons.tsx b/web/src/components/icons.tsx index 5e58714463..f55f3c719e 100644 --- a/web/src/components/icons.tsx +++ b/web/src/components/icons.tsx @@ -61,6 +61,18 @@ export function CheckIcon(props: IconProps) { ) } +export function InfoIcon(props: IconProps) { + return createIcon( + <> + + + + , + props, + 2 + ) +} + /** Composer schedule-send clock — circle + hands (matches ComposerButtons). */ export function ScheduleIcon(props: IconProps) { return createIcon( diff --git a/web/src/index.css b/web/src/index.css index 5833f9d74a..612b0704d0 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -433,6 +433,33 @@ body { margin-top: max(0px, calc((var(--app-chat-line-height) - var(--app-message-action-size)) / 2)); } +.happy-message-actions-desktop-only, +.happy-message-actions-desktop-only-row { + display: none; +} + +@media (hover: hover) and (pointer: fine) { + .happy-message-actions { + opacity: 0; + pointer-events: none; + transition: opacity 150ms ease; + } + + .happy-message:hover .happy-message-actions, + .happy-message:focus-within .happy-message-actions { + opacity: 1; + pointer-events: auto; + } + + .happy-message-actions-desktop-only { + display: inline-flex; + } + + .happy-message-actions-desktop-only-row { + display: flex; + } +} + .aui-md :where(.contains-task-list) { list-style: none; padding-left: 0; diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index c606cea4ae..245be7a860 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -8,6 +8,9 @@ export default { 'loading.files': 'Loading files…', 'loading.messages': 'Loading messages…', 'loading.machines': 'Loading machines…', + 'message.copy': 'Copy', + 'message.copied': 'Copied', + 'message.info': 'Message details', // Login / Auth 'login.title': 'HAPI', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 44b73250a6..a966483824 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -8,6 +8,9 @@ export default { 'loading.files': '加载文件…', 'loading.messages': '加载消息…', 'loading.machines': '加载机器…', + 'message.copy': '复制', + 'message.copied': '已复制', + 'message.info': '消息详情', // Login / Auth 'login.title': 'HAPI', From d160203bb226f206009b36d34ab24750db268aab Mon Sep 17 00:00:00 2001 From: "ejj.cc" Date: Sun, 12 Jul 2026 18:38:56 +0800 Subject: [PATCH 13/28] fix(codex): support dynamic reasoning efforts (#1012) * fix(codex): support model-reported reasoning efforts * fix(web): prevent service worker edge caching * ci: retrigger stuck Actions run * fix(codex): accept dynamic reasoning effort values * fix(web): restore reasoning effort on model switch failure --- cli/src/codex/appServerTypes.ts | 4 +- cli/src/codex/runCodex.test.ts | 17 ++++ cli/src/codex/runCodex.ts | 15 +--- cli/src/codex/utils/appServerConfig.test.ts | 4 +- cli/src/codex/utils/reasoningEffort.test.ts | 17 ++++ cli/src/codex/utils/reasoningEffort.ts | 16 ++++ cli/src/codex/utils/slashCommands.test.ts | 6 ++ cli/src/codex/utils/slashCommands.ts | 15 ++-- cli/src/commands/codex.test.ts | 14 ++++ cli/src/commands/codex.ts | 17 +--- hub/src/web/server.ts | 14 +++- .../AssistantChat/HappyComposer.tsx | 2 +- .../codexReasoningEffortOptions.test.ts | 33 ++++++++ .../codexReasoningEffortOptions.ts | 11 ++- .../NewSession/ReasoningEffortSelector.tsx | 13 +++- web/src/components/NewSession/index.tsx | 24 +++++- web/src/components/NewSession/types.ts | 3 +- web/src/components/SessionChat.test.ts | 39 +++++++++- web/src/components/SessionChat.tsx | 78 +++++++++++++++++-- web/src/lib/codexModelCapabilities.test.ts | 44 +++++++++++ web/src/lib/codexModelCapabilities.ts | 44 +++++++++++ 21 files changed, 372 insertions(+), 58 deletions(-) create mode 100644 cli/src/codex/utils/reasoningEffort.test.ts create mode 100644 cli/src/codex/utils/reasoningEffort.ts create mode 100644 web/src/lib/codexModelCapabilities.test.ts create mode 100644 web/src/lib/codexModelCapabilities.ts diff --git a/cli/src/codex/appServerTypes.ts b/cli/src/codex/appServerTypes.ts index e6cb42c579..ffebf0e69d 100644 --- a/cli/src/codex/appServerTypes.ts +++ b/cli/src/codex/appServerTypes.ts @@ -153,7 +153,9 @@ export type SandboxPolicy = excludeSlashTmp?: boolean; }; -export type ReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh'; +// The app server reports supported effort identifiers per model. Keep this +// open so newly introduced server values can flow through without a CLI update. +export type ReasoningEffort = string; export type ReasoningSummary = 'auto' | 'none' | 'brief' | 'detailed'; export type CollaborationMode = { diff --git a/cli/src/codex/runCodex.test.ts b/cli/src/codex/runCodex.test.ts index 8b2ab0a2e6..5fc99c808f 100644 --- a/cli/src/codex/runCodex.test.ts +++ b/cli/src/codex/runCodex.test.ts @@ -111,6 +111,7 @@ vi.mock('./utils/codexCliOverrides', () => ({ })) import { runCodex as runCodexImpl } from './runCodex' +import { RPC_METHODS } from '@hapi/protocol/rpcMethods' describe('runCodex', () => { beforeEach(() => { @@ -242,4 +243,20 @@ describe('runCodex', () => { replayTranscriptHistoryOnStart: true })) }) + + it('accepts and normalizes model-reported reasoning efforts from session config', async () => { + await runCodexImpl({ workingDirectory: '/tmp/project' }) + + const registration = harness.session.rpcHandlerManager.registerHandler.mock.calls.find( + ([method]) => method === RPC_METHODS.SetSessionConfig + ) + const handler = registration?.[1] as ((payload: unknown) => Promise) | undefined + expect(handler).toBeTypeOf('function') + + await handler?.({ modelReasoningEffort: 'max' }) + await handler?.({ modelReasoningEffort: ' EXTREME ' }) + + expect(mockCodexSession.setModelReasoningEffort).toHaveBeenNthCalledWith(2, 'max') + expect(mockCodexSession.setModelReasoningEffort).toHaveBeenNthCalledWith(3, 'extreme') + }) }) diff --git a/cli/src/codex/runCodex.ts b/cli/src/codex/runCodex.ts index fe3a0f7088..04b8a9df3a 100644 --- a/cli/src/codex/runCodex.ts +++ b/cli/src/codex/runCodex.ts @@ -19,11 +19,10 @@ import type { ReasoningEffort } from './appServerTypes'; import { parseCodexSpecialCommand } from './codexSpecialCommands'; import { listSlashCommands } from '@/modules/common/slashCommands'; import { resolveCodexSlashCommand } from './utils/slashCommands'; +import { parseReasoningEffortValue } from './utils/reasoningEffort'; export { emitReadyIfIdle } from './utils/emitReadyIfIdle'; -const REASONING_EFFORTS = new Set(['none', 'minimal', 'low', 'medium', 'high', 'xhigh']) - export async function runCodex(opts: { startedBy?: 'runner' | 'terminal'; codexArgs?: string[]; @@ -304,16 +303,6 @@ export async function runCodex(opts: { return parsed.data; }; - const resolveModelReasoningEffort = (value: unknown): ReasoningEffort | undefined => { - if (value === null) { - return undefined; - } - if (typeof value !== 'string' || !REASONING_EFFORTS.has(value as ReasoningEffort)) { - throw new Error('Invalid model reasoning effort'); - } - return value as ReasoningEffort; - }; - const resolveModel = (value: unknown): string => { if (typeof value !== 'string') { throw new Error('Invalid model'); @@ -363,7 +352,7 @@ export async function runCodex(opts: { } if (config.modelReasoningEffort !== undefined) { - currentModelReasoningEffort = resolveModelReasoningEffort(config.modelReasoningEffort); + currentModelReasoningEffort = parseReasoningEffortValue(config.modelReasoningEffort); } if (config.collaborationMode !== undefined) { diff --git a/cli/src/codex/utils/appServerConfig.test.ts b/cli/src/codex/utils/appServerConfig.test.ts index 378cec11ff..2142e18ebf 100644 --- a/cli/src/codex/utils/appServerConfig.test.ts +++ b/cli/src/codex/utils/appServerConfig.test.ts @@ -123,7 +123,7 @@ describe('appServerConfig', () => { it('passes model reasoning effort via thread config', () => { const params = buildThreadStartParams({ cwd: '/workspace/project', - mode: { permissionMode: 'default', modelReasoningEffort: 'xhigh', collaborationMode: 'default' }, + mode: { permissionMode: 'default', modelReasoningEffort: 'ultra', collaborationMode: 'default' }, mcpServers }); @@ -133,7 +133,7 @@ describe('appServerConfig', () => { args: ['mcp'] }, developer_instructions: codexSystemPrompt, - model_reasoning_effort: 'xhigh' + model_reasoning_effort: 'ultra' }); }); diff --git a/cli/src/codex/utils/reasoningEffort.test.ts b/cli/src/codex/utils/reasoningEffort.test.ts new file mode 100644 index 0000000000..d7cf304b11 --- /dev/null +++ b/cli/src/codex/utils/reasoningEffort.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { parseReasoningEffortValue } from './reasoningEffort'; + +describe('parseReasoningEffortValue', () => { + it('normalizes non-empty model-reported values', () => { + expect(parseReasoningEffortValue(' EXTREME ')).toBe('extreme'); + }); + + it('maps null to the default effort', () => { + expect(parseReasoningEffortValue(null)).toBeUndefined(); + }); + + it('rejects empty and non-string values', () => { + expect(() => parseReasoningEffortValue(' ')).toThrow('Invalid model reasoning effort'); + expect(() => parseReasoningEffortValue(42)).toThrow('Invalid model reasoning effort'); + }); +}); diff --git a/cli/src/codex/utils/reasoningEffort.ts b/cli/src/codex/utils/reasoningEffort.ts new file mode 100644 index 0000000000..6e744239ab --- /dev/null +++ b/cli/src/codex/utils/reasoningEffort.ts @@ -0,0 +1,16 @@ +import type { ReasoningEffort } from '../appServerTypes'; + +export function parseReasoningEffortValue(value: unknown): ReasoningEffort | undefined { + if (value === null) { + return undefined; + } + if (typeof value !== 'string') { + throw new Error('Invalid model reasoning effort'); + } + + const effort = value.trim().toLowerCase(); + if (!effort) { + throw new Error('Invalid model reasoning effort'); + } + return effort; +} diff --git a/cli/src/codex/utils/slashCommands.test.ts b/cli/src/codex/utils/slashCommands.test.ts index aac27c181c..c07fc5e1bd 100644 --- a/cli/src/codex/utils/slashCommands.test.ts +++ b/cli/src/codex/utils/slashCommands.test.ts @@ -41,6 +41,12 @@ describe('resolveCodexSlashCommand', () => { expect(resolveCodexSlashCommand('/reasoning low', state)).toMatchObject({ updates: { modelReasoningEffort: 'low' } }); + expect(resolveCodexSlashCommand('/reasoning max', state)).toMatchObject({ + updates: { modelReasoningEffort: 'max' } + }); + expect(resolveCodexSlashCommand('/reasoning EXTREME', state)).toMatchObject({ + updates: { modelReasoningEffort: 'extreme' } + }); expect(resolveCodexSlashCommand('/permissions yolo', state)).toMatchObject({ updates: { permissionMode: 'yolo' } }); diff --git a/cli/src/codex/utils/slashCommands.ts b/cli/src/codex/utils/slashCommands.ts index 62c714a70b..7c481fe100 100644 --- a/cli/src/codex/utils/slashCommands.ts +++ b/cli/src/codex/utils/slashCommands.ts @@ -3,8 +3,8 @@ import type { CodexPermissionMode } from '@hapi/protocol/types'; import type { ReasoningEffort } from '../appServerTypes'; import type { EnhancedMode } from '../loop'; import type { SlashCommand } from '@/modules/common/slashCommands'; +import { parseReasoningEffortValue } from './reasoningEffort'; -const REASONING_EFFORTS = new Set(['none', 'minimal', 'low', 'medium', 'high', 'xhigh']); export const MAX_CODEX_GOAL_OBJECTIVE_CHARS = 4_000; const UNSUPPORTED_CODEX_BUILTIN_COMMANDS = new Set([ @@ -186,16 +186,11 @@ export function resolveCodexSlashCommand( updates: { modelReasoningEffort: null } }; } - if (!REASONING_EFFORTS.has(rest as ReasoningEffort)) { - return { - kind: 'handled', - message: `Unknown Codex reasoning effort: ${rest}` - }; - } + const effort = parseReasoningEffortValue(rest); return { kind: 'handled', - message: `Codex reasoning effort set to ${rest}`, - updates: { modelReasoningEffort: rest as ReasoningEffort } + message: `Codex reasoning effort set to ${effort}`, + updates: { modelReasoningEffort: effort } }; } @@ -259,7 +254,7 @@ export function resolveCodexSlashCommand( '- `/compact` — compact current Codex thread context', '- `/status` — show current Codex session config', '- `/model [name|auto]` — show or set model', - '- `/reasoning [low|medium|high|xhigh|default]` — show or set reasoning effort', + '- `/reasoning [level|default]` — show or set reasoning effort', '- `/fast [on|off|status]` — toggle Fast mode (GPT-5.5 / GPT-5.4, ChatGPT login)', '- `/permissions [default|read-only|safe-yolo|yolo]` — show or set permission mode', '', diff --git a/cli/src/commands/codex.test.ts b/cli/src/commands/codex.test.ts index 1e5fba1ccf..058b242020 100644 --- a/cli/src/commands/codex.test.ts +++ b/cli/src/commands/codex.test.ts @@ -121,6 +121,20 @@ describe('codexCommand', () => { } }) + it('accepts and normalizes a dynamic model reasoning effort', async () => { + await codexCommand.run(createCommandContext([ + '--started-by', + 'runner', + '--model-reasoning-effort', + ' EXTREME ' + ])) + + expect(runCodexMock).toHaveBeenCalledWith({ + startedBy: 'runner', + modelReasoningEffort: 'extreme' + }) + }) + it('prints the upgrade error and exits when the local version check fails', async () => { const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { diff --git a/cli/src/commands/codex.ts b/cli/src/commands/codex.ts index 579bf5cb3d..a57cce5cdf 100644 --- a/cli/src/commands/codex.ts +++ b/cli/src/commands/codex.ts @@ -7,20 +7,7 @@ import { CODEX_PERMISSION_MODES } from '@hapi/protocol/modes' import type { CodexPermissionMode } from '@hapi/protocol/types' import type { ReasoningEffort } from '@/codex/appServerTypes' import { assertCodexLocalSupported } from '@/codex/utils/codexVersion' - -function parseReasoningEffort(value: string): ReasoningEffort { - switch (value) { - case 'none': - case 'minimal': - case 'low': - case 'medium': - case 'high': - case 'xhigh': - return value - default: - throw new Error('Invalid --model-reasoning-effort value') - } -} +import { parseReasoningEffortValue } from '@/codex/utils/reasoningEffort' // Mirror the web /service-tier endpoint's enum so the internal resume spawn // path can never seed/persist an unsupported tier string. @@ -86,7 +73,7 @@ export const codexCommand: CommandDefinition = { if (!effort) { throw new Error('Missing --model-reasoning-effort value') } - options.modelReasoningEffort = parseReasoningEffort(effort) + options.modelReasoningEffort = parseReasoningEffortValue(effort) } else if (arg === '--service-tier') { const tier = commandArgs[++i] if (!tier) { diff --git a/hub/src/web/server.ts b/hub/src/web/server.ts index b0cf0592c5..e3c1a96ad8 100644 --- a/hub/src/web/server.ts +++ b/hub/src/web/server.ts @@ -191,10 +191,18 @@ function findWebappDistDir(): { distDir: string; indexHtmlPath: string } { } function serveEmbeddedAsset(asset: EmbeddedWebAsset): Response { + const headers: Record = { + 'Content-Type': asset.mimeType + } + + if (asset.path === '/sw.js') { + headers['Cache-Control'] = 'no-store, no-cache, must-revalidate' + headers['CDN-Cache-Control'] = 'no-store' + headers['Cloudflare-CDN-Cache-Control'] = 'no-store' + } + return new Response(Bun.file(asset.sourcePath), { - headers: { - 'Content-Type': asset.mimeType - } + headers }) } diff --git a/web/src/components/AssistantChat/HappyComposer.tsx b/web/src/components/AssistantChat/HappyComposer.tsx index 70b1f0866b..d5033cc453 100644 --- a/web/src/components/AssistantChat/HappyComposer.tsx +++ b/web/src/components/AssistantChat/HappyComposer.tsx @@ -471,7 +471,7 @@ export function HappyComposer(props: { ? getCodexComposerReasoningEffortOptions( modelReasoningEffort, agentFlavor, - agentFlavor === 'opencode' ? availableModelReasoningEffortOptions : undefined + availableModelReasoningEffortOptions ) : [], [agentFlavor, modelReasoningEffort, availableModelReasoningEffortOptions] diff --git a/web/src/components/AssistantChat/codexReasoningEffortOptions.test.ts b/web/src/components/AssistantChat/codexReasoningEffortOptions.test.ts index 1b531f34e7..901e2c715c 100644 --- a/web/src/components/AssistantChat/codexReasoningEffortOptions.test.ts +++ b/web/src/components/AssistantChat/codexReasoningEffortOptions.test.ts @@ -23,6 +23,39 @@ describe('getCodexComposerReasoningEffortOptions', () => { ]) }) + it('uses arbitrary model-reported efforts for Codex', () => { + expect(getCodexComposerReasoningEffortOptions('extreme', 'codex', [ + { value: 'low' }, + { value: 'medium' }, + { value: 'high' }, + { value: 'xhigh' }, + { value: 'max' }, + { value: 'ultra' }, + { value: 'extreme' } + ])).toEqual([ + { value: null, label: 'Default' }, + { value: 'low', label: 'Low' }, + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' }, + { value: 'xhigh', label: 'XHigh' }, + { value: 'max', label: 'Max' }, + { value: 'ultra', label: 'Ultra' }, + { value: 'extreme', label: 'Extreme' } + ]) + }) + + it('keeps an unsupported current Codex effort visible', () => { + expect(getCodexComposerReasoningEffortOptions('ultra', 'codex', [ + { value: 'low' }, + { value: 'max' } + ])).toEqual([ + { value: null, label: 'Default' }, + { value: 'ultra', label: 'Ultra' }, + { value: 'low', label: 'Low' }, + { value: 'max', label: 'Max' } + ]) + }) + it('returns no options for OpenCode until dynamic options are available', () => { expect(getCodexComposerReasoningEffortOptions(null, 'opencode')).toEqual([]) expect(getCodexComposerReasoningEffortOptions(null, 'opencode', [])).toEqual([]) diff --git a/web/src/components/AssistantChat/codexReasoningEffortOptions.ts b/web/src/components/AssistantChat/codexReasoningEffortOptions.ts index b2dfc9d09f..692a51ee00 100644 --- a/web/src/components/AssistantChat/codexReasoningEffortOptions.ts +++ b/web/src/components/AssistantChat/codexReasoningEffortOptions.ts @@ -14,7 +14,8 @@ const CODEX_REASONING_EFFORT_LABELS: Record = { medium: 'Medium', high: 'High', xhigh: 'XHigh', - max: 'Max' + max: 'Max', + ultra: 'Ultra' } function normalizeCodexComposerReasoningEffort(effort?: string | null): string | null { @@ -31,7 +32,7 @@ function formatCodexReasoningEffortLabel(effort: string): string { ?? `${effort.charAt(0).toUpperCase()}${effort.slice(1)}` } -function buildOpencodeComposerReasoningEffortOptions( +function buildDynamicReasoningEffortOptions( currentEffort: string | null, dynamicOptions: ComposerReasoningEffortSourceOption[] ): CodexComposerReasoningEffortOption[] { @@ -66,7 +67,11 @@ export function getCodexComposerReasoningEffortOptions( if (!dynamicOptions || dynamicOptions.length === 0) { return [] } - return buildOpencodeComposerReasoningEffortOptions(normalizedCurrentEffort, dynamicOptions) + return buildDynamicReasoningEffortOptions(normalizedCurrentEffort, dynamicOptions) + } + + if (dynamicOptions && dynamicOptions.length > 0) { + return buildDynamicReasoningEffortOptions(normalizedCurrentEffort, dynamicOptions) } const options: CodexComposerReasoningEffortOption[] = [ diff --git a/web/src/components/NewSession/ReasoningEffortSelector.tsx b/web/src/components/NewSession/ReasoningEffortSelector.tsx index 23d55f14db..36f042c8a0 100644 --- a/web/src/components/NewSession/ReasoningEffortSelector.tsx +++ b/web/src/components/NewSession/ReasoningEffortSelector.tsx @@ -1,10 +1,12 @@ import type { AgentType, CodexReasoningEffort } from './types' import { CODEX_REASONING_EFFORT_OPTIONS } from './types' import { useTranslation } from '@/lib/use-translation' +import { getCodexComposerReasoningEffortOptions } from '@/components/AssistantChat/codexReasoningEffortOptions' export function ReasoningEffortSelector(props: { agent: AgentType value: CodexReasoningEffort + availableOptions?: Array<{ value: string; name?: string }> isDisabled: boolean onChange: (value: CodexReasoningEffort) => void }) { @@ -14,6 +16,15 @@ export function ReasoningEffortSelector(props: { return null } + const options = props.agent === 'codex' && props.availableOptions?.length + ? getCodexComposerReasoningEffortOptions(null, props.agent, props.availableOptions).map((option) => ({ + value: option.value ?? 'default', + label: option.label + })) + : CODEX_REASONING_EFFORT_OPTIONS.filter( + (option) => props.agent === 'opencode' ? option.value !== 'xhigh' : option.value !== 'max' + ) + return (