From f51e06e8f3b27b3fdf9b1130ffdc2762cb73bb9f Mon Sep 17 00:00:00 2001 From: Junmo Kim Date: Mon, 29 Jun 2026 12:40:32 +0900 Subject: [PATCH 01/31] fix(web): stop pinning resolved permission cards to the bottom of the chat (#974) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit agentState keeps an answered permission request in completedRequests. When its tool_use message is not in the loaded window, the permission-only synthesis appended a card to the end of the timeline — and there is no chronological re-sort, so the card stays pinned above the composer as a stale "answered" card that never moves to its place in history. With several answered asks this piles up at the bottom of the chat. Synthesize a card only for a *pending* request (the case that needs an answerable card when its message hasn't loaded). A resolved request is history and renders only via its own message when that message is in the window. --- web/src/chat/reducer.test.ts | 40 +++++++++++++++++++++++++++++++++++- web/src/chat/reducer.ts | 31 ++++++++++++---------------- 2 files changed, 52 insertions(+), 19 deletions(-) diff --git a/web/src/chat/reducer.test.ts b/web/src/chat/reducer.test.ts index 4ad3aec119..6000d70c73 100644 --- a/web/src/chat/reducer.test.ts +++ b/web/src/chat/reducer.test.ts @@ -3,7 +3,7 @@ import { reduceChatBlocks } from './reducer' import { normalizeDecryptedMessage } from './normalize' import type { NormalizedMessage } from './types' import type { DecryptedMessage } from '@/types/api' -import type { ThreadGoal, ThreadGoalStatus } from '@/types/api' +import type { AgentState, ThreadGoal, ThreadGoalStatus } from '@/types/api' function userMessage(id: string, text: string, createdAt: number): NormalizedMessage { return { @@ -279,4 +279,42 @@ describe('reduceChatBlocks', () => { tokensUsed: 8016 }) }) + + it('does not pin a resolved request as a bottom card when its message is not in the window', () => { + // agentState keeps completedRequests after an ask is answered. With no + // tool_use message loaded for it, the permission-only synthesis used to + // append an "answered" card at the end of the timeline (no re-sort), + // pinning it above the composer forever. + const messages = [userMessage('u1', 'hello', 1_700_000_000_000)] + const agentState = { + requests: {}, + completedRequests: { + 'ask-done': { + tool: 'AskUserQuestion', + arguments: { questions: [] }, + status: 'approved', + createdAt: 1_700_000_000_500, + completedAt: 1_700_000_000_600 + } + } + } as unknown as AgentState + + const reduced = reduceChatBlocks(messages, agentState) + expect(reduced.blocks.some(b => b.kind === 'tool-call' && b.id === 'ask-done')).toBe(false) + }) + + it('still synthesizes a card for a pending request with no message in the window', () => { + const messages = [userMessage('u1', 'hello', 1_700_000_000_000)] + const agentState = { + requests: { + 'ask-pending': { tool: 'AskUserQuestion', arguments: { questions: [] }, createdAt: 1_700_000_000_500 } + }, + completedRequests: {} + } as unknown as AgentState + + const reduced = reduceChatBlocks(messages, agentState) + const block = reduced.blocks.find(b => b.kind === 'tool-call' && b.id === 'ask-pending') + expect(block).toBeDefined() + expect(block?.kind === 'tool-call' ? block.tool.permission?.status : null).toBe('pending') + }) }) diff --git a/web/src/chat/reducer.ts b/web/src/chat/reducer.ts index 5cb627c155..dca7b9ba9c 100644 --- a/web/src/chat/reducer.ts +++ b/web/src/chat/reducer.ts @@ -118,14 +118,23 @@ export function reduceChatBlocks( const rootResult = reduceTimeline(root, reducerContext) let hasReadyEvent = rootResult.hasReadyEvent - // Only create permission-only tool cards when there is no tool call/result in the transcript. - // Also skip if the permission is older than the oldest message in the current view, - // to avoid mixing old tool cards with newer messages when paginating. + // Synthesize a tool card only for a *pending* permission that has no tool + // call/result in the transcript — so the user can still answer it when its + // tool_use message hasn't loaded. A resolved request (approved/denied/ + // canceled) is history: agentState keeps it in completedRequests, but + // synthesizing it here appends a card to the end of the timeline (there is + // no chronological re-sort), pinning a stale "answered" card above the + // composer forever. Resolved requests render only via their own message, + // when it is in the window. + // Also skip if the permission is older than the oldest message in the + // current view, to avoid mixing old tool cards with newer messages when + // paginating. const oldestMessageTime = normalized.length > 0 ? Math.min(...normalized.map(m => m.createdAt)) : null for (const [id, entry] of permissionsById) { + if (entry.permission.status !== 'pending') continue if (toolIdsInMessages.has(id)) continue if (rootResult.toolBlocksById.has(id)) continue @@ -137,7 +146,7 @@ export function reduceChatBlocks( continue } - const block = ensureToolBlock(rootResult.blocks, rootResult.toolBlocksById, id, { + ensureToolBlock(rootResult.blocks, rootResult.toolBlocksById, id, { createdAt, localId: null, name: entry.toolName, @@ -145,20 +154,6 @@ export function reduceChatBlocks( description: null, permission: entry.permission }) - - if (entry.permission.status === 'approved') { - block.tool.state = 'completed' - block.tool.completedAt = entry.permission.completedAt ?? createdAt - if (block.tool.result === undefined) { - block.tool.result = 'Approved' - } - } else if (entry.permission.status === 'denied' || entry.permission.status === 'canceled') { - block.tool.state = 'error' - block.tool.completedAt = entry.permission.completedAt ?? createdAt - if (block.tool.result === undefined && entry.permission.reason) { - block.tool.result = { error: entry.permission.reason } - } - } } // Calculate latest usage from messages (find the most recent message with usage data) From 8493ec92f4bfd9a61dc55a499f97c12c4c819ff1 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Mon, 29 Jun 2026 04:41:02 +0100 Subject: [PATCH 02/31] fix(cli): Pi RPC parsers compatible with Zod 4 optional fields (#973) asStrOrDef/asOpt* helpers now treat missing keys as undefined so get_available_models/get_commands parsing works again. safeParse success checked explicitly. Co-authored-by: Cursor --- cli/src/pi/schemas.ts | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/cli/src/pi/schemas.ts b/cli/src/pi/schemas.ts index 3532265af2..358b441313 100644 --- a/cli/src/pi/schemas.ts +++ b/cli/src/pi/schemas.ts @@ -17,20 +17,20 @@ import type { PiModelSummary } from '@hapi/protocol/apiTypes'; // 字段级容错 schema // ============================================================================ -/** 提取 string 值,非 string 返回 undefined */ -const asOptStr = z.unknown().transform(v => typeof v === 'string' ? v : undefined); +/** 提取 string 值,非 string 或缺失返回 undefined */ +const asOptStr = z.unknown().optional().transform(v => typeof v === 'string' ? v : undefined); -/** 提取 number 值,非 number 返回 undefined */ -const asOptNum = z.unknown().transform(v => typeof v === 'number' ? v : undefined); +/** 提取 number 值,非 number 或缺失返回 undefined */ +const asOptNum = z.unknown().optional().transform(v => typeof v === 'number' ? v : undefined); -/** 提取 boolean 值,非 boolean 返回 undefined */ -const asOptBool = z.unknown().transform(v => typeof v === 'boolean' ? v : undefined); +/** 提取 boolean 值,非 boolean 或缺失返回 undefined */ +const asOptBool = z.unknown().optional().transform(v => typeof v === 'boolean' ? v : undefined); -/** 提取 string 值,非 string 返回指定默认值 */ -const asStrOrDef = (def: string) => z.unknown().transform(v => typeof v === 'string' ? v : def); +/** 提取 string 值,非 string 或缺失返回指定默认值 */ +const asStrOrDef = (def: string) => z.unknown().optional().transform(v => typeof v === 'string' ? v : def); -/** 提取合法的 thinkingLevelMap,非法结构返回 undefined */ -const asOptThinkingLevelMap = z.unknown().transform((v): Record | undefined => { +/** 提取合法的 thinkingLevelMap,非法结构或缺失返回 undefined */ +const asOptThinkingLevelMap = z.unknown().optional().transform((v): Record | undefined => { if (typeof v !== 'object' || v === null) return undefined; const map: Record = {}; for (const [key, val] of Object.entries(v as Record)) { @@ -80,7 +80,7 @@ const PiCommandSummarySchema = z.object({ const PiCommandEntrySchema = z.object({ name: asStrOrDef(''), description: asOptStr, - source: z.unknown().transform(v => + source: z.unknown().optional().transform(v => VALID_COMMAND_SOURCES.includes(v as PiCommandSource) ? (v as PiCommandSource) : ('skill' as const), @@ -195,9 +195,11 @@ export const PiAssistantMessageEventSchema = z.object({ // ============================================================================ export function parsePiCommands(data: unknown) { - return PiCommandsResponseDataSchema.safeParse(data).data ?? []; + const result = PiCommandsResponseDataSchema.safeParse(data) + return result.success ? result.data : [] } export function parsePiModels(data: unknown) { - return PiModelsResponseDataSchema.safeParse(data).data ?? []; + const result = PiModelsResponseDataSchema.safeParse(data) + return result.success ? result.data : [] } From 5ade95221885bba024b6edbd58c1408f1f75c038 Mon Sep 17 00:00:00 2001 From: SSU-WEI HUANG Date: Mon, 29 Jun 2026 11:41:21 +0800 Subject: [PATCH 03/31] fix(cursor): support ACP parameterized model picker (#969) * test: reproduce issue #968 * fix: support Cursor parameterized model picker (closes #968) --- .../acp/AcpSdkBackend.initialize.test.ts | 43 ++++++++ cli/src/agent/backends/acp/AcpSdkBackend.ts | 10 +- .../utils/cursorAcpModelsSnapshot.test.ts | 46 ++++++++ .../cursor/utils/cursorAcpModelsSnapshot.ts | 57 ++++++++-- cli/src/cursor/utils/cursorModeConfig.test.ts | 103 ++++++++++++++++++ cli/src/cursor/utils/cursorModeConfig.ts | 69 +++++++++++- .../HappyComposer.modelEffort.test.tsx | 31 ++++++ .../AssistantChat/HappyComposer.tsx | 69 ++++++++++++ 8 files changed, 418 insertions(+), 10 deletions(-) create mode 100644 cli/src/agent/backends/acp/AcpSdkBackend.initialize.test.ts create mode 100644 web/src/components/AssistantChat/HappyComposer.modelEffort.test.tsx diff --git a/cli/src/agent/backends/acp/AcpSdkBackend.initialize.test.ts b/cli/src/agent/backends/acp/AcpSdkBackend.initialize.test.ts new file mode 100644 index 0000000000..6dc851b794 --- /dev/null +++ b/cli/src/agent/backends/acp/AcpSdkBackend.initialize.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from 'vitest'; + +const transportState = vi.hoisted(() => ({ + calls: [] as Array<{ method: string; params?: unknown }> +})); + +vi.mock('./AcpStdioTransport', () => ({ + AcpStdioTransport: class { + constructor(_options: unknown) {} + onNotification = vi.fn(); + onStderrError = vi.fn(); + registerRequestHandler = vi.fn(); + sendRequest = vi.fn(async (method: string, params?: unknown) => { + transportState.calls.push({ method, params }); + if (method === 'initialize') { + return { protocolVersion: 1, authMethods: [] }; + } + return null; + }); + close = vi.fn(async () => {}); + } +})); + +import { AcpSdkBackend } from './AcpSdkBackend'; + +describe('AcpSdkBackend.initialize', () => { + it('advertises Cursor-compatible parameterized model picker support', async () => { + const backend = new AcpSdkBackend({ command: 'agent', args: ['acp'] }); + + await backend.initialize(); + + expect(transportState.calls).toContainEqual({ + method: 'initialize', + params: expect.objectContaining({ + clientCapabilities: expect.objectContaining({ + _meta: expect.objectContaining({ + parameterizedModelPicker: true + }) + }) + }) + }); + }); +}); diff --git a/cli/src/agent/backends/acp/AcpSdkBackend.ts b/cli/src/agent/backends/acp/AcpSdkBackend.ts index 13191a5a4c..b82421a94a 100644 --- a/cli/src/agent/backends/acp/AcpSdkBackend.ts +++ b/cli/src/agent/backends/acp/AcpSdkBackend.ts @@ -131,7 +131,13 @@ export class AcpSdkBackend implements AgentBackend { protocolVersion: 1, clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, - terminal: false + terminal: false, + _meta: { + // Cursor ACP exposes Composer's non-fast/fast choice as separate + // `model` + `fast` config options only when the client advertises + // this capability. Agents that do not know this metadata ignore it. + parameterizedModelPicker: true + } }, clientInfo: { name: 'hapi', @@ -839,7 +845,7 @@ export class AcpSdkBackend implements AgentBackend { for (const entry of response.configOptions) { if (!isObject(entry)) continue; - if (asString(entry.category) !== 'model') continue; + if (asString(entry.category) !== 'model' && asString(entry.id) !== 'model') continue; return { currentValue: asString(entry.currentValue), options: Array.isArray(entry.options) ? entry.options : [] diff --git a/cli/src/cursor/utils/cursorAcpModelsSnapshot.test.ts b/cli/src/cursor/utils/cursorAcpModelsSnapshot.test.ts index 2aaa652d6a..8b4903c3a9 100644 --- a/cli/src/cursor/utils/cursorAcpModelsSnapshot.test.ts +++ b/cli/src/cursor/utils/cursorAcpModelsSnapshot.test.ts @@ -47,4 +47,50 @@ describe('buildCursorModelsSnapshotFromAcp', () => { expect(snapshot?.availableModels).toHaveLength(2); }); + + it('synthesizes Composer fast variants from parameterized model + fast config options', () => { + const backend = { + getSessionModelsMetadata: () => ({ + availableModels: [{ modelId: 'composer-2.5', name: 'Composer 2.5' }], + currentModelId: 'composer-2.5' + }), + getConfigOptionByCategory: (_sessionId: string, category: string) => { + if (category === 'model') { + return { + id: 'model', + currentValue: 'composer-2.5', + options: [{ value: 'composer-2.5', name: 'Composer 2.5' }] + }; + } + if (category === 'fast') { + return { + id: 'fast', + currentValue: 'false', + options: [{ value: 'false', name: 'Off' }, { value: 'true', name: 'Fast' }] + }; + } + return undefined; + }, + getSessionConfigOptions: () => [ + { + id: 'model', + currentValue: 'composer-2.5', + options: [{ value: 'composer-2.5', name: 'Composer 2.5' }] + }, + { + id: 'fast', + currentValue: 'false', + options: [{ value: 'false', name: 'Off' }, { value: 'true', name: 'Fast' }] + } + ] + }; + + const snapshot = buildCursorModelsSnapshotFromAcp(backend, 's1'); + + expect(snapshot?.availableModels.map((entry) => entry.modelId).sort()).toEqual([ + 'composer-2.5[fast=false]', + 'composer-2.5[fast=true]' + ]); + expect(snapshot?.currentModelId).toBe('composer-2.5[fast=false]'); + }); }); diff --git a/cli/src/cursor/utils/cursorAcpModelsSnapshot.ts b/cli/src/cursor/utils/cursorAcpModelsSnapshot.ts index 3d836da197..5c3b27e269 100644 --- a/cli/src/cursor/utils/cursorAcpModelsSnapshot.ts +++ b/cli/src/cursor/utils/cursorAcpModelsSnapshot.ts @@ -6,6 +6,18 @@ export type CursorModelsSnapshot = { currentModelId: string | null; }; +type CursorAcpModelSnapshotBackend = Pick + & Partial>; + +function findConfigOption( + backend: CursorAcpModelSnapshotBackend, + sessionId: string, + key: string +) { + return backend.getConfigOptionByCategory?.(sessionId, key) + ?? backend.getSessionConfigOptions?.(sessionId)?.find((option) => option.id === key || option.category === key); +} + function mergeModelEntries( target: Map, entries: Iterable<{ modelId: string; name?: string | null }> @@ -31,19 +43,43 @@ function mergeModelEntries( * `availableModels` alone is often one variant per base family. */ export function buildCursorModelsSnapshotFromAcp( - backend: Pick, + backend: CursorAcpModelSnapshotBackend, sessionId: string ): CursorModelsSnapshot | null { const metadata = backend.getSessionModelsMetadata(sessionId); - const modelOption = backend.getConfigOptionByCategory?.(sessionId, 'model'); + const modelOption = findConfigOption(backend, sessionId, 'model'); + const fastOption = findConfigOption(backend, sessionId, 'fast'); if (!metadata && !modelOption) { return null; } const merged = new Map(); + const parameterizedFastModels: CursorModelSummary[] = []; + + if (modelOption?.options?.length && fastOption?.options?.length) { + const fastValues = fastOption.options + .map((option) => option.value.trim()) + .filter((value) => value === 'false' || value === 'true'); + if (fastValues.length > 0) { + for (const option of modelOption.options) { + const modelId = option.value.trim(); + if (!modelId || modelId.includes('[')) { + continue; + } + for (const fast of fastValues) { + parameterizedFastModels.push({ + modelId: `${modelId}[fast=${fast}]`, + name: option.name + }); + } + } + } + } - if (modelOption?.options?.length) { + if (parameterizedFastModels.length > 0) { + mergeModelEntries(merged, parameterizedFastModels); + } else if (modelOption?.options?.length) { mergeModelEntries(merged, modelOption.options.map((option) => ({ modelId: option.value, name: option.name @@ -51,16 +87,23 @@ export function buildCursorModelsSnapshotFromAcp( } if (metadata?.availableModels?.length) { - mergeModelEntries(merged, metadata.availableModels); + mergeModelEntries( + merged, + parameterizedFastModels.length > 0 + ? metadata.availableModels.filter((entry) => entry.modelId.includes('[')) + : metadata.availableModels + ); } if (merged.size === 0) { return null; } - const currentModelId = metadata?.currentModelId - ?? modelOption?.currentValue - ?? null; + const currentModelId = parameterizedFastModels.length > 0 && modelOption?.currentValue && fastOption?.currentValue + ? `${modelOption.currentValue}[fast=${fastOption.currentValue}]` + : metadata?.currentModelId + ?? modelOption?.currentValue + ?? null; return { availableModels: [...merged.values()], diff --git a/cli/src/cursor/utils/cursorModeConfig.test.ts b/cli/src/cursor/utils/cursorModeConfig.test.ts index ea4c39dbf1..63a0cde3c5 100644 --- a/cli/src/cursor/utils/cursorModeConfig.test.ts +++ b/cli/src/cursor/utils/cursorModeConfig.test.ts @@ -194,6 +194,109 @@ describe('applyCursorAcpModel', () => { expect(setConfigOption).toHaveBeenCalledWith('s1', 'model-opt', 'composer-2.5[fast=false]'); }); + it('applies parameterized Cursor Composer fast=false for base CLI sku requests', async () => { + const setConfigOption = vi.fn(async () => {}); + const backend = mockModelBackend({ + setConfigOption, + getSessionModelsMetadata: vi.fn(() => ({ + availableModels: [{ modelId: 'composer-2.5', name: 'Composer 2.5' }], + currentModelId: 'composer-2.5' + })), + getConfigOptionByCategory: vi.fn((_sessionId: string, category: string) => { + if (category === 'model') { + return { + id: 'model', + category: 'model', + currentValue: 'composer-2.5', + options: [{ value: 'composer-2.5', name: 'Composer 2.5' }] + }; + } + if (category === 'fast') { + return { + id: 'fast', + category: 'fast', + currentValue: 'true', + options: [{ value: 'false', name: 'Off' }, { value: 'true', name: 'Fast' }] + }; + } + return undefined; + }) + }); + + await expect(applyCursorAcpModel(backend, 's1', 'composer-2.5')).resolves.toEqual({ + applied: true, + resolvedWireId: 'composer-2.5[fast=false]', + requestedWireId: 'composer-2.5' + }); + expect(setConfigOption).toHaveBeenNthCalledWith(1, 's1', 'model', 'composer-2.5'); + expect(setConfigOption).toHaveBeenNthCalledWith(2, 's1', 'fast', 'false'); + expect(backend.pinSessionModelWireId).toHaveBeenCalledWith('s1', 'composer-2.5[fast=false]'); + }); + + it('applies parameterized Cursor Composer fast=true for -fast CLI sku requests', async () => { + const setConfigOption = vi.fn(async () => {}); + const backend = mockModelBackend({ + setConfigOption, + getSessionModelsMetadata: vi.fn(() => ({ + availableModels: [{ modelId: 'composer-2.5', name: 'Composer 2.5' }], + currentModelId: 'composer-2.5' + })), + getConfigOptionByCategory: vi.fn((_sessionId: string, category: string) => { + if (category === 'model') { + return { id: 'model', category: 'model', options: [{ value: 'composer-2.5' }] }; + } + if (category === 'fast') { + return { id: 'fast', category: 'fast', options: [{ value: 'false' }, { value: 'true' }] }; + } + return undefined; + }) + }); + + await expect(applyCursorAcpModel(backend, 's1', 'composer-2.5-fast')).resolves.toMatchObject({ + applied: true, + resolvedWireId: 'composer-2.5[fast=true]', + requestedWireId: 'composer-2.5-fast' + }); + expect(setConfigOption).toHaveBeenNthCalledWith(1, 's1', 'model', 'composer-2.5'); + expect(setConfigOption).toHaveBeenNthCalledWith(2, 's1', 'fast', 'true'); + }); + + it('does not fall back to base-only model apply when parameterized fast update fails', async () => { + const setConfigOption = vi.fn() + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('fast update failed')); + const backend = mockModelBackend({ + setConfigOption, + getSessionModelsMetadata: vi.fn(() => ({ + availableModels: [{ modelId: 'composer-2.5', name: 'Composer 2.5' }], + currentModelId: 'composer-2.5' + })), + getConfigOptionByCategory: vi.fn((_sessionId: string, category: string) => { + if (category === 'model') { + return { + id: 'model', + category: 'model', + options: [{ value: 'composer-2.5', name: 'Composer 2.5' }] + }; + } + if (category === 'fast') { + return { + id: 'fast', + category: 'fast', + options: [{ value: 'false', name: 'Off' }, { value: 'true', name: 'Fast' }] + }; + } + return undefined; + }) + }); + + await expect(applyCursorAcpModel(backend, 's1', 'composer-2.5')).resolves.toEqual({ + applied: false + }); + expect(setConfigOption).toHaveBeenCalledTimes(2); + expect(backend.pinSessionModelWireId).not.toHaveBeenCalled(); + }); + it('retries set_config_option once before failing apply', async () => { const setConfigOption = vi.fn() .mockRejectedValueOnce(new Error('transient')) diff --git a/cli/src/cursor/utils/cursorModeConfig.ts b/cli/src/cursor/utils/cursorModeConfig.ts index 2f21a3b35d..ed7da60add 100644 --- a/cli/src/cursor/utils/cursorModeConfig.ts +++ b/cli/src/cursor/utils/cursorModeConfig.ts @@ -1,5 +1,5 @@ import type { CursorPermissionMode } from '@hapi/protocol/types'; -import { matchCliSkuToAcpWireId } from '@hapi/protocol'; +import { cursorCliSkuBaseId, cursorModelBaseId, matchCliSkuToAcpWireId } from '@hapi/protocol'; import type { AcpSdkBackend } from '@/agent/backends/acp'; import { logger } from '@/ui/logger'; @@ -71,6 +71,9 @@ export type ApplyCursorAcpModelResult = { requestedWireId?: string; }; +type ConfigOption = NonNullable>; +type ParameterizedCursorModelResult = ApplyCursorAcpModelResult | 'unsupported' | 'failed'; + /** Wire id stored on session + keepalive (preserve explicit variant picks). */ export function wireIdForCursorSessionState(requested: string, resolved: string): string { const trimmed = requested.trim(); @@ -101,6 +104,61 @@ export function resolveCursorAcpWireId( return matchCliSkuToAcpWireId(trimmed, available); } +function findConfigOption(backend: AcpSdkBackend, sessionId: string, key: string): ConfigOption | undefined { + return backend.getConfigOptionByCategory?.(sessionId, key) + ?? backend.getSessionConfigOptions?.(sessionId)?.find((option) => option.id === key || option.category === key); +} + +function optionHasValue(option: ConfigOption | undefined, value: string): boolean { + return Boolean(option?.options?.some((entry) => entry.value === value)); +} + +function fastHintForCursorSkuOrWire(modelId: string): 'false' | 'true' { + const lower = modelId.trim().toLowerCase(); + const fastMatch = lower.match(/[\[,](?:\s*)fast=(true|false)(?:\s*)[\],]/) + ?? lower.match(/\[\s*fast=(true|false)\s*\]/); + if (fastMatch?.[1] === 'true' || fastMatch?.[1] === 'false') { + return fastMatch[1]; + } + return lower.includes('-fast') ? 'true' : 'false'; +} + +function cursorRequestBaseId(modelId: string): string { + return modelId.includes('[') + ? cursorModelBaseId(modelId) + : cursorCliSkuBaseId(modelId); +} + +async function applyParameterizedCursorModel( + backend: AcpSdkBackend, + sessionId: string, + requested: string +): Promise { + const modelOption = findConfigOption(backend, sessionId, 'model'); + const fastOption = findConfigOption(backend, sessionId, 'fast'); + if (!modelOption || !fastOption || !backend.setConfigOption) { + return 'unsupported'; + } + + const baseModel = cursorRequestBaseId(requested); + const fast = fastHintForCursorSkuOrWire(requested); + if (!optionHasValue(modelOption, baseModel) || !optionHasValue(fastOption, fast)) { + return 'unsupported'; + } + + try { + await backend.setConfigOption(sessionId, modelOption.id, baseModel); + await backend.setConfigOption(sessionId, fastOption.id, fast); + } catch (error) { + logger.debug('[cursor-acp] parameterized model config failed', error); + return 'failed'; + } + + const resolved = `${baseModel}[fast=${fast}]`; + backend.pinSessionModelWireId(sessionId, resolved); + return { applied: true, resolvedWireId: resolved, requestedWireId: requested }; +} + /** * Apply a model from the live ACP configOptions list (Zed-style). * Only wire ids present in `availableModels` are accepted. @@ -118,6 +176,15 @@ export async function applyCursorAcpModel( const metadata = backend.getSessionModelsMetadata(sessionId); const available = metadata?.availableModels ?? []; const modelOption = backend.getConfigOptionByCategory?.(sessionId, 'model'); + + const parameterized = await applyParameterizedCursorModel(backend, sessionId, trimmed); + if (parameterized === 'failed') { + return { applied: false }; + } + if (parameterized !== 'unsupported') { + return parameterized; + } + const optionWireIds = modelOption?.options?.map((option) => ({ modelId: option.value })) ?? []; const catalog = [...available, ...optionWireIds]; const resolved = resolveCursorAcpWireId(trimmed, catalog); diff --git a/web/src/components/AssistantChat/HappyComposer.modelEffort.test.tsx b/web/src/components/AssistantChat/HappyComposer.modelEffort.test.tsx new file mode 100644 index 0000000000..f9966d60df --- /dev/null +++ b/web/src/components/AssistantChat/HappyComposer.modelEffort.test.tsx @@ -0,0 +1,31 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { ModelEffortSettingsSection } from './HappyComposer'; + +vi.mock('@/lib/use-translation', () => ({ + useTranslation: () => ({ + t: (key: string) => key === 'misc.variant' ? 'Variant' : key + }) +})); + +describe('ModelEffortSettingsSection', () => { + it('renders Cursor variant choices and marks the selected variant', () => { + render( + {}} + /> + ); + + expect(screen.getByText('Variant')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^Composer 2.5$/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Composer 2.5 Fast/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^Composer 2.5$/ }).innerHTML).toContain('bg-[var(--app-link)]'); + }); +}); diff --git a/web/src/components/AssistantChat/HappyComposer.tsx b/web/src/components/AssistantChat/HappyComposer.tsx index 10287ae6e7..e2639d2a13 100644 --- a/web/src/components/AssistantChat/HappyComposer.tsx +++ b/web/src/components/AssistantChat/HappyComposer.tsx @@ -81,6 +81,57 @@ export type ComposerSendError = { const defaultSuggestionHandler = async (): Promise => [] +export function ModelEffortSettingsSection(props: { + agentFlavor?: string | null + options: Array<{ value: string; label: string }> + selectedValue: string | null | undefined + controlsDisabled: boolean + onChange: (value: string) => void +}) { + const { t } = useTranslation() + const { agentFlavor, options, selectedValue, controlsDisabled, onChange } = props + + return ( +
+
+ {agentFlavor === 'cursor' ? t('misc.variant') : t('misc.effort')} +
+ {options.map((option) => { + const isSelected = selectedValue === option.value + return ( + + ) + })} +
+ ) +} + export function HappyComposer(props: { sessionId?: string disabled?: boolean @@ -984,6 +1035,24 @@ export function HappyComposer(props: { ) : null} + {showModelSettings && showModelEffortSettings ? ( +
+ ) : null} + + {showModelEffortSettings ? ( + + ) : null} + + {(showModelSettings || showModelEffortSettings) && showModelReasoningEffortSettings ? ( +
+ ) : null} + {(showModelSettings || showModelEffortSettings || showModelReasoningEffortSettings) && showEffortSettings ? (
) : null} From 26a24bb6cea7e7bfdbf0bb819113db80c800967d Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Mon, 29 Jun 2026 04:41:53 +0100 Subject: [PATCH 04/31] feat(web,hub,cli): show machine health in session sidebar (#962) * feat(web,hub,cli): show machine load in session sidebar Runners attach OS health snapshots to machine-alive heartbeats; the hub caches them and the web session list renders load or CPU between the machine label and session count. Co-authored-by: Cursor * fix(web,cli): show CPU and RAM pressure in machine health badge Sidebar label now combines CPU and RAM percentages for overload signaling; load stays in the tooltip on Unix. Prime CPU sampling so the first heartbeat includes usage, not just memory. Co-authored-by: Cursor * feat(web): visual machine health meters with tooltip Replace bare CPU/RAM text with labeled mini bar gauges, chip border tint by severity, and a HoverTooltip explaining capacity and overload guidance. Co-authored-by: Cursor * fix(web): widen machine health tooltip with horizontal layout Allow a generous popover width and lay CPU/RAM/load out side by side so the capacity tooltip reads wider and less tall than the chip. Co-authored-by: Cursor * fix(web): anchor machine health tooltip to row left edge Wide tooltip was align=end on the chip, so it grew left off-screen. Use row-span positioning on the machine tile button instead. Co-authored-by: Cursor * feat(web): machine host card with OS label and inline health Turn the session sidebar machine row into a bordered host panel with OS metadata and side-by-side CPU/RAM meters embedded in the tile instead of a flat label line matching project rows. Co-authored-by: Cursor * fix(web): keep machine host tile single-row height Collapse the machine header back to one py-1.5 row with OS and compact inline health beside the name, and restore the original project indent without the extra nested rail or second header line. Co-authored-by: Cursor * feat(web): show CPU core count in machine health tooltip When the runner reports cpuCount, the tooltip reads "CPU across all 6 cores" instead of the generic all-cores label. Co-authored-by: Cursor * docs: add machine health sidebar screenshots Dogfood captures for the session sidebar machine tile and capacity tooltip, for upstream PR review. Co-authored-by: Cursor * fix(cli): clear machine-alive priming timeout on disconnect Track the 50ms CPU priming setTimeout and clear it in stopKeepAlive so disconnect/shutdown during the delay cannot leave a stray interval alive. Co-authored-by: Cursor * chore: drop dogfood screenshots from upstream PR diff Review evidence lives in the PR discussion only; no need to ship PNGs in the repo long-term. Co-authored-by: Cursor * fix(web): truncate long machine OS/host metadata in sidebar row Bound the metadata span so a long hostname cannot push the health chip or session count off-screen in narrow sidebars. Co-authored-by: Cursor * fix(web): reveal machine health tooltip on keyboard row focus Wire MACHINE_ROW_TOOLTIP_FOCUS_CLASS and aria-describedby on the machine header button so keyboard users can read the health tooltip like session rows. Co-authored-by: Cursor * fix(cli): use MemAvailable for Linux RAM pressure on Bun Bun's os.freemem() reflects MemFree (~1% on cache-heavy hosts), which made sidebar RAM read ~99% while btop showed ~40% used. Parse /proc/meminfo MemAvailable instead so used percent matches operator tools. Co-authored-by: Cursor * feat(web,cli): show machine uptime in sidebar tiles and tooltip Collect os.uptime() as uptimeSeconds on keepalive and render compact up 1h 54m in the machine meta row plus an Uptime line in the health tooltip. Co-authored-by: Cursor * fix(web): anchor machine health tooltip to chip not row align=row positioned the tooltip below the full machine header button, so the collapsible project panel painted over it on hover. Use align=end with a min-width panel so mouse and keyboard tooltips stay visible. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- cli/src/api/apiMachine.test.ts | 59 ++++++ cli/src/api/apiMachine.ts | 20 +- cli/src/utils/machineHealth.test.ts | 39 ++++ cli/src/utils/machineHealth.ts | 142 +++++++++++++ .../socket/handlers/cli/machineHandlers.ts | 1 + hub/src/socket/server.ts | 2 +- hub/src/sync/aliveEvents.test.ts | 49 +++++ hub/src/sync/machineCache.ts | 47 ++++- hub/src/sync/syncEngine.ts | 2 +- shared/src/schemas.ts | 14 +- shared/src/socket.ts | 2 +- shared/src/types.ts | 1 + web/src/components/HoverTooltip.tsx | 19 +- .../components/MachineGroupHeader.test.tsx | 80 +++++++ web/src/components/MachineGroupHeader.tsx | 133 ++++++++++++ .../MachineHealthIndicator.test.tsx | 50 +++++ web/src/components/MachineHealthIndicator.tsx | 181 ++++++++++++++++ web/src/components/SessionList.tsx | 52 ++--- web/src/lib/locales/en.ts | 21 ++ web/src/lib/locales/zh-CN.ts | 21 ++ web/src/lib/machineHealth.test.ts | 102 +++++++++ web/src/lib/machineHealth.ts | 198 ++++++++++++++++++ web/src/router.tsx | 8 + web/src/types/api.ts | 1 + 24 files changed, 1194 insertions(+), 50 deletions(-) create mode 100644 cli/src/utils/machineHealth.test.ts create mode 100644 cli/src/utils/machineHealth.ts create mode 100644 web/src/components/MachineGroupHeader.test.tsx create mode 100644 web/src/components/MachineGroupHeader.tsx create mode 100644 web/src/components/MachineHealthIndicator.test.tsx create mode 100644 web/src/components/MachineHealthIndicator.tsx create mode 100644 web/src/lib/machineHealth.test.ts create mode 100644 web/src/lib/machineHealth.ts diff --git a/cli/src/api/apiMachine.test.ts b/cli/src/api/apiMachine.test.ts index 5af3186cee..321990f0f2 100644 --- a/cli/src/api/apiMachine.test.ts +++ b/cli/src/api/apiMachine.test.ts @@ -144,3 +144,62 @@ describe('ApiMachineClient listOpencodeModelsForCwd handler', () => { } }) }) + +describe('ApiMachineClient keepAlive lifecycle', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('clears priming timeout on shutdown before first machine-alive emit', () => { + const machine = makeMachine('machine-keepalive') + const client = new ApiMachineClient('cli-token', machine) + const emit = vi.fn() + ;(client as unknown as { socket: { emit: typeof emit; close: () => void } }).socket = { + emit, + close: vi.fn(), + } as never + + const priv = client as unknown as { + startKeepAlive: () => void + keepAliveInterval: NodeJS.Timeout | null + keepAliveStartTimeout: ReturnType | null + } + + priv.startKeepAlive() + client.shutdown() + vi.advanceTimersByTime(100) + + expect(emit).not.toHaveBeenCalled() + expect(priv.keepAliveInterval).toBeNull() + expect(priv.keepAliveStartTimeout).toBeNull() + }) + + it('clears running keepAlive interval on shutdown', () => { + const machine = makeMachine('machine-keepalive-2') + const client = new ApiMachineClient('cli-token', machine) + const emit = vi.fn() + ;(client as unknown as { socket: { emit: typeof emit; close: () => void } }).socket = { + emit, + close: vi.fn(), + } as never + + const priv = client as unknown as { + startKeepAlive: () => void + keepAliveInterval: NodeJS.Timeout | null + } + + priv.startKeepAlive() + vi.advanceTimersByTime(50) + expect(emit).toHaveBeenCalledTimes(1) + + client.shutdown() + vi.advanceTimersByTime(20_000) + + expect(emit).toHaveBeenCalledTimes(1) + expect(priv.keepAliveInterval).toBeNull() + }) +}) diff --git a/cli/src/api/apiMachine.ts b/cli/src/api/apiMachine.ts index 88ba35966b..4225ff6a51 100644 --- a/cli/src/api/apiMachine.ts +++ b/cli/src/api/apiMachine.ts @@ -25,6 +25,7 @@ import { import type { SpawnSessionOptions, SpawnSessionResult } from '../modules/common/rpcTypes' import { applyVersionedAck } from './versionedUpdate' import { buildSocketIoExtraHeaderOptions } from './hubExtraHeaders' +import { collectMachineHealth } from '@/utils/machineHealth' type MachineRpcHandlers = { spawnSession: (options: SpawnSessionOptions) => Promise @@ -73,6 +74,7 @@ function formatWorkspaceRoots(paths?: string[]): string { export class ApiMachineClient { private socket!: Socket private keepAliveInterval: NodeJS.Timeout | null = null + private keepAliveStartTimeout: ReturnType | null = null private rpcHandlerManager: RpcHandlerManager private readonly normalizedWorkspaceRoots: string[] | undefined @@ -489,15 +491,27 @@ export class ApiMachineClient { private startKeepAlive(): void { this.stopKeepAlive() - this.keepAliveInterval = setInterval(() => { + const emitAlive = () => { this.socket.emit('machine-alive', { machineId: this.machine.id, - time: Date.now() + time: Date.now(), + health: collectMachineHealth() }) - }, 20_000) + } + // Prime CPU sampling so the first heartbeat already includes CPU %. + collectMachineHealth() + this.keepAliveStartTimeout = setTimeout(() => { + this.keepAliveStartTimeout = null + emitAlive() + this.keepAliveInterval = setInterval(emitAlive, 20_000) + }, 50) } private stopKeepAlive(): void { + if (this.keepAliveStartTimeout) { + clearTimeout(this.keepAliveStartTimeout) + this.keepAliveStartTimeout = null + } if (this.keepAliveInterval) { clearInterval(this.keepAliveInterval) this.keepAliveInterval = null diff --git a/cli/src/utils/machineHealth.test.ts b/cli/src/utils/machineHealth.test.ts new file mode 100644 index 0000000000..bdfb207089 --- /dev/null +++ b/cli/src/utils/machineHealth.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { collectMachineHealth, readLinuxMemoryUsedPercent, resetMachineHealthSamplerForTests } from './machineHealth' + +describe('readLinuxMemoryUsedPercent', () => { + it('uses MemAvailable, not MemFree, so page cache does not read as pressure', () => { + const meminfo = ` +MemTotal: 32793696 kB +MemFree: 578248 kB +MemAvailable: 18312444 kB +Buffers: 1196580 kB +Cached: 9758076 kB +`.trim() + + expect(readLinuxMemoryUsedPercent(meminfo)).toBe(44) + }) +}) + +describe('collectMachineHealth', () => { + it('returns schema-valid health with memory, uptime, and cpu count', () => { + resetMachineHealthSamplerForTests() + const health = collectMachineHealth(1_700_000_000_000) + expect(health.collectedAt).toBe(1_700_000_000_000) + expect(health.cpuCount).toBeGreaterThan(0) + expect(health.memoryPercent).toBeGreaterThanOrEqual(0) + expect(health.memoryPercent).toBeLessThanOrEqual(100) + expect(health.uptimeSeconds).toBeGreaterThan(0) + }) + + it('computes cpu percent after a second sample', async () => { + resetMachineHealthSamplerForTests() + collectMachineHealth() + await new Promise((resolve) => setTimeout(resolve, 50)) + const second = collectMachineHealth() + if (second.cpuPercent !== undefined) { + expect(second.cpuPercent).toBeGreaterThanOrEqual(0) + expect(second.cpuPercent).toBeLessThanOrEqual(100) + } + }) +}) diff --git a/cli/src/utils/machineHealth.ts b/cli/src/utils/machineHealth.ts new file mode 100644 index 0000000000..53d837e21e --- /dev/null +++ b/cli/src/utils/machineHealth.ts @@ -0,0 +1,142 @@ +import { readFileSync } from 'node:fs' +import { availableParallelism, cpus, freemem, loadavg, platform, totalmem, uptime } from 'node:os' +import type { MachineHealth } from '@hapi/protocol/types' +import { MachineHealthSchema } from '@hapi/protocol/schemas' + +type CpuTimesSnapshot = { + idle: number + total: number +} + +let previousCpuSnapshot: CpuTimesSnapshot | null = null + +function sumCpuTimes(): CpuTimesSnapshot | null { + const cores = cpus() + if (cores.length === 0) { + return null + } + + let idle = 0 + let total = 0 + for (const core of cores) { + const times = core.times + idle += times.idle + total += times.user + times.nice + times.sys + times.idle + times.irq + } + + return { idle, total } +} + +function computeCpuPercent(current: CpuTimesSnapshot, previous: CpuTimesSnapshot): number | undefined { + const idleDelta = current.idle - previous.idle + const totalDelta = current.total - previous.total + if (totalDelta <= 0) { + return undefined + } + + const usage = 1 - idleDelta / totalDelta + return Math.max(0, Math.min(100, Math.round(usage * 100))) +} + +function parseMeminfoKbValue(meminfo: string, key: string): number | undefined { + for (const line of meminfo.split('\n')) { + if (!line.startsWith(`${key}:`)) { + continue + } + const kb = Number(line.split(/\s+/)[1]) + return Number.isFinite(kb) ? kb * 1024 : undefined + } + return undefined +} + +/** Linux pressure percent: (MemTotal - MemAvailable) / MemTotal. Testable without /proc. */ +export function readLinuxMemoryUsedPercent(meminfo: string): number | undefined { + const total = parseMeminfoKbValue(meminfo, 'MemTotal') + if (!total || total <= 0) { + return undefined + } + + const available = parseMeminfoKbValue(meminfo, 'MemAvailable') + if (available !== undefined) { + return Math.max(0, Math.min(100, Math.round(((total - available) / total) * 100))) + } + + // Pre-3.14 kernels: approximate available as free + reclaimable cache. + const free = parseMeminfoKbValue(meminfo, 'MemFree') + if (free === undefined) { + return undefined + } + const buffers = parseMeminfoKbValue(meminfo, 'Buffers') ?? 0 + const cached = parseMeminfoKbValue(meminfo, 'Cached') ?? 0 + const approxAvailable = free + buffers + cached + return Math.max(0, Math.min(100, Math.round(((total - approxAvailable) / total) * 100))) +} + +function computeMemoryPercent(): number | undefined { + if (platform() === 'linux') { + try { + const fromProc = readLinuxMemoryUsedPercent(readFileSync('/proc/meminfo', 'utf8')) + if (fromProc !== undefined) { + return fromProc + } + } catch { + // fall through to os.freemem() + } + } + + const total = totalmem() + if (total <= 0) { + return undefined + } + + const used = total - freemem() + return Math.max(0, Math.min(100, Math.round((used / total) * 100))) +} + +function isUnixLikeLoadPlatform(): boolean { + return platform() !== 'win32' +} + +function computeUptimeSeconds(): number | undefined { + const seconds = uptime() + if (!Number.isFinite(seconds) || seconds < 0) { + return undefined + } + return Math.floor(seconds) +} + +export function collectMachineHealth(now: number = Date.now()): MachineHealth { + const cpuCount = availableParallelism() + const memoryPercent = computeMemoryPercent() + const uptimeSeconds = computeUptimeSeconds() + const load1m = isUnixLikeLoadPlatform() ? loadavg()[0] : undefined + + const cpuSnapshot = sumCpuTimes() + let cpuPercent: number | undefined + if (cpuSnapshot && previousCpuSnapshot) { + cpuPercent = computeCpuPercent(cpuSnapshot, previousCpuSnapshot) + } + if (cpuSnapshot) { + previousCpuSnapshot = cpuSnapshot + } + + const health = { + collectedAt: now, + cpuCount, + ...(load1m !== undefined ? { load1m } : {}), + ...(cpuPercent !== undefined ? { cpuPercent } : {}), + ...(memoryPercent !== undefined ? { memoryPercent } : {}), + ...(uptimeSeconds !== undefined ? { uptimeSeconds } : {}) + } + + const parsed = MachineHealthSchema.safeParse(health) + if (!parsed.success) { + return { collectedAt: now } + } + return parsed.data +} + +/** Test helper */ +export function resetMachineHealthSamplerForTests(): void { + previousCpuSnapshot = null +} diff --git a/hub/src/socket/handlers/cli/machineHandlers.ts b/hub/src/socket/handlers/cli/machineHandlers.ts index 43c0555f6a..4c98d40eb1 100644 --- a/hub/src/socket/handlers/cli/machineHandlers.ts +++ b/hub/src/socket/handlers/cli/machineHandlers.ts @@ -9,6 +9,7 @@ import type { AccessErrorReason, AccessResult } from './types' type MachineAlivePayload = { machineId: string time: number + health?: unknown } type ResolveMachineAccess = (machineId: string) => AccessResult diff --git a/hub/src/socket/server.ts b/hub/src/socket/server.ts index 02728afaed..e22280350c 100644 --- a/hub/src/socket/server.ts +++ b/hub/src/socket/server.ts @@ -40,7 +40,7 @@ export type SocketServerDeps = { onSessionAlive?: (payload: { sid: string; time: number; thinking?: boolean; mode?: 'local' | 'remote' }) => void onSessionReady?: (payload: { sid: string; time: number }) => void onSessionEnd?: (payload: { sid: string; time: number }) => void - onMachineAlive?: (payload: { machineId: string; time: number }) => void + onMachineAlive?: (payload: { machineId: string; time: number; health?: unknown }) => void onBackgroundTaskDelta?: (sessionId: string, delta: { started: number; completed: number }) => void onSessionActivity?: (sessionId: string, updatedAt: number) => void onSweepImmediateQueued?: (sessionId: string, now: number) => void diff --git a/hub/src/sync/aliveEvents.test.ts b/hub/src/sync/aliveEvents.test.ts index a218f493e4..abc02076d3 100644 --- a/hub/src/sync/aliveEvents.test.ts +++ b/hub/src/sync/aliveEvents.test.ts @@ -64,6 +64,55 @@ describe('alive incremental events', () => { expect(update.data).toEqual(expect.objectContaining({ id: machine.id, active: true })) }) + it('stores health from machine alive and rebroadcasts when it changes', () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new MachineCache(store, createPublisher(events)) + + const machine = cache.getOrCreateMachine( + 'machine-health-test', + { host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' }, + null, + 'default' + ) + + events.length = 0 + cache.handleMachineAlive({ + machineId: machine.id, + time: Date.now(), + health: { + collectedAt: Date.now(), + load1m: 0.4, + cpuCount: 8, + memoryPercent: 55 + } + }) + + const updated = cache.getMachine(machine.id) + expect(updated?.health).toEqual(expect.objectContaining({ load1m: 0.4, cpuCount: 8 })) + + events.length = 0 + cache.handleMachineAlive({ + machineId: machine.id, + time: Date.now() + 1, + health: { + collectedAt: Date.now() + 1, + load1m: 2.1, + cpuCount: 8, + memoryPercent: 80 + } + }) + + const healthUpdate = events.find((event) => event.type === 'machine-updated') + expect(healthUpdate).toBeDefined() + if (!healthUpdate || healthUpdate.type !== 'machine-updated' || !healthUpdate.data || typeof healthUpdate.data !== 'object') { + return + } + expect(healthUpdate.data).toEqual(expect.objectContaining({ + health: expect.objectContaining({ load1m: 2.1, memoryPercent: 80 }) + })) + }) + it('marks session thinking immediately when a user message is accepted by the hub', async () => { const store = new Store(':memory:') const emittedSocketUpdates: unknown[] = [] diff --git a/hub/src/sync/machineCache.ts b/hub/src/sync/machineCache.ts index 750db3e587..1579fd75b2 100644 --- a/hub/src/sync/machineCache.ts +++ b/hub/src/sync/machineCache.ts @@ -1,9 +1,38 @@ import type { Machine, MachinePatch } from '@hapi/protocol/types' -import { MachineMetadataSchema, RunnerStateSchema } from '@hapi/protocol/schemas' +import { MachineHealthSchema, MachineMetadataSchema, RunnerStateSchema } from '@hapi/protocol/schemas' import type { Store } from '../store' import { clampAliveTime } from './aliveTime' import { EventPublisher } from './eventPublisher' +type MachineAlivePayload = { + machineId: string + time: number + health?: unknown +} + +function parseMachineHealth(value: unknown): Machine['health'] { + const parsed = MachineHealthSchema.safeParse(value) + return parsed.success ? parsed.data : null +} + +function healthDisplayChanged( + before: Machine['health'] | undefined, + after: Machine['health'] | null | undefined +): boolean { + if (!before && !after) { + return false + } + if (!before || !after) { + return true + } + + return before.load1m !== after.load1m + || before.cpuPercent !== after.cpuPercent + || before.memoryPercent !== after.memoryPercent + || before.cpuCount !== after.cpuCount + || before.uptimeSeconds !== after.uptimeSeconds +} + export class MachineCache { private readonly machines: Map = new Map() private readonly lastBroadcastAtByMachineId: Map = new Map() @@ -93,7 +122,8 @@ export class MachineCache { metadata, metadataVersion: stored.metadataVersion, runnerState, - runnerStateVersion: stored.runnerStateVersion + runnerStateVersion: stored.runnerStateVersion, + health: existing?.health ?? null } this.machines.set(machineId, machine) @@ -108,7 +138,7 @@ export class MachineCache { } } - handleMachineAlive(payload: { machineId: string; time: number }): void { + handleMachineAlive(payload: MachineAlivePayload): void { const t = clampAliveTime(payload.time) if (!t) return @@ -116,12 +146,21 @@ export class MachineCache { if (!machine) return const wasActive = machine.active + const previousHealth = machine.health ?? null machine.active = true machine.activeAt = Math.max(machine.activeAt, t) + if (payload.health !== undefined) { + machine.health = parseMachineHealth(payload.health) + } + const now = Date.now() const lastBroadcastAt = this.lastBroadcastAtByMachineId.get(machine.id) ?? 0 - const shouldBroadcast = (!wasActive && machine.active) || (now - lastBroadcastAt > 10_000) + const healthChanged = payload.health !== undefined + && healthDisplayChanged(previousHealth, machine.health) + const shouldBroadcast = (!wasActive && machine.active) + || healthChanged + || (now - lastBroadcastAt > 10_000) if (shouldBroadcast) { this.lastBroadcastAtByMachineId.set(machine.id, now) this.publisher.emit({ type: 'machine-updated', machineId: machine.id, data: machine }) diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index d59f58fd47..7986a81db4 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -354,7 +354,7 @@ export class SyncEngine { this.sessionCache.recordSessionActivity(sessionId, updatedAt) } - handleMachineAlive(payload: { machineId: string; time: number }): void { + handleMachineAlive(payload: { machineId: string; time: number; health?: unknown }): void { this.machineCache.handleMachineAlive(payload) } diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index 1b891fbef5..58b1a8aede 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -278,6 +278,17 @@ export const RunnerStateSchema = z.object({ export type RunnerState = z.infer +export const MachineHealthSchema = z.object({ + collectedAt: z.number(), + cpuCount: z.number().int().positive().optional(), + load1m: z.number().nonnegative().optional(), + cpuPercent: z.number().min(0).max(100).optional(), + memoryPercent: z.number().min(0).max(100).optional(), + uptimeSeconds: z.number().nonnegative().optional() +}).strict() + +export type MachineHealth = z.infer + export const MachineSchema = z.object({ id: z.string(), namespace: z.string(), @@ -289,7 +300,8 @@ export const MachineSchema = z.object({ metadata: MachineMetadataSchema.nullable(), metadataVersion: z.number(), runnerState: RunnerStateSchema.nullable(), - runnerStateVersion: z.number() + runnerStateVersion: z.number(), + health: MachineHealthSchema.nullable().optional() }) export type Machine = z.infer diff --git a/shared/src/socket.ts b/shared/src/socket.ts index e050b0df98..977dcd5c9e 100644 --- a/shared/src/socket.ts +++ b/shared/src/socket.ts @@ -219,7 +219,7 @@ export interface ClientToServerEvents { 'messages-consumed': (data: { sid: string; localIds: string[] }) => void 'update-metadata': (data: { sid: string; expectedVersion: number; metadata: unknown }, cb: (answer: UpdateMetadataAck) => void) => void 'update-state': (data: { sid: string; expectedVersion: number; agentState: unknown | null }, cb: (answer: UpdateStateAck) => void) => void - 'machine-alive': (data: { machineId: string; time: number }) => void + 'machine-alive': (data: { machineId: string; time: number; health?: unknown }) => void 'machine-update-metadata': (data: { machineId: string; expectedVersion: number; metadata: unknown }, cb: (answer: MachineUpdateMetadataAck) => void) => void 'machine-update-state': (data: { machineId: string; expectedVersion: number; runnerState: unknown | null }, cb: (answer: MachineUpdateStateAck) => void) => void 'rpc-register': (data: { method: string }) => void diff --git a/shared/src/types.ts b/shared/src/types.ts index 9c38fda221..63ffb71d98 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -6,6 +6,7 @@ export type { DecryptedMessage, Metadata, Machine, + MachineHealth, MachineMetadata, MachinePatch, MachineUpdatedData, diff --git a/web/src/components/HoverTooltip.tsx b/web/src/components/HoverTooltip.tsx index cc523f0620..d24744feed 100644 --- a/web/src/components/HoverTooltip.tsx +++ b/web/src/components/HoverTooltip.tsx @@ -5,6 +5,9 @@ import { cn } from '@/lib/utils' export const SESSION_ROW_TOOLTIP_FOCUS_CLASS = 'group-focus-visible/session-row:opacity-100 group-focus-visible/session-row:visible' +export const MACHINE_ROW_TOOLTIP_FOCUS_CLASS = + 'group-focus-visible/machine-row:opacity-100 group-focus-visible/machine-row:visible' + /** * Lightweight CSS-driven tooltip used by the session list to surface "why is * this indicator showing?" copy on hover/focus. Pure CSS reveal (no portal, @@ -30,21 +33,25 @@ export function HoverTooltip(props: { /** Rich tooltip content. Plain text or a small fragment with headings/lists. */ children: ReactNode side?: 'top' | 'bottom' - align?: 'start' | 'center' | 'end' + align?: 'start' | 'center' | 'end' | 'row' className?: string /** Parent-focus reveal classes (e.g. SESSION_ROW_TOOLTIP_FOCUS_CLASS). */ revealOnParentFocusClass?: string + /** Optional classes for the tooltip panel (e.g. wider popover). */ + tooltipClassName?: string }) { const side = props.side ?? 'bottom' const align = props.align ?? 'center' + const spansRow = align === 'row' - const alignClasses = - align === 'start' ? 'left-0' + const alignClasses = spansRow + ? 'left-1 right-1 w-auto' + : align === 'start' ? 'left-0' : align === 'end' ? 'right-0' : 'left-1/2 -translate-x-1/2' return ( - + {props.target} @@ -52,7 +59,8 @@ export function HoverTooltip(props: { role="tooltip" id={props.id} className={cn( - 'pointer-events-none absolute z-30 max-w-[14rem] whitespace-normal', + 'pointer-events-none absolute z-30 whitespace-normal', + spansRow ? 'max-w-none' : 'max-w-[14rem]', 'rounded-md border border-[var(--app-border)] bg-[var(--app-secondary-bg)]', 'px-2 py-1 text-xs leading-snug text-[var(--app-fg)] shadow-lg', side === 'top' ? 'bottom-full mb-1' : 'top-full mt-1', @@ -60,6 +68,7 @@ export function HoverTooltip(props: { 'opacity-0 invisible', 'group-hover:opacity-100 group-hover:visible', props.revealOnParentFocusClass, + props.tooltipClassName, 'transition-opacity duration-100' )} > diff --git a/web/src/components/MachineGroupHeader.test.tsx b/web/src/components/MachineGroupHeader.test.tsx new file mode 100644 index 0000000000..1193cbeb9d --- /dev/null +++ b/web/src/components/MachineGroupHeader.test.tsx @@ -0,0 +1,80 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import type { Machine } from '@/types/api' +import { MachineGroupHeader } from './MachineGroupHeader' +import { I18nProvider } from '@/lib/i18n-context' + +const machine: Machine = { + id: 'Teemo', + namespace: 'default', + seq: 1, + createdAt: 0, + updatedAt: 0, + active: true, + activeAt: 0, + metadata: { + host: 'Teemo', + platform: 'win32', + happyCliVersion: '0.20.2', + }, + metadataVersion: 1, + runnerState: null, + runnerStateVersion: 0, +} + +describe('MachineGroupHeader', () => { + it('renders a single-row machine tile with os label and compact health', () => { + render( + + {}} + machine={machine} + healthPresentation={{ + metrics: [ + { id: 'cpu', shortLabel: 'CPU', percent: 12, tone: 'ok' }, + { id: 'ram', shortLabel: 'RAM', percent: 88, tone: 'warn' }, + ], + overallTone: 'warn', + status: 'elevated', + }} + /> + + ) + + expect(screen.getByRole('button', { name: /Teemo/i })).toBeTruthy() + expect(screen.getByText('Windows')).toBeTruthy() + expect(screen.getByText('(4)')).toBeTruthy() + expect(screen.getByLabelText(/CPU 12 percent; RAM 88 percent/i)).toBeTruthy() + }) + + it('shows compact uptime in the machine meta row', () => { + render( + + {}} + machine={{ + ...machine, + metadata: { ...machine.metadata!, host: 'proxmox', platform: 'linux' }, + }} + healthPresentation={{ + metrics: [ + { id: 'cpu', shortLabel: 'CPU', percent: 12, tone: 'ok' }, + { id: 'ram', shortLabel: 'RAM', percent: 40, tone: 'ok' }, + ], + overallTone: 'ok', + status: 'healthy', + uptimeDetail: '1h 54m', + }} + /> + + ) + + expect(screen.getByTitle('Linux · up 1h 54m')).toBeTruthy() + }) +}) diff --git a/web/src/components/MachineGroupHeader.tsx b/web/src/components/MachineGroupHeader.tsx new file mode 100644 index 0000000000..6f8e5d5fd9 --- /dev/null +++ b/web/src/components/MachineGroupHeader.tsx @@ -0,0 +1,133 @@ +import { useId } from 'react' +import type { Machine } from '@/types/api' +import { MACHINE_ROW_TOOLTIP_FOCUS_CLASS } from '@/components/HoverTooltip' +import { MachineHealthIndicator } from '@/components/MachineHealthIndicator' +import { + getMachineHost, + getMachinePlatform, + resolveMachineOsLabel, + shouldShowMachineHostSubtitle, + type MachineHealthPresentation, +} from '@/lib/machineHealth' +import { cn } from '@/lib/utils' +import { useTranslation } from '@/lib/use-translation' + +function MachineIcon(props: { className?: string }) { + return ( + + + + + + ) +} + +function ChevronIcon(props: { className?: string; collapsed?: boolean }) { + return ( + + + + ) +} + +function formatOsLabel( + osLabel: ReturnType, + t: (key: string) => string +): string { + if (osLabel.kind === 'raw') { + return osLabel.value + } + return t(osLabel.key) +} + +export function MachineGroupHeader(props: { + label: string + sessionCount: number + collapsed: boolean + onToggle: () => void + machine?: Machine + healthPresentation: MachineHealthPresentation | null +}) { + const { t } = useTranslation() + const healthTooltipId = useId() + const platform = getMachinePlatform(props.machine) + const host = getMachineHost(props.machine) + const osLabel = resolveMachineOsLabel(platform) + const osText = formatOsLabel(osLabel, t) + const showHost = shouldShowMachineHostSubtitle(props.label, host) + const uptimeText = props.healthPresentation?.uptimeDetail + const metaParts = [osText] + if (showHost && host) { + metaParts.push(host) + } + if (uptimeText) { + metaParts.push(t('machine.health.uptimeCompact', { value: uptimeText })) + } + const machineMeta = metaParts.join(' · ') + const hasHealth = props.healthPresentation && props.healthPresentation.metrics.length > 0 + + return ( + + ) +} diff --git a/web/src/components/MachineHealthIndicator.test.tsx b/web/src/components/MachineHealthIndicator.test.tsx new file mode 100644 index 0000000000..677846f484 --- /dev/null +++ b/web/src/components/MachineHealthIndicator.test.tsx @@ -0,0 +1,50 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { MachineHealthIndicator } from './MachineHealthIndicator' +import { I18nProvider } from '@/lib/i18n-context' + +describe('MachineHealthIndicator', () => { + it('renders labeled cpu and ram meter bars', () => { + render( + + + + ) + + expect(screen.getByText('CPU')).toBeTruthy() + expect(screen.getByText('RAM')).toBeTruthy() + expect(screen.getByText('CPU across all 6 cores')).toBeTruthy() + expect(screen.getByLabelText(/CPU 72/i)).toBeTruthy() + }) + + it('renders inline percent labels', () => { + render( + + + + ) + + expect(screen.getByLabelText(/CPU 34 percent; RAM 56 percent/i)).toBeTruthy() + }) +}) diff --git a/web/src/components/MachineHealthIndicator.tsx b/web/src/components/MachineHealthIndicator.tsx new file mode 100644 index 0000000000..457039d49f --- /dev/null +++ b/web/src/components/MachineHealthIndicator.tsx @@ -0,0 +1,181 @@ +import { useId } from 'react' +import { HoverTooltip } from '@/components/HoverTooltip' +import { + MACHINE_HEALTH_BAR_FILL_CLASS, + MACHINE_HEALTH_CHIP_CLASS, + getCpuMetricTooltipLabel, + type MachineHealthMetricPresentation, + type MachineHealthPresentation +} from '@/lib/machineHealth' +import { cn } from '@/lib/utils' +import { useTranslation } from '@/lib/use-translation' + +function HealthMeterBar(props: { + label: string + percent: number + tone: MachineHealthPresentation['overallTone'] + layout: 'stack' | 'inline' + compact?: boolean +}) { + const barWidthClass = props.compact ? 'w-8' : props.layout === 'inline' ? 'w-14' : 'w-11' + const labelWidthClass = props.compact ? 'w-5 text-[8px]' : 'w-6 text-[9px]' + + return ( +
+ + {props.label} + + + ) +} + +function TooltipMetricStat(props: { + metric: MachineHealthMetricPresentation + label: string +}) { + return ( + + {props.label} + + {props.metric.percent}% + + + ) +} + +function MachineHealthTooltipBody(props: { + presentation: MachineHealthPresentation +}) { + const { t } = useTranslation() + const { presentation } = props + const statusKey = `machine.health.status.${presentation.status}` as const + + return ( + + + {t('machine.health.tooltip.title')} + {t(statusKey)} + + + {presentation.metrics.map((metric) => ( + + ))} + {presentation.loadDetail ? ( + + {t('machine.health.tooltip.loadShort')} + + {presentation.loadDetail} + + + ) : null} + {presentation.uptimeDetail ? ( + + {t('machine.health.tooltip.uptimeShort')} + + {presentation.uptimeDetail} + + + ) : null} + + + {t('machine.health.tooltip.hint')} + + + ) +} + +export function MachineHealthIndicator(props: { + presentation: MachineHealthPresentation + className?: string + layout?: 'stack' | 'inline' + compact?: boolean + tooltipId?: string + revealOnParentFocusClass?: string +}) { + const { t } = useTranslation() + const generatedTooltipId = useId() + const tooltipId = props.tooltipId ?? generatedTooltipId + const { presentation, layout = 'stack', compact = false } = props + + const ariaLabel = presentation.metrics.length > 0 + ? presentation.metrics + .map((metric) => t(`machine.health.aria.${metric.id}`, { n: metric.percent })) + .join('; ') + : t('machine.health.aria.unknown') + + const chip = ( + + {presentation.metrics.map((metric) => ( + + ))} + + ) + + return ( + + + + ) +} diff --git a/web/src/components/SessionList.tsx b/web/src/components/SessionList.tsx index 8443b88279..4b90e8bea9 100644 --- a/web/src/components/SessionList.tsx +++ b/web/src/components/SessionList.tsx @@ -22,6 +22,9 @@ import { formatRelativeTime } from '@/lib/relativeTime' import { formatScheduledTooltipDetail } from '@/lib/scheduledTime' import { getCodexImportedAt, subscribeCodexImportedSessions } from '@/lib/codexImportedSessions' import { formatReopenError } from '@/lib/reopenError' +import type { Machine } from '@/types/api' +import { getMachinePlatform, presentMachineHealth } from '@/lib/machineHealth' +import { MachineGroupHeader } from '@/components/MachineGroupHeader' type SessionGroup = { key: string @@ -542,28 +545,6 @@ function SessionListSearch(props: { ) } -function MachineIcon(props: { className?: string }) { - return ( - - - - - - ) -} - - function formatCodexImportedRelativeTime(value: number, t: (key: string, params?: Record) => string): string | null { const ms = value < 1_000_000_000_000 ? value * 1000 : value if (!Number.isFinite(ms)) return null @@ -802,10 +783,11 @@ export function SessionList(props: { renderHeader?: boolean api: ApiClient | null machineLabelsById?: Record + machinesById?: Record selectedSessionId?: string | null }) { const { t } = useTranslation() - const { renderHeader = true, api, selectedSessionId, machineLabelsById = {}, onNewSessionInDirectory } = props + const { renderHeader = true, api, selectedSessionId, machineLabelsById = {}, machinesById = {}, onNewSessionInDirectory } = props const { sessionPreviewLimit } = useSessionPreviewLimit() const { sessionListStatusMode } = useSessionListStatusMode() const { showActiveSessionsOnly } = useShowActiveSessionsOnly() @@ -1045,19 +1027,21 @@ export function SessionList(props: {
{machineGroups.map((mg) => { const machineCollapsed = isMachineCollapsed(mg) + const machine = mg.machineId ? machinesById[mg.machineId] : undefined + const healthPresentation = presentMachineHealth( + machine?.health, + getMachinePlatform(machine) + ) return (
- {/* Level 1: Machine */} - + toggleMachine(mg)} + machine={machine} + healthPresentation={healthPresentation} + /> {/* Level 2: Projects */}
diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index dcaeeb20d3..3e1d922091 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -245,6 +245,27 @@ export default { // Machine 'machine.unknown': 'Unknown platform', + 'machine.os.windows': 'Windows', + 'machine.os.linux': 'Linux', + 'machine.os.macos': 'macOS', + 'machine.os.unknown': 'Unknown OS', + 'machine.header.sessionCount': '{n} sessions', + 'machine.health.tooltip.title': 'Machine capacity', + 'machine.health.status.healthy': 'Healthy — room for more agents', + 'machine.health.status.elevated': 'Elevated — new agents may run slower', + 'machine.health.status.high': 'High pressure — avoid spawning more here', + 'machine.health.status.unknown': 'Metrics unavailable', + 'machine.health.metric.cpu': 'CPU across all cores', + 'machine.health.metric.cpuWithCount': 'CPU across all {n} cores', + 'machine.health.metric.ram': 'RAM in use', + 'machine.health.tooltip.load': 'Run queue (1 min): {value}', + 'machine.health.tooltip.loadShort': 'Load (1m)', + 'machine.health.tooltip.uptimeShort': 'Uptime', + 'machine.health.uptimeCompact': 'up {value}', + 'machine.health.tooltip.hint': 'Updated every ~20s from the runner on this machine.', + 'machine.health.aria.cpu': 'CPU {n} percent', + 'machine.health.aria.ram': 'RAM {n} percent', + 'machine.health.aria.unknown': 'Machine health unavailable', // Chat 'chat.placeholder': 'Type a message…', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 7508af1039..7518bb771e 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -249,6 +249,27 @@ export default { // Machine 'machine.unknown': '未知平台', + 'machine.os.windows': 'Windows', + 'machine.os.linux': 'Linux', + 'machine.os.macos': 'macOS', + 'machine.os.unknown': '未知系统', + 'machine.header.sessionCount': '{n} 个会话', + 'machine.health.tooltip.title': '机器负载', + 'machine.health.status.healthy': '健康 — 还可运行更多代理', + 'machine.health.status.elevated': '偏高 — 新代理可能变慢', + 'machine.health.status.high': '高压 — 避免在此继续启动', + 'machine.health.status.unknown': '指标不可用', + 'machine.health.metric.cpu': '全部核心的 CPU', + 'machine.health.metric.cpuWithCount': '全部 {n} 个核心的 CPU', + 'machine.health.metric.ram': '内存占用', + 'machine.health.tooltip.load': '运行队列 (1 分钟): {value}', + 'machine.health.tooltip.loadShort': '负载 (1 分钟)', + 'machine.health.tooltip.uptimeShort': '运行时间', + 'machine.health.uptimeCompact': '已运行 {value}', + 'machine.health.tooltip.hint': '约每 20 秒由该机器上的 runner 更新。', + 'machine.health.aria.cpu': 'CPU {n}%', + 'machine.health.aria.ram': '内存 {n}%', + 'machine.health.aria.unknown': '机器健康数据不可用', // Chat 'chat.placeholder': '输入消息…', diff --git a/web/src/lib/machineHealth.test.ts b/web/src/lib/machineHealth.test.ts new file mode 100644 index 0000000000..9770d61a2e --- /dev/null +++ b/web/src/lib/machineHealth.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest' +import { + formatMachineUptimeSeconds, + presentMachineHealth, + resolveMachineOsLabel, + shouldShowMachineHostSubtitle, + getCpuMetricTooltipLabel, +} from './machineHealth' + +describe('presentMachineHealth', () => { + it('builds cpu and ram metrics for visual meters', () => { + const result = presentMachineHealth({ + collectedAt: Date.now(), + load1m: 2.4, + cpuCount: 8, + cpuPercent: 72, + memoryPercent: 81 + }, 'linux') + + expect(result?.metrics).toEqual([ + { id: 'cpu', shortLabel: 'CPU', percent: 72, tone: 'ok' }, + { id: 'ram', shortLabel: 'RAM', percent: 81, tone: 'warn' } + ]) + expect(result?.overallTone).toBe('warn') + expect(result?.status).toBe('elevated') + expect(result?.loadDetail).toBe('2.4/8') + expect(result?.cpuCount).toBe(8) + }) + + it('marks high pressure when ram is critical', () => { + const result = presentMachineHealth({ + collectedAt: Date.now(), + cpuPercent: 42, + memoryPercent: 93 + }, 'linux') + + expect(result?.overallTone).toBe('critical') + expect(result?.status).toBe('high') + }) + + it('returns null when health is missing', () => { + expect(presentMachineHealth(null, 'linux')).toBeNull() + }) + + it('formats uptime for tooltip and tile meta', () => { + const result = presentMachineHealth({ + collectedAt: Date.now(), + cpuPercent: 10, + memoryPercent: 20, + uptimeSeconds: 6_540, + }, 'linux') + + expect(result?.uptimeDetail).toBe('1h 49m') + }) +}) + +describe('formatMachineUptimeSeconds', () => { + it('formats days, hours, minutes, and seconds', () => { + expect(formatMachineUptimeSeconds(90_000)).toBe('1d 1h') + expect(formatMachineUptimeSeconds(3_720)).toBe('1h 2m') + expect(formatMachineUptimeSeconds(300)).toBe('5m') + expect(formatMachineUptimeSeconds(45)).toBe('45s') + }) +}) + +describe('resolveMachineOsLabel', () => { + it('maps known platforms to i18n keys', () => { + expect(resolveMachineOsLabel('win32')).toEqual({ kind: 'i18n', key: 'machine.os.windows' }) + expect(resolveMachineOsLabel('linux')).toEqual({ kind: 'i18n', key: 'machine.os.linux' }) + expect(resolveMachineOsLabel('darwin')).toEqual({ kind: 'i18n', key: 'machine.os.macos' }) + }) + + it('falls back to raw platform string when unknown', () => { + expect(resolveMachineOsLabel('freebsd')).toEqual({ kind: 'raw', value: 'freebsd' }) + }) +}) + +describe('getCpuMetricTooltipLabel', () => { + const t = (key: string, params?: Record) => { + if (key === 'machine.health.metric.cpuWithCount') { + return `CPU across all ${params?.n} cores` + } + return 'CPU across all cores' + } + + it('includes core count when known', () => { + expect(getCpuMetricTooltipLabel(6, t)).toBe('CPU across all 6 cores') + }) + + it('falls back when core count is missing', () => { + expect(getCpuMetricTooltipLabel(undefined, t)).toBe('CPU across all cores') + }) +}) +describe('shouldShowMachineHostSubtitle', () => { + it('hides host when it matches the display label', () => { + expect(shouldShowMachineHostSubtitle('Teemo', 'Teemo')).toBe(false) + }) + + it('shows host when it differs from the display label', () => { + expect(shouldShowMachineHostSubtitle('f9bb3c9e', 'proxmox')).toBe(true) + }) +}) diff --git a/web/src/lib/machineHealth.ts b/web/src/lib/machineHealth.ts new file mode 100644 index 0000000000..24b074a7bf --- /dev/null +++ b/web/src/lib/machineHealth.ts @@ -0,0 +1,198 @@ +import type { Machine, MachineHealth } from '@/types/api' + +export type MachineHealthTone = 'ok' | 'warn' | 'critical' | 'unknown' + +export type MachineHealthMetricPresentation = { + id: 'cpu' | 'ram' + shortLabel: 'CPU' | 'RAM' + percent: number + tone: MachineHealthTone +} + +export type MachineHealthPresentation = { + metrics: MachineHealthMetricPresentation[] + overallTone: MachineHealthTone + loadDetail?: string + uptimeDetail?: string + cpuCount?: number + status: 'healthy' | 'elevated' | 'high' | 'unknown' +} + +/** Compact uptime for sidebar tiles, e.g. 1h 54m, 2d 4h, 5m. */ +export function formatMachineUptimeSeconds(totalSeconds: number): string | null { + if (!Number.isFinite(totalSeconds) || totalSeconds < 0) { + return null + } + + const seconds = Math.floor(totalSeconds) + const days = Math.floor(seconds / 86_400) + const hours = Math.floor((seconds % 86_400) / 3_600) + const minutes = Math.floor((seconds % 3_600) / 60) + + if (days > 0) { + return `${days}d ${hours}h` + } + if (hours > 0) { + return `${hours}h ${minutes}m` + } + if (minutes > 0) { + return `${minutes}m` + } + return `${seconds}s` +} + +function formatLoad(load1m: number, cpuCount?: number): string { + if (cpuCount && cpuCount > 0) { + return `${load1m.toFixed(1)}/${cpuCount}` + } + return load1m.toFixed(1) +} + +function loadTone(load1m: number, cpuCount?: number): MachineHealthTone { + const cores = cpuCount && cpuCount > 0 ? cpuCount : 1 + const ratio = load1m / cores + if (ratio >= 1.5) return 'critical' + if (ratio >= 1) return 'warn' + return 'ok' +} + +function percentTone(value: number): MachineHealthTone { + if (value >= 90) return 'critical' + if (value >= 75) return 'warn' + return 'ok' +} + +function worstTone(...tones: MachineHealthTone[]): MachineHealthTone { + if (tones.includes('critical')) return 'critical' + if (tones.includes('warn')) return 'warn' + if (tones.includes('unknown')) return 'unknown' + return 'ok' +} + +function statusFromTone(tone: MachineHealthTone): MachineHealthPresentation['status'] { + if (tone === 'critical') return 'high' + if (tone === 'warn') return 'elevated' + if (tone === 'ok') return 'healthy' + return 'unknown' +} + +export function presentMachineHealth( + health: MachineHealth | null | undefined, + platform?: string | null +): MachineHealthPresentation | null { + if (!health) { + return null + } + + const metrics: MachineHealthMetricPresentation[] = [] + const tones: MachineHealthTone[] = [] + + if (health.cpuPercent !== undefined) { + const tone = percentTone(health.cpuPercent) + metrics.push({ + id: 'cpu', + shortLabel: 'CPU', + percent: health.cpuPercent, + tone + }) + tones.push(tone) + } + + if (health.memoryPercent !== undefined) { + const tone = percentTone(health.memoryPercent) + metrics.push({ + id: 'ram', + shortLabel: 'RAM', + percent: health.memoryPercent, + tone + }) + tones.push(tone) + } + + const loadDetail = health.load1m !== undefined && platform !== 'win32' + ? formatLoad(health.load1m, health.cpuCount) + : undefined + const uptimeDetail = health.uptimeSeconds !== undefined + ? formatMachineUptimeSeconds(health.uptimeSeconds) + : undefined + + if (loadDetail !== undefined) { + tones.push(loadTone(health.load1m!, health.cpuCount)) + } + + if (metrics.length === 0 && loadDetail === undefined && uptimeDetail === undefined) { + return { + metrics: [], + overallTone: 'unknown', + status: 'unknown' + } + } + + const overallTone = metrics.length > 0 ? worstTone(...tones) : loadTone(health.load1m!, health.cpuCount) + + return { + metrics, + overallTone, + loadDetail, + uptimeDetail: uptimeDetail ?? undefined, + cpuCount: health.cpuCount, + status: statusFromTone(overallTone) + } +} + +export function getCpuMetricTooltipLabel( + cpuCount: number | undefined, + t: (key: string, params?: Record) => string +): string { + if (cpuCount !== undefined && cpuCount > 0) { + return t('machine.health.metric.cpuWithCount', { n: cpuCount }) + } + return t('machine.health.metric.cpu') +} + +export function getMachinePlatform(machine: Machine | null | undefined): string | null { + return machine?.metadata?.platform ?? null +} + +export function getMachineHost(machine: Machine | null | undefined): string | null { + return machine?.metadata?.host ?? null +} + +export type MachineOsLabel = + | { kind: 'i18n'; key: 'machine.os.windows' | 'machine.os.linux' | 'machine.os.macos' | 'machine.os.unknown' } + | { kind: 'raw'; value: string } + +export function resolveMachineOsLabel(platform: string | null | undefined): MachineOsLabel { + switch (platform) { + case 'win32': + return { kind: 'i18n', key: 'machine.os.windows' } + case 'linux': + return { kind: 'i18n', key: 'machine.os.linux' } + case 'darwin': + return { kind: 'i18n', key: 'machine.os.macos' } + default: + if (platform?.trim()) { + return { kind: 'raw', value: platform.trim() } + } + return { kind: 'i18n', key: 'machine.os.unknown' } + } +} + +export function shouldShowMachineHostSubtitle(label: string, host: string | null | undefined): boolean { + if (!host?.trim()) return false + return host.trim().toLowerCase() !== label.trim().toLowerCase() +} + +export const MACHINE_HEALTH_BAR_FILL_CLASS: Record = { + ok: 'bg-[var(--app-link)]/70', + warn: 'bg-[var(--app-badge-warning-text)]', + critical: 'bg-[var(--app-badge-error-text)]', + unknown: 'bg-[var(--app-hint)]/50' +} + +export const MACHINE_HEALTH_CHIP_CLASS: Record = { + ok: 'border-[var(--app-border)] bg-[var(--app-subtle-bg)]/80', + warn: 'border-[var(--app-badge-warning-border)] bg-[var(--app-badge-warning-bg)]/40', + critical: 'border-[var(--app-badge-error-border)] bg-[var(--app-badge-error-bg)]/40', + unknown: 'border-[var(--app-border)] bg-[var(--app-subtle-bg)]/60 opacity-70' +} diff --git a/web/src/router.tsx b/web/src/router.tsx index 84222fef70..ec6ea6f69b 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -190,6 +190,13 @@ function SessionsPage() { } return labels }, [machines]) + const machinesById = useMemo(() => { + const byId: Record = {} + for (const machine of machines) { + byId[machine.id] = machine + } + return byId + }, [machines]) const sessionMatch = matchRoute({ to: '/sessions/$sessionId', fuzzy: true }) const selectedSessionId = sessionMatch && sessionMatch.sessionId !== 'new' ? sessionMatch.sessionId : null const selectedSession = useMemo( @@ -531,6 +538,7 @@ function SessionsPage() { renderHeader={false} api={api} machineLabelsById={machineLabelsById} + machinesById={machinesById} />
diff --git a/web/src/types/api.ts b/web/src/types/api.ts index 89846a75b2..bee38fcf72 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -46,6 +46,7 @@ export type { Metadata, PermissionMode, Machine, + MachineHealth, PendingRequest, PendingRequestKind, RunnerState, From b44885ae676652db2905e8cab6d8331a67adad6e Mon Sep 17 00:00:00 2001 From: SSU-WEI HUANG Date: Mon, 29 Jun 2026 11:42:41 +0800 Subject: [PATCH 05/31] feat(gemini): remove launchable Gemini CLI agent, keep old sessions readable (#953) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(gemini): remove launchable Gemini CLI agent, keep sessions readable Google sunset the consumer Gemini CLI (Pro/Ultra/free tiers stopped serving requests 2026-06-18). This removes the ability to launch/create Gemini CLI sessions while keeping existing stored Gemini sessions fully readable in the web UI. Removed (no longer launchable): - cli/src/gemini/ runtime (runGemini, loop, local/remote launchers, session, ACP backend, config, scanner) + GeminiDisplay ink view - `hapi gemini` command + registry entry + usage line - runner spawn branch & buildCliArgs mapping now reject gemini with a clear error; resume dispatch throws a clear "no longer supported" error - gemini dropped from the new-session agent selector via new CREATABLE_AGENT_FLAVORS, and from preferred-agent defaults Kept (read path — existing sessions still validate, load, render): - `gemini` in AGENT_FLAVORS / AgentFlavorSchema, FLAVOR_CAPS / FLAVOR_LABELS - AgentFlavorIcon badge, model-option labels, ACP message normalization, metadata.geminiSessionId, hub session dedup/resume-id Note: the Gemini *Live voice* backend is a separate feature and is untouched. Adds read-guarantee tests (stored gemini validates; excluded from creatable). typecheck + full suite green. via [HAPI](https://hapi.run) Co-Authored-By: HAPI Co-Authored-By: Claude Opus 4.8 * fix(gemini): reject gemini resume before handoff (#953 review) HAPI Bot [Major]: `hapi resume ` called handoffSessionToLocal() — which tells the running remote agent to exit — before reaching the gemini-unsupported throw in dispatchLocalResume, so it could stop the live/readable session and then fail locally. Move the gemini guard into resumeCommand.run before the handoff, so an active Gemini session is left running/readable instead of being stopped. Keep the dispatch-layer guard as defense-in-depth. Adds a regression test asserting handoffSessionToLocal is not called for an active gemini target. via [HAPI](https://hapi.run) Co-Authored-By: HAPI Co-Authored-By: Claude Opus 4.8 * fix(gemini): harden against stale gemini input (#953 review) Two [Minor] follow-ups from HAPI Bot: - newSessionFormDraft: coerce a restored browse draft's agent to a creatable flavor, so a pre-removal 'gemini' draft cannot submit agent:'gemini' even though the selector no longer offers it. - buildCliArgs: reject 'gemini' explicitly instead of silently falling through to the 'claude' command if the exported helper is reused outside the guarded spawnSession path. Updated the buildCliArgs precedence test to a creatable agent and added a test asserting buildCliArgs('gemini') throws. via [HAPI](https://hapi.run) Co-Authored-By: HAPI Co-Authored-By: Claude Opus 4.8 * fix(gemini): reset dependent draft fields when coercing stale agent (#953 review) Follow-up [Minor]: coercing a stale gemini draft's agent to claude left model/base/effort untouched, so a { agent:'gemini', model:'gemini-2.5-pro' } draft restored as claude *with* a Gemini model, which handleCreate() then sent to the runner. Now reset model / cursorSelectedBase / effort / modelReasoningEffort to defaults whenever the agent is coerced. Adds a regression test. via [HAPI](https://hapi.run) Co-Authored-By: HAPI Co-Authored-By: Claude Opus 4.8 * fix(gemini): tombstone `hapi gemini` so it errors clearly (#953 review) HAPI Bot [Major]: after removing geminiCommand from the registry, resolveCommand() treats `gemini` as an unknown subcommand and falls through to the default Claude command (forwarding "gemini" as an arg), so `hapi gemini` silently started Claude instead of reporting the sunset. Add an explicit tombstone `gemini` command that prints the sunset error and exits 1. via [HAPI](https://hapi.run) Co-Authored-By: HAPI Co-Authored-By: Claude Opus 4.8 * test(web): assert AgentSelector hides the sunset Gemini agent (#953) Render regression test confirming the new-session AgentSelector offers exactly CREATABLE_AGENT_FLAVORS and never shows a Gemini radio. via [HAPI](https://hapi.run) Co-Authored-By: HAPI Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: HAPI Co-authored-by: Claude Opus 4.8 --- cli/src/commands/claude.ts | 1 - cli/src/commands/gemini.ts | 30 -- cli/src/commands/registry.ts | 20 +- cli/src/commands/resume.test.ts | 31 ++ cli/src/commands/resume.ts | 21 +- cli/src/gemini/geminiLocal.ts | 51 --- cli/src/gemini/geminiLocalLauncher.ts | 104 ------ cli/src/gemini/geminiRemoteLauncher.test.ts | 235 ------------- cli/src/gemini/geminiRemoteLauncher.ts | 306 ----------------- cli/src/gemini/loop.ts | 69 ---- cli/src/gemini/runGemini.test.ts | 308 ------------------ cli/src/gemini/runGemini.ts | 199 ----------- cli/src/gemini/session.ts | 100 ------ cli/src/gemini/types.ts | 8 - cli/src/gemini/utils/config.ts | 143 -------- cli/src/gemini/utils/geminiBackend.ts | 50 --- cli/src/gemini/utils/permissionHandler.ts | 170 ---------- cli/src/gemini/utils/sessionScanner.ts | 176 ---------- cli/src/runner/buildCliArgs.test.ts | 6 +- cli/src/runner/run.ts | 22 +- cli/src/ui/ink/GeminiDisplay.tsx | 187 ----------- shared/src/modes.test.ts | 21 ++ shared/src/modes.ts | 8 + .../NewSession/AgentSelector.test.tsx | 28 ++ .../components/NewSession/AgentSelector.tsx | 4 +- .../NewSession/newSessionFormDraft.test.ts | 25 ++ .../NewSession/newSessionFormDraft.ts | 23 +- web/src/components/NewSession/preferences.ts | 6 +- 28 files changed, 182 insertions(+), 2170 deletions(-) delete mode 100644 cli/src/commands/gemini.ts delete mode 100644 cli/src/gemini/geminiLocal.ts delete mode 100644 cli/src/gemini/geminiLocalLauncher.ts delete mode 100644 cli/src/gemini/geminiRemoteLauncher.test.ts delete mode 100644 cli/src/gemini/geminiRemoteLauncher.ts delete mode 100644 cli/src/gemini/loop.ts delete mode 100644 cli/src/gemini/runGemini.test.ts delete mode 100644 cli/src/gemini/runGemini.ts delete mode 100644 cli/src/gemini/session.ts delete mode 100644 cli/src/gemini/types.ts delete mode 100644 cli/src/gemini/utils/config.ts delete mode 100644 cli/src/gemini/utils/geminiBackend.ts delete mode 100644 cli/src/gemini/utils/permissionHandler.ts delete mode 100644 cli/src/gemini/utils/sessionScanner.ts delete mode 100644 cli/src/ui/ink/GeminiDisplay.tsx create mode 100644 web/src/components/NewSession/AgentSelector.test.tsx diff --git a/cli/src/commands/claude.ts b/cli/src/commands/claude.ts index 6b72a51776..367a0d12d6 100644 --- a/cli/src/commands/claude.ts +++ b/cli/src/commands/claude.ts @@ -88,7 +88,6 @@ ${chalk.bold('Usage:')} hapi auth Manage authentication hapi codex Start Codex mode hapi cursor Start Cursor Agent mode - hapi gemini Start Gemini ACP mode hapi opencode Start OpenCode ACP mode hapi resume [id] Resume an existing HAPI session locally hapi mcp Start MCP stdio bridge diff --git a/cli/src/commands/gemini.ts b/cli/src/commands/gemini.ts deleted file mode 100644 index fb7e9bfaa3..0000000000 --- a/cli/src/commands/gemini.ts +++ /dev/null @@ -1,30 +0,0 @@ -import chalk from 'chalk' -import { authAndSetupMachineIfNeeded } from '@/ui/auth' -import { initializeToken } from '@/ui/tokenInit' -import { maybeAutoStartServer } from '@/utils/autoStartServer' -import type { CommandDefinition } from './types' -import { GEMINI_PERMISSION_MODES } from '@hapi/protocol/modes' -import { parseRemoteAgentCommandOptions } from './agentCommandOptions' - -export const geminiCommand: CommandDefinition = { - name: 'gemini', - requiresRuntimeAssets: true, - run: async ({ commandArgs }) => { - try { - const options = parseRemoteAgentCommandOptions(commandArgs, GEMINI_PERMISSION_MODES) - - await initializeToken() - await maybeAutoStartServer() - await authAndSetupMachineIfNeeded() - - const { runGemini } = await import('@/gemini/runGemini') - await runGemini(options) - } catch (error) { - console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error') - if (process.env.DEBUG) { - console.error(error) - } - process.exit(1) - } - } -} diff --git a/cli/src/commands/registry.ts b/cli/src/commands/registry.ts index 7e93ca4fca..358281dfc8 100644 --- a/cli/src/commands/registry.ts +++ b/cli/src/commands/registry.ts @@ -1,3 +1,4 @@ +import chalk from 'chalk' import { authCommand } from './auth' import { claudeCommand } from './claude' import { codexCommand } from './codex' @@ -6,7 +7,6 @@ import { connectCommand } from './connect' import { runnerCommand } from './runner' import { resumeCommand } from './resume' import { doctorCommand } from './doctor' -import { geminiCommand } from './gemini' import { kimiCommand } from './kimi' import { opencodeCommand } from './opencode' import { piCommand } from './pi' @@ -16,12 +16,28 @@ import { notifyCommand } from './notify' import { hubCommand } from './hub' import type { CommandContext, CommandDefinition } from './types' +// Gemini CLI was sunset (Google stopped serving the consumer Gemini CLI on +// 2026-06-18) so the agent is no longer launchable. Keep an explicit tombstone +// command so `hapi gemini` reports a clear error instead of falling through to +// the default Claude command with "gemini" as a forwarded argument. +const removedGeminiCommand: CommandDefinition = { + name: 'gemini', + requiresRuntimeAssets: false, + run: async () => { + console.error( + chalk.red('Error:'), + 'Gemini CLI is no longer supported and cannot be launched (Google sunset the consumer Gemini CLI on 2026-06-18). Existing Gemini sessions remain viewable in the web UI.' + ) + process.exit(1) + } +} + const COMMANDS: CommandDefinition[] = [ authCommand, connectCommand, codexCommand, cursorCommand, - geminiCommand, + removedGeminiCommand, kimiCommand, opencodeCommand, piCommand, diff --git a/cli/src/commands/resume.test.ts b/cli/src/commands/resume.test.ts index 73b0de0a2a..4c3c709f2f 100644 --- a/cli/src/commands/resume.test.ts +++ b/cli/src/commands/resume.test.ts @@ -142,6 +142,37 @@ describe('resumeCommand', () => { }) }) + it('rejects an active Gemini target before handoff (no longer supported, leaves running session alone)', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`process.exit:${code ?? 'undefined'}`) + }) as never) + + getLocalResumeTargetMock.mockResolvedValue({ + sessionId: 'hapi-session-gemini', + flavor: 'gemini', + directory: '/tmp/project', + machineId: 'machine-1', + active: true, + thinking: false, + controlledByUser: false, + agentSessionId: 'gemini-conv-1', + model: 'gemini-2.5-pro', + permissionMode: 'default' + }) + + try { + await expect(resumeCommand.run(createContext(['hapi-session-gemini']))).rejects.toThrow('process.exit:1') + // Regression (#953): the gemini guard must fire BEFORE handoff so an + // active Gemini session is not stopped and then left failing locally. + expect(handoffSessionToLocalMock).not.toHaveBeenCalled() + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.any(String), expect.stringContaining('no longer supported')) + } finally { + consoleErrorSpy.mockRestore() + exitSpy.mockRestore() + } + }) + it('fails before launching when the target belongs to another machine', async () => { const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { diff --git a/cli/src/commands/resume.ts b/cli/src/commands/resume.ts index cf2af593fd..146189d6e9 100644 --- a/cli/src/commands/resume.ts +++ b/cli/src/commands/resume.ts @@ -7,7 +7,6 @@ import type { ClaudePermissionMode, CodexPermissionMode, CursorPermissionMode, - GeminiPermissionMode, KimiPermissionMode, OpencodePermissionMode } from '@hapi/protocol/types' @@ -104,17 +103,7 @@ async function dispatchLocalResume(target: LocalResumeTarget): Promise { } if (target.flavor === 'gemini') { - const { runGemini } = await import('@/gemini/runGemini') - await runGemini({ - existingSessionId: base.existingSessionId, - workingDirectory: base.workingDirectory, - resumeSessionId: base.resumeSessionId, - startedBy: base.startedBy, - permissionMode: base.permissionMode as GeminiPermissionMode | undefined, - startingMode: 'local', - model: target.model ?? undefined - }) - return + throw new Error('Gemini CLI is no longer supported and cannot be resumed (Google sunset the consumer Gemini CLI on 2026-06-18). The session history remains viewable in the web UI.') } if (target.flavor === 'opencode') { @@ -209,6 +198,14 @@ export const resumeCommand: CommandDefinition = { assertTargetMachine(target, machineId) assertDirectoryExists(target) + // Gemini CLI is no longer launchable (Google sunset the consumer + // Gemini CLI on 2026-06-18). Reject BEFORE the handoff below so an + // active Gemini session is left running/readable rather than being + // stopped by handoffSessionToLocal and then failing locally. + if (target.flavor === 'gemini') { + throw new Error('Gemini CLI is no longer supported and cannot be resumed (Google sunset the consumer Gemini CLI on 2026-06-18). The session history remains viewable in the web UI.') + } + if (target.active && target.controlledByUser) { throw new Error('Session is already controlled by a local terminal') } diff --git a/cli/src/gemini/geminiLocal.ts b/cli/src/gemini/geminiLocal.ts deleted file mode 100644 index 551145df7f..0000000000 --- a/cli/src/gemini/geminiLocal.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { logger } from '@/ui/logger'; -import { spawnWithTerminalGuard } from '@/utils/spawnWithTerminalGuard'; - -export async function geminiLocal(opts: { - path: string; - sessionId: string | null; - abort: AbortSignal; - model?: string; - approvalMode?: string; - allowedTools?: string[]; - hookSettingsPath?: string; -}): Promise { - const args: string[] = []; - - if (opts.sessionId) { - args.push('--resume', opts.sessionId); - } - if (opts.model) { - args.push('--model', opts.model); - } - if (opts.approvalMode) { - args.push('--approval-mode', opts.approvalMode); - } - if (opts.allowedTools && opts.allowedTools.length > 0) { - args.push('--allowed-tools', ...opts.allowedTools); - } - - const env: NodeJS.ProcessEnv = { - ...process.env, - GEMINI_PROJECT_DIR: opts.path - }; - if (opts.hookSettingsPath) { - env.GEMINI_CLI_SYSTEM_SETTINGS_PATH = opts.hookSettingsPath; - } - - logger.debug(`[GeminiLocal] Spawning gemini with args: ${JSON.stringify(args)}`); - - await spawnWithTerminalGuard({ - command: 'gemini', - args, - cwd: opts.path, - env, - signal: opts.abort, - shell: process.platform === 'win32', - logLabel: 'GeminiLocal', - spawnName: 'gemini', - installHint: 'Gemini CLI', - includeCause: true, - logExit: true - }); -} diff --git a/cli/src/gemini/geminiLocalLauncher.ts b/cli/src/gemini/geminiLocalLauncher.ts deleted file mode 100644 index adfb8f25a4..0000000000 --- a/cli/src/gemini/geminiLocalLauncher.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { geminiLocal } from './geminiLocal'; -import { GeminiSession } from './session'; -import { createGeminiSessionScanner } from './utils/sessionScanner'; -import type { PermissionMode } from './types'; -import { randomUUID } from 'node:crypto'; -import { BaseLocalLauncher } from '@/modules/common/launcher/BaseLocalLauncher'; - -type GeminiScannerHandle = Awaited>; - -function mapApprovalMode(mode: PermissionMode | undefined): string | undefined { - if (!mode || mode === 'default' || mode === 'read-only') { - return 'default'; - } - if (mode === 'safe-yolo') { - return 'auto_edit'; - } - return 'yolo'; -} - -export async function geminiLocalLauncher( - session: GeminiSession, - opts: { - model?: string; - allowedTools?: string[]; - hookSettingsPath?: string; - } -): Promise<'switch' | 'exit'> { - const launcher = new BaseLocalLauncher({ - label: 'gemini-local', - failureLabel: 'Local Gemini process failed', - queue: session.queue, - rpcHandlerManager: session.client.rpcHandlerManager, - startedBy: session.startedBy, - startingMode: session.startingMode, - launch: async (abortSignal) => { - await geminiLocal({ - path: session.path, - sessionId: session.sessionId, - abort: abortSignal, - model: opts.model, - approvalMode: mapApprovalMode(session.getPermissionMode() as PermissionMode | undefined), - allowedTools: opts.allowedTools, - hookSettingsPath: opts.hookSettingsPath - }); - }, - sendFailureMessage: (message) => { - session.sendSessionEvent({ type: 'message', message }); - }, - recordLocalLaunchFailure: (message, exitReason) => { - session.recordLocalLaunchFailure(message, exitReason); - } - }); - - let scanner: GeminiScannerHandle | null = null; - - const handleTranscriptMessage = (message: { type?: string; content?: string }) => { - if (message.type === 'user' && typeof message.content === 'string') { - session.sendUserMessage(message.content); - return; - } - if (message.type === 'gemini' && typeof message.content === 'string') { - session.sendAgentMessage({ - type: 'message', - message: message.content, - id: randomUUID() - }); - } - }; - - const ensureScanner = async (transcriptPath: string): Promise => { - if (scanner) { - scanner.onNewSession(transcriptPath); - return; - } - scanner = await createGeminiSessionScanner({ - transcriptPath, - onMessage: handleTranscriptMessage, - onSessionId: (sessionId) => session.onSessionFound(sessionId) - }); - }; - - const handleTranscriptPath = (transcriptPath: string) => { - void ensureScanner(transcriptPath); - }; - - const hadTranscriptPath = Boolean(session.transcriptPath); - if (hadTranscriptPath && session.transcriptPath) { - await ensureScanner(session.transcriptPath); - } else { - session.addTranscriptPathCallback(handleTranscriptPath); - } - - try { - return await launcher.run(); - } finally { - if (!hadTranscriptPath) { - session.removeTranscriptPathCallback(handleTranscriptPath); - } - - if (scanner !== null) { - await (scanner as GeminiScannerHandle).cleanup(); - } - } -} diff --git a/cli/src/gemini/geminiRemoteLauncher.test.ts b/cli/src/gemini/geminiRemoteLauncher.test.ts deleted file mode 100644 index 4b1aab676c..0000000000 --- a/cli/src/gemini/geminiRemoteLauncher.test.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { MessageQueue2 } from '@/utils/MessageQueue2'; -import type { GeminiMode, PermissionMode } from './types'; - -const harness = vi.hoisted(() => ({ - setModelArgs: [] as Array<{ sessionId: string; modelId: string }>, - promptCount: 0, - events: [] as string[], - setModelImpl: null as null | ((sessionId: string, modelId: string) => Promise) -})); - -vi.mock('./utils/geminiBackend', () => ({ - createGeminiBackend: vi.fn(() => ({ - initialize: vi.fn(async () => {}), - newSession: vi.fn(async () => 'acp-session-1'), - loadSession: vi.fn(async () => 'acp-session-1'), - setModel: vi.fn(async (sessionId: string, modelId: string) => { - harness.events.push(`setModel:${modelId}`); - harness.setModelArgs.push({ sessionId, modelId }); - if (harness.setModelImpl) { - await harness.setModelImpl(sessionId, modelId); - } - }), - prompt: vi.fn(async () => { - harness.events.push('prompt:start'); - harness.promptCount++; - await new Promise((resolve) => setImmediate(resolve)); - harness.events.push('prompt:end'); - }), - cancelPrompt: vi.fn(async () => {}), - respondToPermission: vi.fn(async () => {}), - onStderrError: vi.fn(), - onPermissionRequest: vi.fn(), - disconnect: vi.fn(async () => {}) - })) -})); - -vi.mock('@/codex/utils/buildHapiMcpBridge', () => ({ - buildHapiMcpBridge: async () => ({ - server: { stop: () => {} }, - mcpServers: {} - }) -})); - -vi.mock('./utils/permissionHandler', () => ({ - GeminiPermissionHandler: class { - async cancelAll(): Promise {} - } -})); - -vi.mock('./utils/config', () => ({ - resolveGeminiRuntimeConfig: () => ({ - model: 'gemini-3-flash-preview', - modelSource: 'default' - }) -})); - -vi.mock('@/ui/ink/GeminiDisplay', () => ({ - GeminiDisplay: () => null -})); - -vi.mock('@/ui/logger', () => ({ - logger: { - debug: vi.fn(), - warn: vi.fn(), - info: vi.fn() - } -})); - -import { geminiRemoteLauncher } from './geminiRemoteLauncher'; - -function createMode(model?: string): GeminiMode { - return { - permissionMode: 'default' as PermissionMode, - model - }; -} - -function createSessionStub(items: Array<{ message: string; mode: GeminiMode }>) { - const queue = new MessageQueue2((mode) => JSON.stringify(mode)); - items.forEach(({ message, mode }, index) => { - if (index === 0 && items.length > 1) { - queue.pushIsolateAndClear(message, mode); - } else { - queue.push(message, mode); - } - }); - queue.close(); - - const sessionEvents: Array<{ type: string; [key: string]: unknown }> = []; - const rpcHandlers = new Map unknown>(); - - const client = { - rpcHandlerManager: { - registerHandler(method: string, handler: (params: unknown) => unknown) { - rpcHandlers.set(method, handler); - } - }, - sendAgentMessage(_message: unknown) {}, - sendUserMessage(_text: string) {}, - sendSessionEvent(event: { type: string; [key: string]: unknown }) { - sessionEvents.push(event); - } - }; - - const session = { - path: '/tmp/hapi-gemini-test', - logPath: '/tmp/hapi-gemini-test/test.log', - client, - queue, - sessionId: null as string | null, - thinking: false, - getPermissionMode() { - return 'default' as const; - }, - setModel(_model: string | null) {}, - onThinkingChange(thinking: boolean) { - session.thinking = thinking; - }, - onSessionFound(id: string) { - session.sessionId = id; - }, - sendAgentMessage(_message: unknown) {}, - sendSessionEvent(event: { type: string; [key: string]: unknown }) { - client.sendSessionEvent(event); - }, - sendUserMessage(_text: string) {} - }; - - return { session, sessionEvents, rpcHandlers }; -} - -describe('geminiRemoteLauncher inline model switch', () => { - afterEach(() => { - harness.setModelArgs = []; - harness.promptCount = 0; - harness.events = []; - harness.setModelImpl = null; - }); - - it('calls setModel between turns when the queued model differs from the running backend model', async () => { - const { session } = createSessionStub([ - { message: 'first', mode: createMode('gemini-3-flash-preview') }, - { message: 'second', mode: createMode('gemini-2.5-pro') } - ]); - - await geminiRemoteLauncher(session as never, {}); - - expect(harness.setModelArgs).toEqual([ - { sessionId: 'acp-session-1', modelId: 'gemini-2.5-pro' } - ]); - expect(harness.promptCount).toBe(2); - }); - - it('does not call setModel when the model is unchanged across turns', async () => { - const { session } = createSessionStub([ - { message: 'first', mode: createMode('gemini-3-flash-preview') }, - { message: 'second', mode: createMode('gemini-3-flash-preview') } - ]); - - await geminiRemoteLauncher(session as never, {}); - - expect(harness.setModelArgs).toEqual([]); - expect(harness.promptCount).toBe(2); - }); - - it('latches inline switching off after a method-not-found response and notifies the user once', async () => { - harness.setModelImpl = async () => { - throw new Error('Method not found: session/set_model'); - }; - const { session, sessionEvents } = createSessionStub([ - { message: 'first', mode: createMode('gemini-3-flash-preview') }, - { message: 'second', mode: createMode('gemini-2.5-pro') }, - { message: 'third', mode: createMode('gemini-2.5-flash') } - ]); - - await geminiRemoteLauncher(session as never, {}); - - // Only one setModel attempt — latched off after the first method-not-found - expect(harness.setModelArgs).toEqual([ - { sessionId: 'acp-session-1', modelId: 'gemini-2.5-pro' } - ]); - const unsupportedMessages = sessionEvents.filter( - (event) => - event.type === 'message' && - typeof event.message === 'string' && - event.message.includes('does not support inline model switching') - ); - expect(unsupportedMessages.length).toBe(1); - expect(harness.promptCount).toBe(3); - }); - - it('reports a transient setModel error and continues with the previous model', async () => { - let attempts = 0; - harness.setModelImpl = async () => { - attempts++; - throw new Error('Transient backend failure'); - }; - const { session, sessionEvents } = createSessionStub([ - { message: 'first', mode: createMode('gemini-3-flash-preview') }, - { message: 'second', mode: createMode('gemini-2.5-pro') } - ]); - - await geminiRemoteLauncher(session as never, {}); - - expect(attempts).toBe(1); - const failureMessages = sessionEvents.filter( - (event) => - event.type === 'message' && - typeof event.message === 'string' && - event.message.includes('Failed to switch model') - ); - expect(failureMessages.length).toBe(1); - expect(failureMessages[0]?.message).toContain('gemini-2.5-pro'); - expect(harness.promptCount).toBe(2); - }); - - it('serializes setModel after the previous prompt resolves', async () => { - const { session } = createSessionStub([ - { message: 'first', mode: createMode('gemini-3-flash-preview') }, - { message: 'second', mode: createMode('gemini-2.5-pro') } - ]); - - await geminiRemoteLauncher(session as never, {}); - - // Order must be: prompt(1) start/end → setModel → prompt(2) start/end - expect(harness.events).toEqual([ - 'prompt:start', - 'prompt:end', - 'setModel:gemini-2.5-pro', - 'prompt:start', - 'prompt:end' - ]); - }); -}); diff --git a/cli/src/gemini/geminiRemoteLauncher.ts b/cli/src/gemini/geminiRemoteLauncher.ts deleted file mode 100644 index fa6e3cb5c8..0000000000 --- a/cli/src/gemini/geminiRemoteLauncher.ts +++ /dev/null @@ -1,306 +0,0 @@ -import React from 'react'; -import { logger } from '@/ui/logger'; -import { buildHapiMcpBridge } from '@/codex/utils/buildHapiMcpBridge'; -import { convertAgentMessage } from '@/agent/messageConverter'; -import type { AgentMessage, McpServerStdio, PromptContent } from '@/agent/types'; -import { RemoteLauncherBase, type RemoteLauncherDisplayContext, type RemoteLauncherExitReason } from '@/modules/common/remote/RemoteLauncherBase'; -import { GeminiDisplay } from '@/ui/ink/GeminiDisplay'; -import type { GeminiSession } from './session'; -import type { PermissionMode } from './types'; -import { createGeminiBackend } from './utils/geminiBackend'; -import { GeminiPermissionHandler } from './utils/permissionHandler'; -import { resolveGeminiRuntimeConfig } from './utils/config'; - -class GeminiRemoteLauncher extends RemoteLauncherBase { - private readonly session: GeminiSession; - private readonly model?: string; - private readonly hookSettingsPath?: string; - private backend: ReturnType | null = null; - private permissionHandler: GeminiPermissionHandler | null = null; - private happyServer: { stop: () => void } | null = null; - private abortController = new AbortController(); - private displayModel: string | null = null; - private displayPermissionMode: PermissionMode | null = null; - private currentBackendModel: string | null = null; - private setModelSupported: boolean | undefined = undefined; - - constructor(session: GeminiSession, opts: { model?: string; hookSettingsPath?: string }) { - super(process.env.DEBUG ? session.logPath : undefined); - this.session = session; - this.model = opts.model; - this.hookSettingsPath = opts.hookSettingsPath; - } - - public async launch(): Promise { - return this.start({ - onExit: () => this.handleExitFromUi(), - onSwitchToLocal: () => this.handleSwitchFromUi() - }); - } - - protected createDisplay(context: RemoteLauncherDisplayContext): React.ReactElement { - return React.createElement(GeminiDisplay, context); - } - - protected async runMainLoop(): Promise { - const session = this.session; - const messageBuffer = this.messageBuffer; - - const { server: happyServer, mcpServers } = await buildHapiMcpBridge(session.client); - this.happyServer = happyServer; - - const runtimeConfig = resolveGeminiRuntimeConfig({ model: this.model }); - this.displayModel = runtimeConfig.model; - messageBuffer.addMessage(`[MODEL:${runtimeConfig.model}]`, 'system'); - - const backend = createGeminiBackend({ - model: runtimeConfig.model, - token: runtimeConfig.token, - hookSettingsPath: this.hookSettingsPath, - cwd: session.path, - permissionMode: session.getPermissionMode() as string | undefined - }); - this.backend = backend; - - backend.onStderrError((error) => { - logger.debug('[gemini-remote] stderr error', error); - session.sendSessionEvent({ type: 'message', message: error.message }); - messageBuffer.addMessage(error.message, 'status'); - }); - - await backend.initialize(); - - const resumeSessionId = session.sessionId; - const acpMcpServers = toAcpMcpServers(mcpServers); - let acpSessionId: string; - if (resumeSessionId) { - try { - acpSessionId = await backend.loadSession({ - sessionId: resumeSessionId, - cwd: session.path, - mcpServers: acpMcpServers - }); - } catch (error) { - logger.warn('[gemini-remote] resume failed, starting new session', error); - session.sendSessionEvent({ - type: 'message', - message: 'Gemini resume failed; starting a new session.' - }); - acpSessionId = await backend.newSession({ - cwd: session.path, - mcpServers: acpMcpServers - }); - } - } else { - acpSessionId = await backend.newSession({ - cwd: session.path, - mcpServers: acpMcpServers - }); - } - session.onSessionFound(acpSessionId); - - this.permissionHandler = new GeminiPermissionHandler( - session.client, - backend, - () => session.getPermissionMode() as PermissionMode | undefined - ); - this.currentBackendModel = runtimeConfig.model; - this.applyDisplayMode(session.getPermissionMode() as PermissionMode, this.currentBackendModel); - - this.setupAbortHandlers(session.client.rpcHandlerManager, { - onAbort: () => this.handleAbort(), - onSwitch: () => this.handleSwitchRequest() - }); - - const sendReady = () => { - session.sendSessionEvent({ type: 'ready' }); - }; - - while (!this.shouldExit) { - const batch = await session.queue.waitForMessagesAndGetAsString(this.abortController.signal); - if (!batch) { - if (this.abortController.signal.aborted && !this.shouldExit) { - continue; - } - break; - } - - // Inline model change via RPC. If the running gemini-cli build does not - // implement session/set_model, we learn that from the first method-not-found - // response and stop attempting it for the rest of this session. - if (batch.mode.model && batch.mode.model !== this.currentBackendModel) { - if (!backend.setModel || this.setModelSupported === false) { - batch.mode.model = this.currentBackendModel!; - } else { - logger.debug(`[gemini-remote] Switching model inline: ${this.currentBackendModel} -> ${batch.mode.model}`); - try { - await backend.setModel(acpSessionId, batch.mode.model); - this.currentBackendModel = batch.mode.model; - this.setModelSupported = true; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - const methodNotFound = /method not found/i.test(message); - if (methodNotFound && this.setModelSupported === undefined) { - this.setModelSupported = false; - logger.warn('[gemini-remote] Gemini CLI build does not support session/set_model; inline switching disabled for this session'); - session.sendSessionEvent({ - type: 'message', - message: 'This Gemini CLI build does not support inline model switching. Restart the session to apply a different model.' - }); - } else { - logger.warn('[gemini-remote] Inline model switch failed', error); - session.sendSessionEvent({ - type: 'message', - message: `Failed to switch model to ${batch.mode.model}. Continuing with ${this.currentBackendModel}.` - }); - } - batch.mode.model = this.currentBackendModel!; - } - } - } - - this.applyDisplayMode(batch.mode.permissionMode, batch.mode.model); - messageBuffer.addMessage(batch.message, 'user'); - - const promptContent: PromptContent[] = [{ - type: 'text', - text: batch.message - }]; - - session.onThinkingChange(true); - - try { - await backend.prompt(acpSessionId, promptContent, (message: AgentMessage) => { - this.handleAgentMessage(message); - }); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - logger.warn('[gemini-remote] prompt failed', { message: errorMessage }); - session.sendSessionEvent({ - type: 'message', - message: `Gemini prompt failed: ${errorMessage}` - }); - messageBuffer.addMessage(`Gemini prompt failed: ${errorMessage}`, 'status'); - } finally { - session.onThinkingChange(false); - await this.permissionHandler?.cancelAll('Prompt finished'); - if (session.queue.size() === 0 && !this.shouldExit) { - sendReady(); - } - } - } - } - - protected async cleanup(): Promise { - this.clearAbortHandlers(this.session.client.rpcHandlerManager); - - if (this.permissionHandler) { - await this.permissionHandler.cancelAll('Session ended'); - this.permissionHandler = null; - } - - if (this.backend) { - await this.backend.disconnect(); - this.backend = null; - } - - if (this.happyServer) { - this.happyServer.stop(); - this.happyServer = null; - } - } - - private handleAgentMessage(message: AgentMessage): void { - const converted = convertAgentMessage(message); - if (converted) { - this.session.sendAgentMessage(converted); - } - - switch (message.type) { - case 'text': - this.messageBuffer.addMessage(message.text, 'assistant'); - break; - case 'reasoning': - if (message.live) { - break; - } - this.messageBuffer.addMessage(`[Thinking] ${message.text.substring(0, 100)}...`, 'system'); - break; - case 'tool_call': - this.messageBuffer.addMessage(`Tool call: ${message.name}`, 'tool'); - break; - case 'tool_result': - this.messageBuffer.addMessage('Tool result received', 'result'); - break; - case 'usage': - break; - case 'plan': - this.messageBuffer.addMessage('Plan updated', 'status'); - break; - case 'error': - this.messageBuffer.addMessage(message.message, 'status'); - break; - case 'turn_complete': - this.messageBuffer.addMessage('Turn complete', 'status'); - break; - default: { - const _exhaustive: never = message; - return _exhaustive; - } - } - } - - private applyDisplayMode(permissionMode: PermissionMode | undefined, model?: string): void { - if (permissionMode && permissionMode !== this.displayPermissionMode) { - this.displayPermissionMode = permissionMode; - this.messageBuffer.addMessage(`[MODE:${permissionMode}]`, 'system'); - } - if (model && model !== this.displayModel) { - this.displayModel = model; - this.messageBuffer.addMessage(`[MODEL:${model}]`, 'system'); - } - } - - private async handleAbort(): Promise { - const backend = this.backend; - if (backend && this.session.sessionId) { - await backend.cancelPrompt(this.session.sessionId); - } - await this.permissionHandler?.cancelAll('User aborted'); - this.session.sendSessionEvent({ type: 'message', message: 'Session aborted' }); - this.session.queue.reset(); - this.session.onThinkingChange(false); - this.abortController.abort(); - this.abortController = new AbortController(); - this.messageBuffer.addMessage('Turn aborted', 'status'); - } - - private async handleExitFromUi(): Promise { - await this.requestExit('exit', () => this.handleAbort()); - } - - private async handleSwitchFromUi(): Promise { - await this.requestExit('switch', () => this.handleAbort()); - } - - private async handleSwitchRequest(): Promise { - await this.requestExit('switch', () => this.handleAbort()); - } -} - -function toAcpMcpServers(config: Record): McpServerStdio[] { - return Object.entries(config).map(([name, entry]) => ({ - name, - command: entry.command, - args: entry.args, - env: [] - })); -} - -export async function geminiRemoteLauncher( - session: GeminiSession, - opts: { model?: string; hookSettingsPath?: string } -): Promise<'switch' | 'exit'> { - const launcher = new GeminiRemoteLauncher(session, opts); - return launcher.launch(); -} diff --git a/cli/src/gemini/loop.ts b/cli/src/gemini/loop.ts deleted file mode 100644 index 453e55261e..0000000000 --- a/cli/src/gemini/loop.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { MessageQueue2 } from '@/utils/MessageQueue2'; -import { logger } from '@/ui/logger'; -import { runLocalRemoteSession } from '@/agent/loopBase'; -import { GeminiSession } from './session'; -import { geminiLocalLauncher } from './geminiLocalLauncher'; -import { geminiRemoteLauncher } from './geminiRemoteLauncher'; -import { ApiClient, ApiSessionClient } from '@/lib'; -import type { GeminiMode, PermissionMode } from './types'; - -interface GeminiLoopOptions { - path: string; - startingMode?: 'local' | 'remote'; - startedBy?: 'runner' | 'terminal'; - onModeChange: (mode: 'local' | 'remote') => void; - messageQueue: MessageQueue2; - session: ApiSessionClient; - api: ApiClient; - permissionMode?: PermissionMode; - model?: string; - hookSettingsPath?: string; - allowedTools?: string[]; - resumeSessionId?: string; - onSessionReady?: (session: GeminiSession) => void; -} - -export async function geminiLoop(opts: GeminiLoopOptions): Promise { - const logPath = logger.getLogPath(); - const startedBy = opts.startedBy ?? 'terminal'; - const startingMode = opts.startingMode ?? 'local'; - - const session = new GeminiSession({ - api: opts.api, - client: opts.session, - path: opts.path, - sessionId: opts.resumeSessionId ?? null, - logPath, - messageQueue: opts.messageQueue, - onModeChange: opts.onModeChange, - mode: startingMode, - startedBy, - startingMode, - permissionMode: opts.permissionMode ?? 'default' - }); - - if (opts.resumeSessionId) { - session.onSessionFound(opts.resumeSessionId); - } - - const getCurrentModel = (): string | undefined => { - const sessionModel = session.getModel(); - return sessionModel != null ? sessionModel : opts.model; - }; - - await runLocalRemoteSession({ - session, - startingMode: opts.startingMode, - logTag: 'gemini-loop', - runLocal: (instance) => geminiLocalLauncher(instance, { - model: getCurrentModel(), - allowedTools: opts.allowedTools, - hookSettingsPath: opts.hookSettingsPath - }), - runRemote: (instance) => geminiRemoteLauncher(instance, { - model: getCurrentModel(), - hookSettingsPath: opts.hookSettingsPath - }), - onSessionReady: opts.onSessionReady - }); -} diff --git a/cli/src/gemini/runGemini.test.ts b/cli/src/gemini/runGemini.test.ts deleted file mode 100644 index 9b9b3be4b5..0000000000 --- a/cli/src/gemini/runGemini.test.ts +++ /dev/null @@ -1,308 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -const mockGeminiSession = vi.hoisted(() => ({ - setModel: vi.fn(), - setPermissionMode: vi.fn(), - pushKeepAlive: vi.fn(), - thinking: false, - stopKeepAlive: vi.fn() -})); - -const harness = vi.hoisted(() => ({ - bootstrapArgs: [] as Array>, - geminiLoopArgs: [] as Array>, - geminiLoopError: null as Error | null, - session: { - onUserMessage: vi.fn(), - onCancelQueuedMessage: vi.fn(), - rpcHandlerManager: { - registerHandler: vi.fn() - } - } -})); - -vi.mock('@/agent/sessionFactory', () => ({ - bootstrapSession: vi.fn(async (options: Record) => { - harness.bootstrapArgs.push(options); - return { - api: {}, - session: harness.session - }; - }) -})); - -vi.mock('./loop', () => ({ - geminiLoop: vi.fn(async (options: Record) => { - harness.geminiLoopArgs.push(options); - if (harness.geminiLoopError) { - throw harness.geminiLoopError; - } - const onSessionReady = options.onSessionReady as ((session: unknown) => void) | undefined; - if (onSessionReady) { - onSessionReady(mockGeminiSession); - } - }) -})); - -vi.mock('@/claude/registerKillSessionHandler', () => ({ - registerKillSessionHandler: vi.fn() -})); - -const lifecycleMock = vi.hoisted(() => ({ - registerProcessHandlers: vi.fn(), - cleanupAndExit: vi.fn(async () => {}), - markCrash: vi.fn(), - setExitCode: vi.fn(), - setArchiveReason: vi.fn(), - setSessionEndReason: vi.fn(), - hasExplicitSessionEndReason: vi.fn(() => false) -})); - -vi.mock('@/agent/runnerLifecycle', () => ({ - createModeChangeHandler: vi.fn(() => vi.fn()), - createRunnerLifecycle: vi.fn(() => lifecycleMock), - setControlledByUser: vi.fn() -})); - -vi.mock('@/claude/utils/startHookServer', () => ({ - startHookServer: vi.fn(async () => ({ - port: 1234, - token: 'token', - stop: vi.fn() - })) -})); - -vi.mock('@/modules/common/hooks/generateHookSettings', () => ({ - cleanupHookSettingsFile: vi.fn(), - generateHookSettingsFile: vi.fn(() => '/tmp/gemini-hooks.json') -})); - -const resolveGeminiRuntimeConfigMock = vi.hoisted(() => vi.fn()); - -vi.mock('./utils/config', () => ({ - resolveGeminiRuntimeConfig: resolveGeminiRuntimeConfigMock -})); - -vi.mock('@/ui/logger', () => ({ - logger: { - debug: vi.fn() - } -})); - -vi.mock('@/utils/attachmentFormatter', () => ({ - formatMessageWithAttachments: vi.fn((text: string) => text) -})); - -import { runGemini } from './runGemini'; - -describe('runGemini', () => { - beforeEach(() => { - harness.bootstrapArgs.length = 0; - harness.geminiLoopArgs.length = 0; - harness.geminiLoopError = null; - mockGeminiSession.setModel.mockReset(); - mockGeminiSession.setPermissionMode.mockReset(); - harness.session.onUserMessage.mockReset(); - harness.session.rpcHandlerManager.registerHandler.mockReset(); - lifecycleMock.registerProcessHandlers.mockClear(); - lifecycleMock.cleanupAndExit.mockClear(); - lifecycleMock.markCrash.mockClear(); - lifecycleMock.setExitCode.mockClear(); - lifecycleMock.setArchiveReason.mockClear(); - lifecycleMock.setSessionEndReason.mockClear(); - resolveGeminiRuntimeConfigMock.mockReset(); - }); - - it('persists a resolved config model before bootstrapping the session', async () => { - resolveGeminiRuntimeConfigMock.mockReturnValue({ - model: 'gemini-3-pro-preview', - modelSource: 'local' - }); - - await runGemini({}); - - expect(harness.bootstrapArgs[0]?.model).toBe('gemini-3-pro-preview'); - expect(harness.geminiLoopArgs[0]?.model).toBe('gemini-3-pro-preview'); - }); - - it('does not persist the hardcoded default fallback model so it floats with machine config', async () => { - resolveGeminiRuntimeConfigMock.mockReturnValue({ - model: 'gemini-3-flash-preview', - modelSource: 'default' - }); - - await runGemini({}); - - expect(harness.bootstrapArgs[0]?.model).toBeUndefined(); - expect(harness.geminiLoopArgs[0]?.model).toBe('gemini-3-flash-preview'); - }); - - it('applies model change via set-session-config RPC', async () => { - resolveGeminiRuntimeConfigMock.mockReturnValue({ - model: 'gemini-3-flash-preview', - modelSource: 'default' - }); - - await runGemini({}); - - const registerCalls = harness.session.rpcHandlerManager.registerHandler.mock.calls; - const configHandler = registerCalls.find( - (call: unknown[]) => call[0] === 'set-session-config' - ); - expect(configHandler).toBeDefined(); - - const handler = configHandler![1] as (payload: unknown) => Promise; - const result = await handler({ model: 'gemini-2.5-flash' }) as Record; - const applied = result.applied as Record; - expect(applied.model).toBe('gemini-2.5-flash'); - }); - - it('pushes a keepAlive immediately after a config change so the hub UI reflects it', async () => { - resolveGeminiRuntimeConfigMock.mockReturnValue({ - model: 'gemini-3-flash-preview', - modelSource: 'default' - }); - - await runGemini({}); - - // Reset to ignore pushKeepAlive fired from initial onSessionReady setup - mockGeminiSession.pushKeepAlive.mockClear(); - - const registerCalls = harness.session.rpcHandlerManager.registerHandler.mock.calls; - const configHandler = registerCalls.find( - (call: unknown[]) => call[0] === 'set-session-config' - ); - const handler = configHandler![1] as (payload: unknown) => Promise; - await handler({ model: 'gemini-2.5-flash' }); - - expect(mockGeminiSession.pushKeepAlive).toHaveBeenCalledTimes(1); - }); - - it('rejects invalid model in set-session-config RPC', async () => { - resolveGeminiRuntimeConfigMock.mockReturnValue({ - model: 'gemini-3-flash-preview', - modelSource: 'default' - }); - - await runGemini({}); - - const registerCalls = harness.session.rpcHandlerManager.registerHandler.mock.calls; - const configHandler = registerCalls.find( - (call: unknown[]) => call[0] === 'set-session-config' - ); - const handler = configHandler![1] as (payload: unknown) => Promise; - await expect(handler({ model: 123 })).rejects.toThrow(); - }); - - it('accepts null model (Auto) in set-session-config RPC', async () => { - resolveGeminiRuntimeConfigMock.mockReturnValue({ - model: 'gemini-3-flash-preview', - modelSource: 'default' - }); - - await runGemini({}); - - const registerCalls = harness.session.rpcHandlerManager.registerHandler.mock.calls; - const configHandler = registerCalls.find( - (call: unknown[]) => call[0] === 'set-session-config' - ); - const handler = configHandler![1] as (payload: unknown) => Promise; - const result = await handler({ model: null }) as Record; - const applied = result.applied as Record; - // null (Default) should be passed through to hub for DB clearing - expect(applied.model).toBeNull(); - }); - - it('only includes changed fields in applied response', async () => { - resolveGeminiRuntimeConfigMock.mockReturnValue({ - model: 'gemini-3-flash-preview', - modelSource: 'default' - }); - - await runGemini({}); - - const registerCalls = harness.session.rpcHandlerManager.registerHandler.mock.calls; - const configHandler = registerCalls.find( - (call: unknown[]) => call[0] === 'set-session-config' - ); - const handler = configHandler![1] as (payload: unknown) => Promise; - const result = await handler({ permissionMode: 'default' }) as Record; - const applied = result.applied as Record; - expect(applied.permissionMode).toBe('default'); - expect(applied).not.toHaveProperty('model'); - }); - - it('stores null model in session on Default selection for keepalive', async () => { - resolveGeminiRuntimeConfigMock.mockReturnValue({ - model: 'gemini-2.5-pro', - modelSource: 'default' - }); - - await runGemini({}); - - const registerCalls = harness.session.rpcHandlerManager.registerHandler.mock.calls; - const configHandler = registerCalls.find( - (call: unknown[]) => call[0] === 'set-session-config' - ); - const handler = configHandler![1] as (payload: unknown) => Promise; - - // First set an explicit model - await handler({ model: 'gemini-2.5-flash' }); - expect(mockGeminiSession.setModel).toHaveBeenLastCalledWith('gemini-2.5-flash'); - - // Then select Default (null) — session should store null, not concrete model - await handler({ model: null }); - expect(mockGeminiSession.setModel).toHaveBeenLastCalledWith(null); - }); - - it('passes machine default (not startup model) to geminiLoop for fallback', async () => { - // Session started with explicit model, but machine default differs - resolveGeminiRuntimeConfigMock.mockImplementation((opts?: { model?: string }) => { - if (opts?.model) { - return { model: opts.model, modelSource: 'explicit' }; - } - return { model: 'gemini-2.5-pro', modelSource: 'default' }; - }); - - await runGemini({ model: 'gemini-2.5-flash' }); - - // geminiLoop should receive machine default as fallback, not the explicit startup model - expect(harness.geminiLoopArgs[0]?.model).toBe('gemini-2.5-pro'); - }); - - it('passes resumeSessionId through to geminiLoop', async () => { - resolveGeminiRuntimeConfigMock.mockReturnValue({ - model: 'gemini-2.5-pro', - modelSource: 'default' - }); - - await runGemini({ resumeSessionId: 'a6157ffa-f692-4b73-82d5-63d42177f4f9' }); - - expect(harness.geminiLoopArgs[0]?.resumeSessionId).toBe('a6157ffa-f692-4b73-82d5-63d42177f4f9'); - }); - - it('does not set resumeSessionId when not provided', async () => { - resolveGeminiRuntimeConfigMock.mockReturnValue({ - model: 'gemini-2.5-pro', - modelSource: 'default' - }); - - await runGemini({}); - - expect(harness.geminiLoopArgs[0]?.resumeSessionId).toBeUndefined(); - }); - - it('preserves crash session end reason instead of overwriting it as completed', async () => { - resolveGeminiRuntimeConfigMock.mockReturnValue({ - model: 'gemini-2.5-pro', - modelSource: 'default' - }); - harness.geminiLoopError = new Error('loop failed'); - - await runGemini({}); - - expect(lifecycleMock.markCrash).toHaveBeenCalledWith(harness.geminiLoopError); - expect(lifecycleMock.setSessionEndReason).not.toHaveBeenCalledWith('completed'); - expect(lifecycleMock.cleanupAndExit).toHaveBeenCalled(); - }); -}); diff --git a/cli/src/gemini/runGemini.ts b/cli/src/gemini/runGemini.ts deleted file mode 100644 index c2e667225e..0000000000 --- a/cli/src/gemini/runGemini.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { logger } from '@/ui/logger'; -import { geminiLoop } from './loop'; -import { MessageQueue2 } from '@/utils/MessageQueue2'; -import { hashObject } from '@/utils/deterministicJson'; -import { registerKillSessionHandler } from '@/claude/registerKillSessionHandler'; -import type { AgentState } from '@/api/types'; -import type { GeminiSession } from './session'; -import type { GeminiMode, PermissionMode } from './types'; -import { bootstrapExistingSession, bootstrapSession } from '@/agent/sessionFactory'; -import { registerLocalHandoffHandler } from '@/agent/localHandoff'; -import { createModeChangeHandler, createRunnerLifecycle, setControlledByUser } from '@/agent/runnerLifecycle'; -import { startHookServer } from '@/claude/utils/startHookServer'; -import { cleanupHookSettingsFile, generateHookSettingsFile } from '@/modules/common/hooks/generateHookSettings'; -import { resolveGeminiRuntimeConfig } from './utils/config'; -import { registerSessionConfigRpc } from '@/agent/sessionConfigRpc'; -import { formatMessageWithAttachments } from '@/utils/attachmentFormatter'; -import { getInvokedCwd } from '@/utils/invokedCwd'; - -export async function runGemini(opts: { - startedBy?: 'runner' | 'terminal'; - startingMode?: 'local' | 'remote'; - permissionMode?: PermissionMode; - model?: string; - resumeSessionId?: string; - existingSessionId?: string; - workingDirectory?: string; -} = {}): Promise { - const workingDirectory = opts.workingDirectory ?? getInvokedCwd(); - const startedBy = opts.startedBy ?? 'terminal'; - - logger.debug(`[gemini] Starting with options: startedBy=${startedBy}, startingMode=${opts.startingMode}`); - - if (startedBy === 'runner' && opts.startingMode === 'local') { - logger.debug('[gemini] Runner spawn requested with local mode; forcing remote mode'); - opts.startingMode = 'remote'; - } - - const initialState: AgentState = { - controlledByUser: false - }; - - const machineDefault = resolveGeminiRuntimeConfig().model; - const runtimeConfig = resolveGeminiRuntimeConfig({ model: opts.model }); - // Persist only when the user (or env/local config) chose the model. The hardcoded - // default remains undefined in the DB so it floats with the machine config across - // gemini-cli upgrades. Mid-session selections are persisted by the hub via the - // set-session-config RPC, not by this initial bootstrap. - const persistedModel = runtimeConfig.modelSource === 'default' - ? undefined - : runtimeConfig.model; - - const bootstrap = opts.existingSessionId - ? await bootstrapExistingSession({ - sessionId: opts.existingSessionId, - flavor: 'gemini', - startedBy, - workingDirectory - }) - : await bootstrapSession({ - flavor: 'gemini', - startedBy, - workingDirectory, - agentState: initialState, - model: persistedModel - }); - const { api, session } = bootstrap; - - const startingMode: 'local' | 'remote' = opts.startingMode - ?? (startedBy === 'runner' ? 'remote' : 'local'); - - setControlledByUser(session, startingMode); - - const messageQueue = new MessageQueue2((mode) => hashObject({ - permissionMode: mode.permissionMode, - model: mode.model - })); - - const sessionWrapperRef: { current: GeminiSession | null } = { current: null }; - let currentPermissionMode: PermissionMode = opts.permissionMode ?? 'default'; - let sessionModel: string | null = persistedModel ?? null; - let resolvedModel = sessionModel ?? machineDefault; - - const hookServer = await startHookServer({ - onSessionHook: (sessionId, data) => { - logger.debug(`[gemini] Session hook received: ${sessionId}`); - const currentSession = sessionWrapperRef.current; - if (!currentSession) { - return; - } - if (currentSession.sessionId !== sessionId) { - currentSession.onSessionFound(sessionId); - } - if (typeof data.transcript_path === 'string') { - currentSession.onTranscriptPathFound(data.transcript_path); - } - } - }); - - const hookSettingsPath = generateHookSettingsFile(hookServer.port, hookServer.token, { - filenamePrefix: 'gemini-session-hook', - logLabel: 'gemini-hook-settings', - hooksEnabled: true - }); - - const lifecycle = createRunnerLifecycle({ - session, - logTag: 'gemini', - stopKeepAlive: () => sessionWrapperRef.current?.stopKeepAlive(), - onAfterClose: () => { - hookServer.stop(); - cleanupHookSettingsFile(hookSettingsPath, 'gemini-hook-settings'); - } - }); - - lifecycle.registerProcessHandlers(); - registerKillSessionHandler(session.rpcHandlerManager, lifecycle); - registerLocalHandoffHandler(session.rpcHandlerManager, lifecycle); - - const syncSessionMode = () => { - const sessionInstance = sessionWrapperRef.current; - if (!sessionInstance) { - return; - } - sessionInstance.setPermissionMode(currentPermissionMode); - sessionInstance.setModel(sessionModel); - - // Notify hub immediately to reflect changes in UI - sessionInstance.pushKeepAlive(); - - logger.debug(`[gemini] Synced session config for keepalive: permissionMode=${currentPermissionMode}, model=${resolvedModel}`); - }; - - session.onUserMessage((message, localId) => { - const formattedText = formatMessageWithAttachments(message.content.text, message.content.attachments); - const mode: GeminiMode = { - permissionMode: currentPermissionMode, - model: resolvedModel - }; - messageQueue.push(formattedText, mode, localId); - }); - - session.onCancelQueuedMessage((localId) => { - const removed = messageQueue.cancelByLocalId(localId); - logger.debug(`[gemini] cancelByLocalId(${localId}): ${removed ? 'removed' : 'not found (best-effort)'}`); - return removed; - }); - - registerSessionConfigRpc({ - rpcHandlerManager: session.rpcHandlerManager, - flavor: 'gemini', - modelMode: 'nullable', - onApply: (config) => { - if (config.permissionMode !== undefined) { - currentPermissionMode = config.permissionMode; - } - if (config.model !== undefined) { - sessionModel = config.model; - resolvedModel = sessionModel ?? machineDefault; - } - }, - onAfterApply: syncSessionMode - }); - - let crashed = false; - - try { - await geminiLoop({ - path: workingDirectory, - startingMode, - startedBy, - messageQueue, - session, - api, - permissionMode: currentPermissionMode, - model: machineDefault, - hookSettingsPath, - resumeSessionId: opts.resumeSessionId, - onModeChange: createModeChangeHandler(session), - onSessionReady: (instance) => { - sessionWrapperRef.current = instance; - syncSessionMode(); - } - }); - } catch (error) { - crashed = true; - lifecycle.markCrash(error); - logger.debug('[gemini] Loop error:', error); - } finally { - const localFailure = sessionWrapperRef.current?.localLaunchFailure; - if (localFailure?.exitReason === 'exit') { - lifecycle.setExitCode(1); - lifecycle.setArchiveReason(`Local launch failed: ${localFailure.message.slice(0, 200)}`); - lifecycle.setSessionEndReason('error'); - } else if (!crashed) { - lifecycle.setSessionEndReason('completed'); - } - await lifecycle.cleanupAndExit(); - } -} diff --git a/cli/src/gemini/session.ts b/cli/src/gemini/session.ts deleted file mode 100644 index 800d5658a1..0000000000 --- a/cli/src/gemini/session.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { ApiClient, ApiSessionClient } from '@/lib'; -import { MessageQueue2 } from '@/utils/MessageQueue2'; -import { AgentSessionBase } from '@/agent/sessionBase'; -import type { GeminiMode, PermissionMode } from './types'; -import type { LocalLaunchExitReason } from '@/agent/localLaunchPolicy'; - -type LocalLaunchFailure = { - message: string; - exitReason: LocalLaunchExitReason; -}; - -export class GeminiSession extends AgentSessionBase { - transcriptPath: string | null = null; - readonly startedBy: 'runner' | 'terminal'; - readonly startingMode: 'local' | 'remote'; - localLaunchFailure: LocalLaunchFailure | null = null; - - private transcriptPathCallbacks: Array<(path: string) => void> = []; - - constructor(opts: { - api: ApiClient; - client: ApiSessionClient; - path: string; - logPath: string; - sessionId: string | null; - messageQueue: MessageQueue2; - onModeChange: (mode: 'local' | 'remote') => void; - mode?: 'local' | 'remote'; - startedBy: 'runner' | 'terminal'; - startingMode: 'local' | 'remote'; - permissionMode?: PermissionMode; - }) { - super({ - api: opts.api, - client: opts.client, - path: opts.path, - logPath: opts.logPath, - sessionId: opts.sessionId, - messageQueue: opts.messageQueue, - onModeChange: opts.onModeChange, - mode: opts.mode, - sessionLabel: 'GeminiSession', - sessionIdLabel: 'Gemini', - applySessionIdToMetadata: (metadata, sessionId) => ({ - ...metadata, - geminiSessionId: sessionId - }), - permissionMode: opts.permissionMode - }); - - this.startedBy = opts.startedBy; - this.startingMode = opts.startingMode; - this.permissionMode = opts.permissionMode; - } - - onTranscriptPathFound(path: string): void { - if (this.transcriptPath === path) { - return; - } - this.transcriptPath = path; - for (const callback of this.transcriptPathCallbacks) { - callback(path); - } - } - - addTranscriptPathCallback(cb: (path: string) => void): void { - this.transcriptPathCallbacks.push(cb); - } - - removeTranscriptPathCallback(cb: (path: string) => void): void { - const index = this.transcriptPathCallbacks.indexOf(cb); - if (index !== -1) { - this.transcriptPathCallbacks.splice(index, 1); - } - } - - setPermissionMode = (mode: PermissionMode): void => { - this.permissionMode = mode; - }; - - setModel = (model: string | null): void => { - this.model = model; - }; - - recordLocalLaunchFailure = (message: string, exitReason: LocalLaunchExitReason): void => { - this.localLaunchFailure = { message, exitReason }; - }; - - sendAgentMessage = (message: unknown): void => { - this.client.sendAgentMessage(message); - }; - - sendUserMessage = (text: string): void => { - this.client.sendUserMessage(text); - }; - - sendSessionEvent = (event: Parameters[0]): void => { - this.client.sendSessionEvent(event); - }; -} diff --git a/cli/src/gemini/types.ts b/cli/src/gemini/types.ts deleted file mode 100644 index 9f361b5876..0000000000 --- a/cli/src/gemini/types.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { GeminiPermissionMode } from '@hapi/protocol/types'; - -export type PermissionMode = GeminiPermissionMode; - -export interface GeminiMode { - permissionMode: PermissionMode; - model?: string; -} diff --git a/cli/src/gemini/utils/config.ts b/cli/src/gemini/utils/config.ts deleted file mode 100644 index 4426f52c46..0000000000 --- a/cli/src/gemini/utils/config.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { existsSync, readFileSync } from 'node:fs'; -import { homedir } from 'node:os'; -import { join } from 'node:path'; -import { logger } from '@/ui/logger'; -import { DEFAULT_GEMINI_MODEL } from '@hapi/protocol'; - -export const GEMINI_API_KEY_ENV = 'GEMINI_API_KEY'; -export const GOOGLE_API_KEY_ENV = 'GOOGLE_API_KEY'; -export const GEMINI_MODEL_ENV = 'GEMINI_MODEL'; -export { DEFAULT_GEMINI_MODEL }; - -export type GeminiLocalConfig = { - token?: string; - model?: string; -}; - -export type GeminiModelSource = 'explicit' | 'env' | 'local' | 'default'; - -const GEMINI_DIR = join(homedir(), '.gemini'); -const SETTINGS_PATH = join(GEMINI_DIR, 'settings.json'); -const CONFIG_PATH = join(GEMINI_DIR, 'config.json'); -const OAUTH_PATH = join(GEMINI_DIR, 'oauth_creds.json'); - -function readJsonFile(path: string): Record | null { - if (!existsSync(path)) { - return null; - } - - try { - const raw = readFileSync(path, 'utf-8'); - const parsed = JSON.parse(raw); - if (parsed && typeof parsed === 'object') { - return parsed as Record; - } - } catch (error) { - logger.debug(`[gemini-config] Failed to read ${path}: ${error}`); - } - - return null; -} - -function extractModel(settings: Record): string | undefined { - const modelEntry = settings.model; - if (modelEntry && typeof modelEntry === 'object') { - const name = (modelEntry as Record).name; - if (typeof name === 'string' && name.trim().length > 0) { - return name.trim(); - } - } - - const model = settings.model; - if (typeof model === 'string' && model.trim().length > 0) { - return model.trim(); - } - - return undefined; -} - -function extractToken(settings: Record): string | undefined { - const tokenKeys = ['access_token', 'token', 'apiKey', GEMINI_API_KEY_ENV, GOOGLE_API_KEY_ENV]; - for (const key of tokenKeys) { - const value = settings[key]; - if (typeof value === 'string' && value.trim().length > 0) { - return value.trim(); - } - } - return undefined; -} - -export function readGeminiLocalConfig(): GeminiLocalConfig { - const settingsFile = readJsonFile(SETTINGS_PATH); - const configFile = readJsonFile(CONFIG_PATH); - const oauthFile = readJsonFile(OAUTH_PATH); - - const model = settingsFile ? extractModel(settingsFile) : undefined; - const token = oauthFile - ? extractToken(oauthFile) - : configFile - ? extractToken(configFile) - : undefined; - - return { - model, - token - }; -} - -export function resolveGeminiRuntimeConfig(opts: { - model?: string; - token?: string; -} = {}): { model: string; token?: string; modelSource: GeminiModelSource } { - const local = readGeminiLocalConfig(); - - let modelSource: GeminiModelSource = 'default'; - let model: string = DEFAULT_GEMINI_MODEL; - - if (opts.model) { - model = opts.model; - modelSource = 'explicit'; - } else if (process.env[GEMINI_MODEL_ENV]) { - model = process.env[GEMINI_MODEL_ENV]!; - modelSource = 'env'; - } else if (local.model) { - model = local.model; - modelSource = 'local'; - } - - const token = opts.token - ?? process.env[GEMINI_API_KEY_ENV] - ?? process.env[GOOGLE_API_KEY_ENV] - ?? local.token; - - return { model, token, modelSource }; -} - -export function buildGeminiEnv(opts: { - model?: string; - token?: string; - hookSettingsPath?: string; - cwd?: string; -}): NodeJS.ProcessEnv { - const env: NodeJS.ProcessEnv = { - ...process.env - }; - - if (opts.model) { - env[GEMINI_MODEL_ENV] = opts.model; - } - - if (opts.token && !env[GEMINI_API_KEY_ENV] && !env[GOOGLE_API_KEY_ENV]) { - env[GEMINI_API_KEY_ENV] = opts.token; - } - - if (opts.hookSettingsPath) { - env.GEMINI_CLI_SYSTEM_SETTINGS_PATH = opts.hookSettingsPath; - } - - if (opts.cwd) { - env.GEMINI_PROJECT_DIR = opts.cwd; - } - - return env; -} diff --git a/cli/src/gemini/utils/geminiBackend.ts b/cli/src/gemini/utils/geminiBackend.ts deleted file mode 100644 index 392f8fc9a6..0000000000 --- a/cli/src/gemini/utils/geminiBackend.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { AcpSdkBackend } from '@/agent/backends/acp'; -import { buildGeminiEnv, resolveGeminiRuntimeConfig } from './config'; - -function filterEnv(env: NodeJS.ProcessEnv): Record { - const result: Record = {}; - for (const [key, value] of Object.entries(env)) { - if (value !== undefined) { - result[key] = value; - } - } - return result; -} - -export function createGeminiBackend(opts: { - model?: string; - token?: string; - resumeSessionId?: string | null; - hookSettingsPath?: string; - cwd?: string; - permissionMode?: string; -}): AcpSdkBackend { - const { model, token } = resolveGeminiRuntimeConfig({ - model: opts.model, - token: opts.token - }); - - const args = ['--experimental-acp']; - if (opts.resumeSessionId) { - args.push('--resume', opts.resumeSessionId); - } - if (model) { - args.push('--model', model); - } - if (opts.permissionMode === 'yolo' || opts.permissionMode === 'safe-yolo') { - args.push('--yolo'); - } - - const env = buildGeminiEnv({ - model, - token, - hookSettingsPath: opts.hookSettingsPath, - cwd: opts.cwd - }); - - return new AcpSdkBackend({ - command: 'gemini', - args, - env: filterEnv(env) - }); -} diff --git a/cli/src/gemini/utils/permissionHandler.ts b/cli/src/gemini/utils/permissionHandler.ts deleted file mode 100644 index a73629f981..0000000000 --- a/cli/src/gemini/utils/permissionHandler.ts +++ /dev/null @@ -1,170 +0,0 @@ -import type { ApiSessionClient } from '@/api/apiSession'; -import type { AgentBackend, PermissionRequest, PermissionResponse } from '@/agent/types'; -import type { GeminiPermissionMode } from '@hapi/protocol/types'; -import { deriveToolName } from '@/agent/utils'; -import { logger } from '@/ui/logger'; -import { - BasePermissionHandler, - type AutoApprovalDecision, - type PendingPermissionRequest, - type PermissionCompletion -} from '@/modules/common/permission/BasePermissionHandler'; - -interface PermissionResponseMessage { - id: string; - approved: boolean; - decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort'; - reason?: string; -} - -function deriveToolInput(request: PermissionRequest): unknown { - if (request.rawInput !== undefined) { - return request.rawInput; - } - return request.rawOutput; -} - -function pickOptionId(request: PermissionRequest, preferredKinds: string[]): string | null { - for (const kind of preferredKinds) { - const match = request.options.find((option) => option.kind === kind); - if (match) { - return match.optionId; - } - } - return request.options.length > 0 ? request.options[0].optionId : null; -} - -function mapDecisionToOutcome(request: PermissionRequest, decision: PermissionResponseMessage['decision']): PermissionResponse { - if (decision === 'abort') { - return { outcome: 'cancelled' }; - } - - if (decision === 'approved_for_session') { - const optionId = pickOptionId(request, ['allow_always', 'allow_once']); - return optionId ? { outcome: 'selected', optionId } : { outcome: 'cancelled' }; - } - - if (decision === 'approved') { - const optionId = pickOptionId(request, ['allow_once', 'allow_always']); - return optionId ? { outcome: 'selected', optionId } : { outcome: 'cancelled' }; - } - - const optionId = pickOptionId(request, ['reject_once', 'reject_always']); - return optionId ? { outcome: 'selected', optionId } : { outcome: 'cancelled' }; -} - -export class GeminiPermissionHandler extends BasePermissionHandler { - private readonly pendingBackendRequests = new Map(); - - constructor( - session: ApiSessionClient, - private readonly backend: AgentBackend, - private readonly getPermissionMode: () => GeminiPermissionMode | undefined - ) { - super(session); - this.backend.onPermissionRequest((request) => this.handlePermissionRequest(request)); - } - - private handlePermissionRequest(request: PermissionRequest): void { - const toolName = deriveToolName({ - title: request.title, - kind: request.kind, - rawInput: request.rawInput - }); - const toolInput = deriveToolInput(request); - const mode = this.getPermissionMode() ?? 'default'; - - const autoDecision = this.resolveAutoApprovalDecision(mode, toolName, request.toolCallId); - if (autoDecision) { - void this.autoApprove(request, toolName, toolInput, autoDecision); - return; - } - - this.pendingBackendRequests.set(request.id, request); - this.addPendingRequest(request.id, toolName, toolInput, { - resolve: () => {}, - reject: () => {} - }); - - logger.debug(`[Gemini] Permission request queued for ${toolName} (${request.id})`); - } - - private async autoApprove( - request: PermissionRequest, - toolName: string, - toolInput: unknown, - decision: AutoApprovalDecision - ): Promise { - const outcome = mapDecisionToOutcome(request, decision); - await this.backend.respondToPermission(request.sessionId, request, outcome); - - this.client.updateAgentState((currentState) => ({ - ...currentState, - completedRequests: { - ...currentState.completedRequests, - [request.id]: { - tool: toolName, - arguments: toolInput, - createdAt: Date.now(), - completedAt: Date.now(), - status: 'approved', - decision - } - } - })); - - logger.debug(`[Gemini] Auto-approved ${toolName} (${request.id}) mode=${decision}`); - } - - protected async handlePermissionResponse( - response: PermissionResponseMessage, - pending: PendingPermissionRequest - ): Promise { - const pendingRequest = this.pendingBackendRequests.get(response.id); - if (pendingRequest) { - this.pendingBackendRequests.delete(response.id); - } else { - logger.debug('[Gemini] Permission response missing backend request', response.id); - } - - const decision = response.decision ?? (response.approved ? 'approved' : 'denied'); - - if (decision === 'abort' && pendingRequest) { - await this.backend.cancelPrompt(pendingRequest.sessionId); - } - - if (pendingRequest) { - const outcome = mapDecisionToOutcome(pendingRequest, decision); - await this.backend.respondToPermission(pendingRequest.sessionId, pendingRequest, outcome); - } - - pending.resolve(); - - logger.debug(`[Gemini] Permission ${response.approved ? 'approved' : 'denied'} for ${pending.toolName}`); - - return { - status: response.approved ? 'approved' : 'denied', - decision, - reason: response.reason - }; - } - - protected handleMissingPendingResponse(response: PermissionResponseMessage): void { - logger.debug('[Gemini] Permission response received for unknown request', response.id); - } - - async cancelAll(reason: string): Promise { - const pending = Array.from(this.pendingBackendRequests.values()); - this.pendingBackendRequests.clear(); - - for (const request of pending) { - await this.backend.respondToPermission(request.sessionId, request, { outcome: 'cancelled' }); - } - - this.cancelPendingRequests({ - completedReason: reason, - rejectMessage: reason, - decision: 'abort' - }); - } -} diff --git a/cli/src/gemini/utils/sessionScanner.ts b/cli/src/gemini/utils/sessionScanner.ts deleted file mode 100644 index a33b014a45..0000000000 --- a/cli/src/gemini/utils/sessionScanner.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { readFile } from 'node:fs/promises'; -import { logger } from '@/ui/logger'; -import { - BaseSessionScanner, - SessionFileScanEntry, - SessionFileScanResult, - SessionFileScanStats -} from '@/modules/common/session/BaseSessionScanner'; - -type GeminiTranscriptMessage = { - id?: string; - type?: string; - content?: string; - [key: string]: unknown; -}; - -type GeminiTranscript = { - sessionId?: string; - messages?: GeminiTranscriptMessage[]; - [key: string]: unknown; -}; - -export async function createGeminiSessionScanner(opts: { - transcriptPath: string | null; - onMessage: (message: GeminiTranscriptMessage) => void; - onSessionId?: (sessionId: string) => void; -}) { - const scanner = new GeminiSessionScanner({ - transcriptPath: opts.transcriptPath, - onMessage: opts.onMessage, - onSessionId: opts.onSessionId - }); - - await scanner.start(); - - return { - cleanup: async () => { - await scanner.cleanup(); - }, - onNewSession: (transcriptPath: string) => { - void scanner.setTranscriptPath(transcriptPath); - } - }; -} - -class GeminiSessionScanner extends BaseSessionScanner { - private transcriptPath: string | null; - private readonly onMessage: (message: GeminiTranscriptMessage) => void; - private readonly onSessionId?: (sessionId: string) => void; - private observedSessionId: string | null = null; - - constructor(opts: { - transcriptPath: string | null; - onMessage: (message: GeminiTranscriptMessage) => void; - onSessionId?: (sessionId: string) => void; - }) { - super({ intervalMs: 2000 }); - this.transcriptPath = opts.transcriptPath; - this.onMessage = opts.onMessage; - this.onSessionId = opts.onSessionId; - } - - async setTranscriptPath(path: string): Promise { - if (this.transcriptPath === path) { - return; - } - this.transcriptPath = path; - await this.primeTranscript(path); - this.invalidate(); - } - - protected async initialize(): Promise { - if (this.transcriptPath) { - await this.primeTranscript(this.transcriptPath); - } - } - - protected async findSessionFiles(): Promise { - if (!this.transcriptPath) { - return []; - } - return [this.transcriptPath]; - } - - protected shouldWatchFile(filePath: string): boolean { - return Boolean(this.transcriptPath && filePath === this.transcriptPath); - } - - protected async parseSessionFile(filePath: string, cursor: number): Promise> { - const transcript = await readTranscript(filePath); - if (!transcript) { - return { events: [], nextCursor: cursor }; - } - - this.updateSessionId(transcript.sessionId); - - const messages = transcript.messages ?? []; - let startIndex = cursor; - if (startIndex > messages.length) { - startIndex = 0; - } - - const events: SessionFileScanEntry[] = []; - for (let index = startIndex; index < messages.length; index += 1) { - events.push({ event: messages[index], lineIndex: index }); - } - - return { - events, - nextCursor: messages.length - }; - } - - protected generateEventKey(event: GeminiTranscriptMessage, context: { filePath: string; lineIndex?: number }): string { - if (event.id && event.id.length > 0) { - return `${context.filePath}:${event.id}`; - } - return `${context.filePath}:${context.lineIndex ?? -1}`; - } - - protected async handleFileScan(stats: SessionFileScanStats): Promise { - for (const message of stats.events) { - this.onMessage(message); - } - if (stats.newCount > 0) { - logger.debug(`[gemini-session-scanner] ${stats.newCount} new messages from ${stats.filePath}`); - } - this.pruneWatchers(this.transcriptPath ? [this.transcriptPath] : []); - } - - private updateSessionId(sessionId: string | undefined): void { - if (!sessionId || sessionId.length === 0) { - return; - } - if (this.observedSessionId === sessionId) { - return; - } - this.observedSessionId = sessionId; - this.onSessionId?.(sessionId); - } - - private async primeTranscript(filePath: string): Promise { - const transcript = await readTranscript(filePath); - if (!transcript) { - return; - } - this.updateSessionId(transcript.sessionId); - - const messages = transcript.messages ?? []; - const keys = messages.map((message, index) => this.generateEventKey(message, { filePath, lineIndex: index })); - this.seedProcessedKeys(keys); - this.setCursor(filePath, messages.length); - } -} - -async function readTranscript(filePath: string): Promise { - try { - const raw = await readFile(filePath, 'utf-8'); - const parsed = JSON.parse(raw); - if (!parsed || typeof parsed !== 'object') { - return null; - } - const record = parsed as Record; - const messages = Array.isArray(record.messages) - ? record.messages.filter((value): value is GeminiTranscriptMessage => Boolean(value && typeof value === 'object')) - : []; - const sessionId = typeof record.sessionId === 'string' ? record.sessionId : undefined; - return { - sessionId, - messages - }; - } catch (error) { - logger.debug(`[gemini-session-scanner] Failed to read transcript ${filePath}: ${error}`); - return null; - } -} diff --git a/cli/src/runner/buildCliArgs.test.ts b/cli/src/runner/buildCliArgs.test.ts index fb6d764895..d37c699fe0 100644 --- a/cli/src/runner/buildCliArgs.test.ts +++ b/cli/src/runner/buildCliArgs.test.ts @@ -31,7 +31,7 @@ describe('buildCliArgs', () => { }) it('prefers --permission-mode over --yolo when both present', () => { - const args = buildCliArgs('gemini', { + const args = buildCliArgs('cursor', { directory: '/tmp', permissionMode: 'yolo', }, true) @@ -43,6 +43,10 @@ describe('buildCliArgs', () => { expect(yoloIdx).toBe(-1) }) + it('throws for the removed gemini agent (no longer launchable)', () => { + expect(() => buildCliArgs('gemini', { directory: '/tmp' })).toThrow(/no longer supported/) + }) + it('adds --yolo when no permissionMode and yolo is true', () => { const args = buildCliArgs('claude', { directory: '/tmp', diff --git a/cli/src/runner/run.ts b/cli/src/runner/run.ts index 694ad7197b..a9b3403b9d 100644 --- a/cli/src/runner/run.ts +++ b/cli/src/runner/run.ts @@ -287,6 +287,9 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}): const { directory, sessionId, machineId, approvedNewDirectoryCreation = true } = options; const agent = options.agent ?? 'claude'; + if (agent === 'gemini') { + throw new Error('Gemini CLI is no longer supported and cannot be launched (Google sunset the consumer Gemini CLI on 2026-06-18). Existing Gemini sessions remain viewable in the web UI.'); + } const yolo = options.yolo === true; const sessionType = options.sessionType ?? 'simple'; const worktreeName = options.worktreeName; @@ -1073,19 +1076,20 @@ export function buildCliArgs( options: SpawnSessionOptions, yolo?: boolean ): string[] { + if (agent === 'gemini') { + throw new Error('Gemini CLI is no longer supported and cannot be launched (Google sunset the consumer Gemini CLI on 2026-06-18).'); + } const agentCommand = agent === 'codex' ? 'codex' : agent === 'cursor' ? 'cursor' - : agent === 'gemini' - ? 'gemini' - : agent === 'kimi' - ? 'kimi' - : agent === 'opencode' - ? 'opencode' - : agent === 'pi' - ? 'pi' - : 'claude'; + : agent === 'kimi' + ? 'kimi' + : agent === 'opencode' + ? 'opencode' + : agent === 'pi' + ? 'pi' + : 'claude'; const args = [agentCommand]; if (options.resumeSessionId) { if (agent === 'codex') { diff --git a/cli/src/ui/ink/GeminiDisplay.tsx b/cli/src/ui/ink/GeminiDisplay.tsx deleted file mode 100644 index 34e04e1d7c..0000000000 --- a/cli/src/ui/ink/GeminiDisplay.tsx +++ /dev/null @@ -1,187 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { Box, Text, useStdout } from 'ink'; -import { MessageBuffer, type BufferedMessage } from './messageBuffer'; -import { useSwitchControls } from './useSwitchControls'; - -interface GeminiDisplayProps { - messageBuffer: MessageBuffer; - logPath?: string; - onExit?: () => void; - onSwitchToLocal?: () => void; -} - -function extractTag(messages: BufferedMessage[], tag: 'MODEL' | 'MODE'): string | null { - const prefix = `[${tag}:`; - for (let index = messages.length - 1; index >= 0; index -= 1) { - const message = messages[index]; - if (message.type !== 'system') { - continue; - } - if (!message.content.startsWith(prefix)) { - continue; - } - const match = message.content.match(/\[\w+:(.+?)\]/); - if (match && match[1]) { - return match[1]; - } - } - return null; -} - -export const GeminiDisplay: React.FC = ({ - messageBuffer, - logPath, - onExit, - onSwitchToLocal -}) => { - const [messages, setMessages] = useState([]); - const [model, setModel] = useState(null); - const [permissionMode, setPermissionMode] = useState(null); - const { confirmationMode, actionInProgress } = useSwitchControls({ - onExit, - onSwitch: onSwitchToLocal - }); - const { stdout } = useStdout(); - const terminalWidth = stdout.columns || 80; - const terminalHeight = stdout.rows || 24; - - useEffect(() => { - setMessages(messageBuffer.getMessages()); - - const unsubscribe = messageBuffer.onUpdate((newMessages) => { - setMessages(newMessages); - const nextModel = extractTag(newMessages, 'MODEL'); - if (nextModel) { - setModel(nextModel); - } - const nextMode = extractTag(newMessages, 'MODE'); - if (nextMode) { - setPermissionMode(nextMode); - } - }); - - return () => { - unsubscribe(); - }; - }, [messageBuffer]); - - const getMessageColor = (type: BufferedMessage['type']): string => { - switch (type) { - case 'user': return 'magenta'; - case 'assistant': return 'cyan'; - case 'system': return 'blue'; - case 'tool': return 'yellow'; - case 'result': return 'green'; - case 'status': return 'gray'; - default: return 'white'; - } - }; - - const formatMessage = (msg: BufferedMessage): string => { - const lines = msg.content.split('\n'); - const maxLineLength = Math.max(1, terminalWidth - 10); - return lines.map(line => { - if (line.length <= maxLineLength) return line; - const chunks: string[] = []; - for (let i = 0; i < line.length; i += maxLineLength) { - chunks.push(line.slice(i, i + maxLineLength)); - } - return chunks.join('\n'); - }).join('\n'); - }; - - const visibleMessages = messages.filter((msg) => { - if (msg.type === 'system' && msg.content.startsWith('[MODEL:')) { - return false; - } - if (msg.type === 'system' && msg.content.startsWith('[MODE:')) { - return false; - } - return true; - }); - - return ( - - - - Gemini Agent Messages - {'-'.repeat(Math.min(terminalWidth - 4, 60))} - - - - {visibleMessages.length === 0 ? ( - Waiting for messages... - ) : ( - visibleMessages - .slice(-Math.max(1, terminalHeight - 10)) - .map((msg) => ( - - - {formatMessage(msg)} - - - )) - )} - - - - - - {actionInProgress === 'exiting' ? ( - - Exiting agent... - - ) : actionInProgress === 'switching' ? ( - - Switching to local mode... - - ) : confirmationMode === 'exit' ? ( - - Press Ctrl-C again to exit the agent - - ) : confirmationMode === 'switch' ? ( - - Press space again to switch to local mode - - ) : ( - - Gemini running {onSwitchToLocal ? '(Space to switch to local, Ctrl-C to exit)' : '(Ctrl-C to exit)'} - - )} - {(model || permissionMode) && ( - - {model ? `Model: ${model}` : 'Model: default'} - {permissionMode ? ` | Permission: ${permissionMode}` : ''} - - )} - {process.env.DEBUG && logPath && ( - - Debug logs: {logPath} - - )} - - - - ); -}; diff --git a/shared/src/modes.test.ts b/shared/src/modes.test.ts index eb97fe10f5..3ef3193392 100644 --- a/shared/src/modes.test.ts +++ b/shared/src/modes.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it, test } from 'bun:test' import { + AGENT_FLAVORS, + AgentFlavorSchema, + CREATABLE_AGENT_FLAVORS, getPermissionModeLabel, getPermissionModeOptionsForFlavor, getPermissionModeTone, @@ -7,6 +10,24 @@ import { isPermissionModeAllowedForFlavor, } from './modes' +describe('Gemini CLI sunset (read-only, not creatable)', () => { + test('gemini stays a valid flavor so existing stored sessions still validate/load', () => { + expect(AGENT_FLAVORS).toContain('gemini') + expect(AgentFlavorSchema.safeParse('gemini').success).toBe(true) + }) + + test('gemini is excluded from creatable flavors (not offered for new sessions)', () => { + expect(CREATABLE_AGENT_FLAVORS).not.toContain('gemini') + }) + + test('all other flavors remain creatable', () => { + for (const flavor of AGENT_FLAVORS) { + if (flavor === 'gemini') continue + expect(CREATABLE_AGENT_FLAVORS).toContain(flavor) + } + }) +}) + describe('getPermissionModesForFlavor', () => { test("returns [] for flavor 'pi' (RPC mode has no runtime permission switching)", () => { expect(getPermissionModesForFlavor('pi')).toEqual([]) diff --git a/shared/src/modes.ts b/shared/src/modes.ts index a8d1c6659c..73209677d4 100644 --- a/shared/src/modes.ts +++ b/shared/src/modes.ts @@ -11,6 +11,14 @@ export const AGENT_FLAVORS = ['claude', 'codex', 'cursor', 'gemini', 'kimi', 'op export type AgentFlavor = typeof AGENT_FLAVORS[number] export const AgentFlavorSchema = z.enum(AGENT_FLAVORS) +// Flavors offered when CREATING a new session. Gemini CLI is intentionally +// excluded: Google sunset the consumer Gemini CLI (2026-06-18) so it can no +// longer be launched. It is kept in AGENT_FLAVORS / AgentFlavorSchema above so +// existing stored Gemini sessions still validate and remain viewable. +export const CREATABLE_AGENT_FLAVORS: readonly AgentFlavor[] = AGENT_FLAVORS.filter( + (flavor) => flavor !== 'gemini' +) + export const CLAUDE_PERMISSION_MODES = ['default', 'acceptEdits', 'auto', 'bypassPermissions', 'plan'] as const export type ClaudePermissionMode = typeof CLAUDE_PERMISSION_MODES[number] diff --git a/web/src/components/NewSession/AgentSelector.test.tsx b/web/src/components/NewSession/AgentSelector.test.tsx new file mode 100644 index 0000000000..fbb2ebba4b --- /dev/null +++ b/web/src/components/NewSession/AgentSelector.test.tsx @@ -0,0 +1,28 @@ +import { describe, it, expect, vi } from 'vitest' +import { render } from '@testing-library/react' +import { CREATABLE_AGENT_FLAVORS } from '@hapi/protocol' + +vi.mock('@/lib/use-translation', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})) + +import { AgentSelector } from './AgentSelector' +import type { AgentType } from './types' + +function renderedAgentValues(): string[] { + const { container } = render( + {}} /> + ) + return Array.from(container.querySelectorAll('input[type="radio"]')) + .map((el) => (el as HTMLInputElement).value) +} + +describe('AgentSelector', () => { + it('does not offer the sunset Gemini CLI as a new-session agent', () => { + expect(renderedAgentValues()).not.toContain('gemini') + }) + + it('offers exactly the creatable agent flavors', () => { + expect(renderedAgentValues()).toEqual([...CREATABLE_AGENT_FLAVORS]) + }) +}) diff --git a/web/src/components/NewSession/AgentSelector.tsx b/web/src/components/NewSession/AgentSelector.tsx index 4146f30815..1de5d7a4b8 100644 --- a/web/src/components/NewSession/AgentSelector.tsx +++ b/web/src/components/NewSession/AgentSelector.tsx @@ -1,4 +1,4 @@ -import { AGENT_FLAVORS } from '@hapi/protocol' +import { CREATABLE_AGENT_FLAVORS } from '@hapi/protocol' import type { AgentType } from './types' import { useTranslation } from '@/lib/use-translation' @@ -15,7 +15,7 @@ export function AgentSelector(props: { {t('newSession.agent')}
- {AGENT_FLAVORS.map((agentType) => ( + {CREATABLE_AGENT_FLAVORS.map((agentType) => (
- {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 13/31] 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 14/31] 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 15/31] 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 16/31] 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 17/31] 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 18/31] 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 (