From 0e1e67370193efb41b7b195b13fd78d86b34e992 Mon Sep 17 00:00:00 2001 From: star-nebula <105629895+star-nebula@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:02:29 +0800 Subject: [PATCH 1/2] feat(native-chat): read ZCode session transcripts through the OpenCode SQLite reader ZCode's CLI persists an OpenCode-v1-shaped history in its own SQLite store (~/.zcode/cli/db/db.sqlite). The Agent Session History scan already lists those sessions, but opening one in the native chat found nothing: the chat transcript path had no ZCode awareness. Resolve a zcode transcript against the ZCode database (native home, then WSL homes) before any OpenCode install, tag every read (signal, page, subscribe) with the agent, and apply the same hidden-transcript filter the scan uses so ZCode's hidden bookkeeping rows stay out of chat renders. --- ...ession-scanner-opencode-sqlite-dispatch.ts | 2 +- ...scanner-opencode-sqlite-worker-protocol.ts | 2 + ...on-scanner-opencode-sqlite-worker-spawn.ts | 2 + .../transcript-opencode-database.ts | 42 ++++++++++++- .../transcript-opencode-sqlite-query.ts | 25 ++++++-- .../transcript-opencode-subscribe.ts | 32 ++++------ src/main/native-chat/transcript-opencode.ts | 62 ++++++++++++++++--- src/main/native-chat/transcript-read-cache.ts | 7 ++- src/main/native-chat/transcript-reader.ts | 7 ++- .../native-chat/transcript-tail-reader.ts | 6 +- src/main/native-chat/transcript-watch.ts | 7 ++- src/shared/native-chat-agent-support.test.ts | 5 ++ src/shared/native-chat-agent-support.ts | 9 ++- 13 files changed, 166 insertions(+), 42 deletions(-) diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-dispatch.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-dispatch.ts index d9bc0b3a27c6..d4e66dd48b02 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-dispatch.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-dispatch.ts @@ -25,7 +25,7 @@ export async function handleOpenCodeSqliteRequest( if (request.kind === 'native-page' || request.kind === 'native-signal') { const value = request.kind === 'native-signal' - ? readOpenCodeTranscriptSignal(request.dbPath, request.sessionId) + ? readOpenCodeTranscriptSignal(request.dbPath, request.sessionId, request.agent) : readOpenCodeTranscriptPage({ ...request, limit: request.limit ?? 50 }) return { id: request.id, ok: true, value } } diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-protocol.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-protocol.ts index afee7778e3db..aea241b250f6 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-protocol.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-protocol.ts @@ -50,6 +50,8 @@ export type OpenCodeNativeChatReadRequest = { sessionId: string limit?: number beforeMessageRowId?: number + /** When 'zcode', reads apply ZCode's hidden-transcript filter. */ + agent?: 'zcode' } export type OpenCodeNativeChatReadValue = OpenCodeTranscriptPage | OpenCodeTranscriptSignal | null diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-spawn.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-spawn.ts index e89a6361b1e2..7e6f5eb31e0d 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-spawn.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-spawn.ts @@ -265,6 +265,7 @@ export async function readOpenCodeTranscriptPageViaWorker( sessionId: string limit: number beforeMessageRowId?: number + agent?: 'zcode' }, signal?: AbortSignal ) { @@ -289,6 +290,7 @@ export async function readOpenCodeTranscriptSignalViaWorker( args: { dbPath: string sessionId: string + agent?: 'zcode' }, signal?: AbortSignal ) { diff --git a/src/main/native-chat/transcript-opencode-database.ts b/src/main/native-chat/transcript-opencode-database.ts index 92b97e7c9b09..a99de75a0816 100644 --- a/src/main/native-chat/transcript-opencode-database.ts +++ b/src/main/native-chat/transcript-opencode-database.ts @@ -1,3 +1,4 @@ +import { homedir } from 'node:os' import { join } from 'node:path' import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' import { getAiVaultWslHomeDirs } from '../ai-vault/cached-session-list' @@ -13,9 +14,17 @@ import { listOpenCodeDatabasesInDirectory } from '../opencode-usage/opencode-database-discovery' +// ZCode's CLI keeps its OpenCode-v1-shaped history in its own database, so a +// zcode transcript must never be claimed by an unrelated OpenCode install (the +// same separation the AI Vault scanner draws in session-scanner-zcode-sources). +function zcodeTranscriptDatabasePaths(wslHomeDirs: readonly string[]): string[] { + return [homedir(), ...wslHomeDirs].map((home) => join(home, '.zcode', 'cli', 'db', 'db.sqlite')) +} + export async function discoverOpenCodeTranscriptDatabase( sessionId?: string, - signal?: AbortSignal + signal?: AbortSignal, + agent?: 'zcode' ): Promise { signal?.throwIfAborted() const deadline = new AbortController() @@ -47,7 +56,10 @@ export async function discoverOpenCodeTranscriptDatabase( try { if ( await waitForPromiseWithSignal( - readOpenCodeTranscriptSignalViaWorker({ dbPath, sessionId }, boundedSignal), + readOpenCodeTranscriptSignalViaWorker( + { dbPath, sessionId, ...(agent ? { agent } : {}) }, + boundedSignal + ), boundedSignal ) ) { @@ -61,6 +73,32 @@ export async function discoverOpenCodeTranscriptDatabase( return null } try { + // A zcode transcript only ever lives in a ZCode database: probe those first + // and never fall through to OpenCode installs that cannot hold the session. + if (agent === 'zcode') { + const homes = await waitForPromiseWithSignal(getAiVaultWslHomeDirs(), boundedSignal) + const native = await findSession(zcodeTranscriptDatabasePaths([])) + if (native) { + return native + } + const readerRoots = homes + if (readerRoots.length > 0) { + const readers = await waitForPromiseWithSignal( + prepareOpenCodeWslReaders(readerRoots), + boundedSignal + ) + boundedSignal.throwIfAborted() + configureOpenCodeWslReaders(readers) + } + const wsl = await findSession(zcodeTranscriptDatabasePaths(homes)) + if (wsl) { + return wsl + } + if (refusals[0]) { + throw refusals[0] + } + return null + } const primary = await waitForPromiseWithSignal( listOpenCodeDatabases(onRefusal, undefined, boundedSignal), boundedSignal diff --git a/src/main/native-chat/transcript-opencode-sqlite-query.ts b/src/main/native-chat/transcript-opencode-sqlite-query.ts index d245020b619a..9583c9cc2100 100644 --- a/src/main/native-chat/transcript-opencode-sqlite-query.ts +++ b/src/main/native-chat/transcript-opencode-sqlite-query.ts @@ -16,6 +16,11 @@ import { } from './transcript-opencode-part-blocks' // Cursors are opaque provider order: SQLite rowid in v1, session sequence in v2. +// ZCode stores hidden bookkeeping transcripts in the same message table; the +// same predicate the AI Vault scan applies keeps them out of chat renders. +const ZCODE_HIDDEN_MESSAGE_FILTER = + "AND COALESCE(json_extract(data, '$.semantics.transcriptVisibility'), 'visible') != 'hidden'" + export type OpenCodeTranscriptItem = { rowid: number fingerprint: string @@ -70,7 +75,8 @@ function rowsWithinBudget( export function readOpenCodeTranscriptSignal( dbPath: string, - sessionId: string + sessionId: string, + agent?: 'zcode' ): OpenCodeTranscriptSignal | null { const db = openOpenCodeDatabaseReadonly(dbPath) try { @@ -84,14 +90,22 @@ export function readOpenCodeTranscriptSignal( // Aggregates without GROUP BY always yield exactly one row, so [0] is it. const [messageRow] = rowsOf<{ message_count: number; max_message_rowid: number }>( db.prepare( - 'SELECT COUNT(*) AS message_count, COALESCE(MAX(rowid), 0) AS max_message_rowid FROM message WHERE session_id = ?' + `SELECT COUNT(*) AS message_count, COALESCE(MAX(rowid), 0) AS max_message_rowid FROM message WHERE session_id = ? ${ + agent === 'zcode' ? ZCODE_HIDDEN_MESSAGE_FILTER : '' + }` ), sessionId ) const [partRow] = rowsOf<{ part_count: number; max_part_time_updated: number }>( db.prepare( - 'SELECT COUNT(*) AS part_count, COALESCE(MAX(time_updated), 0) AS max_part_time_updated FROM part WHERE session_id = ?' + `SELECT COUNT(*) AS part_count, COALESCE(MAX(time_updated), 0) AS max_part_time_updated + FROM part WHERE session_id = ? AND message_id IN ( + SELECT id FROM message WHERE session_id = ? ${ + agent === 'zcode' ? ZCODE_HIDDEN_MESSAGE_FILTER : '' + } + )` ), + sessionId, sessionId ) return { @@ -110,6 +124,7 @@ export function readOpenCodeTranscriptPage(args: { sessionId: string limit: number beforeMessageRowId?: number + agent?: 'zcode' }): OpenCodeTranscriptPage | null { const db = openOpenCodeDatabaseReadonly(args.dbPath) try { @@ -127,7 +142,9 @@ export function readOpenCodeTranscriptPage(args: { const select = db.prepare( `SELECT rowid AS message_rowid, id, time_created, time_updated, CASE WHEN length(CAST(data AS BLOB)) <= ${OPENCODE_TRANSCRIPT_MAX_ROW_BYTES} THEN data ELSE NULL END AS data FROM message - WHERE session_id = ? AND rowid < ? + WHERE session_id = ? AND rowid < ? ${ + args.agent === 'zcode' ? ZCODE_HIDDEN_MESSAGE_FILTER : '' + } ORDER BY rowid DESC LIMIT ?` ) diff --git a/src/main/native-chat/transcript-opencode-subscribe.ts b/src/main/native-chat/transcript-opencode-subscribe.ts index b5faaf884ca3..c1997f3ed550 100644 --- a/src/main/native-chat/transcript-opencode-subscribe.ts +++ b/src/main/native-chat/transcript-opencode-subscribe.ts @@ -6,7 +6,7 @@ import { type SubscribeNativeChatTranscriptArgs } from './transcript-watch-contract' import type { OpenCodeTranscriptItem } from './transcript-opencode-sqlite-query' -import { openCodeTranscriptDefaultDeps, type OpenCodeTranscriptDeps } from './transcript-opencode' +import { bindOpenCodeTranscriptDeps, type OpenCodeTranscriptDeps } from './transcript-opencode' import { openCodeTranscriptPageLimit, OPENCODE_TRANSCRIPT_MAX_WINDOW @@ -31,23 +31,13 @@ export const pruneOpenCodeFingerprintCacheForTest = pruneOpenCodeFingerprintCach export function subscribeOpenCodeNativeChatTranscript( args: SubscribeNativeChatTranscriptArgs, setupSignal?: AbortSignal, - deps: OpenCodeTranscriptDeps = {} + deps: OpenCodeTranscriptDeps = {}, + // When 'zcode': discovery probes ZCode's database, reads drop hidden rows. + agent?: 'zcode' ): NativeChatTranscriptSubscription { setupSignal?.throwIfAborted() const controller = new AbortController() - const resolveDbPath = () => - (deps.resolveDbPath ?? openCodeTranscriptDefaultDeps.resolveDbPath)( - args.sessionId, - controller.signal - ) - const readSignal = (dbPath: string, sessionId: string) => - (deps.readSignal ?? openCodeTranscriptDefaultDeps.readSignal)( - dbPath, - sessionId, - controller.signal - ) - const readPage = (page: Parameters>[0]) => - (deps.readPage ?? openCodeTranscriptDefaultDeps.readPage)(page, controller.signal) + const bound = bindOpenCodeTranscriptDeps(deps, controller.signal, agent) const pollMs = args.resolvePollIntervalMs ?? OPENCODE_POLL_MS const initialLimit = openCodeTranscriptPageLimit( args.initialLimit && args.initialLimit > 0 ? args.initialLimit : DESKTOP_READ_WINDOW @@ -117,7 +107,7 @@ export function subscribeOpenCodeNativeChatTranscript( return } try { - dbPath ??= await resolveDbPath() + dbPath ??= await bound.resolveDbPath() if (closed) { return } @@ -126,7 +116,7 @@ export function subscribeOpenCodeNativeChatTranscript( scheduleTick() return } - const signal = await readSignal(dbPath, args.sessionId) + const signal = await bound.readSignal(dbPath, args.sessionId) if (closed) { return } @@ -141,7 +131,7 @@ export function subscribeOpenCodeNativeChatTranscript( scheduleTick() return } - const page = await readPage({ + const page = await bound.readPage({ dbPath, sessionId: args.sessionId, limit: firstSnapshot @@ -193,7 +183,7 @@ export function subscribeOpenCodeNativeChatTranscript( const displaced = [...fingerprints.keys()].filter((rowid) => rowid < oldest) if (displaced.length > 0) { // Verify the displaced fringe: cap eviction and a real deletion look identical in the tail. - const older = await readPage({ + const older = await bound.readPage({ dbPath, sessionId: args.sessionId, limit: displaced.length, @@ -267,14 +257,14 @@ export function subscribeOpenCodeNativeChatTranscript( // overlap, so the gap retries instead of dropping rows. async function replaceWithBridgedWindow( db: string, - page: Awaited>, + page: Awaited>, shrinking = false ): Promise { let limit = Math.max(windowLimit, page?.items.length ?? 0) for (;;) { const replacement = page ?? - (await readPage({ + (await bound.readPage({ dbPath: db, sessionId: args.sessionId, limit diff --git a/src/main/native-chat/transcript-opencode.ts b/src/main/native-chat/transcript-opencode.ts index 497635de87e1..eada2eca9854 100644 --- a/src/main/native-chat/transcript-opencode.ts +++ b/src/main/native-chat/transcript-opencode.ts @@ -20,11 +20,16 @@ import { openCodeTranscriptPageLimit } from '../../shared/opencode-transcript-pa // OpenCode SQLite reads use the same bounded worker as AI Vault. export type OpenCodeTranscriptDeps = { - resolveDbPath?: (sessionId?: string, signal?: AbortSignal) => Promise + resolveDbPath?: ( + sessionId?: string, + signal?: AbortSignal, + agent?: 'zcode' + ) => Promise readSignal?: ( dbPath: string, sessionId: string, - signal?: AbortSignal + signal?: AbortSignal, + agent?: 'zcode' ) => Promise readPage?: ( args: { @@ -32,6 +37,7 @@ export type OpenCodeTranscriptDeps = { sessionId: string limit: number beforeMessageRowId?: number + agent?: 'zcode' }, signal?: AbortSignal ) => Promise @@ -43,17 +49,51 @@ export function resolveOpenCodeTranscriptDbPath(sessionId?: string): Promise = { resolveDbPath: discoverOpenCodeTranscriptDatabase, - readSignal: (dbPath, sessionId, signal) => - readOpenCodeTranscriptSignalViaWorker({ dbPath, sessionId }, signal), + readSignal: (dbPath, sessionId, signal, agent) => + readOpenCodeTranscriptSignalViaWorker({ dbPath, sessionId, agent }, signal), readPage: (args, signal) => readOpenCodeTranscriptPageViaWorker(args, signal) } +/** + * Resolve a subscribe's read legs against its own abort signal, with the agent + * tag (when any) already applied to every leg. Kept here so the poll loop stays + * free of default-vs-override plumbing. + */ +export function bindOpenCodeTranscriptDeps( + deps: OpenCodeTranscriptDeps, + signal: AbortSignal, + agent?: 'zcode' +): { + resolveDbPath: () => Promise + readSignal: (dbPath: string, sessionId: string) => Promise + readPage: ( + page: Parameters>[0] + ) => Promise +} { + return { + resolveDbPath: () => + (deps.resolveDbPath ?? openCodeTranscriptDefaultDeps.resolveDbPath)(undefined, signal, agent), + readSignal: (dbPath, sessionId) => + (deps.readSignal ?? openCodeTranscriptDefaultDeps.readSignal)( + dbPath, + sessionId, + signal, + agent + ), + readPage: (page) => + (deps.readPage ?? openCodeTranscriptDefaultDeps.readPage)( + { ...page, ...(agent ? { agent } : {}) }, + signal + ) + } +} + export type OpenCodeTailResult = | { messages: NativeChatMessage[]; hasMore: boolean; beforeOffset: number } | { error: string; notFound?: true } export async function readOpenCodeNativeChatTranscriptTail( - args: { sessionId: string; limit: number; beforeOffset?: number }, + args: { sessionId: string; limit: number; beforeOffset?: number; agent?: 'zcode' }, deps: OpenCodeTranscriptDeps = {}, signal?: AbortSignal ): Promise { @@ -61,7 +101,8 @@ export async function readOpenCodeNativeChatTranscriptTail( try { const dbPath = await (deps.resolveDbPath ?? openCodeTranscriptDefaultDeps.resolveDbPath)( args.sessionId, - signal + signal, + args.agent ) if (!dbPath) { return { error: 'Transcript unavailable', notFound: true } @@ -71,7 +112,8 @@ export async function readOpenCodeNativeChatTranscriptTail( dbPath, sessionId: args.sessionId, limit, - ...(args.beforeOffset !== undefined ? { beforeMessageRowId: args.beforeOffset } : {}) + ...(args.beforeOffset !== undefined ? { beforeMessageRowId: args.beforeOffset } : {}), + ...(args.agent ? { agent: args.agent } : {}) }, signal ) @@ -91,7 +133,8 @@ export async function readOpenCodeNativeChatTranscriptTail( export async function readOpenCodeNativeChatTranscriptFull( sessionId: string, deps: OpenCodeTranscriptDeps = {}, - signal?: AbortSignal + signal?: AbortSignal, + agent?: 'zcode' ): Promise { // Pages arrive newest-window first; reverse windows, preserving message order. const pages: NativeChatMessage[][] = [] @@ -113,7 +156,8 @@ export async function readOpenCodeNativeChatTranscriptFull( dbPath, sessionId, limit: 500, - ...(cursor !== undefined ? { beforeMessageRowId: cursor } : {}) + ...(cursor !== undefined ? { beforeMessageRowId: cursor } : {}), + ...(agent ? { agent } : {}) }, signal ) diff --git a/src/main/native-chat/transcript-read-cache.ts b/src/main/native-chat/transcript-read-cache.ts index 7233f001de48..25072906d17b 100644 --- a/src/main/native-chat/transcript-read-cache.ts +++ b/src/main/native-chat/transcript-read-cache.ts @@ -107,7 +107,12 @@ export async function readNativeChatTranscriptCached( transcriptPath?: string ): Promise { if (resolveNativeChatTranscriptAgent(agent) === 'opencode') { - return readOpenCodeNativeChatTranscriptFull(sessionId) + return readOpenCodeNativeChatTranscriptFull( + sessionId, + {}, + undefined, + agent === 'zcode' ? 'zcode' : undefined + ) } const epoch = cacheEpoch let filePath: string | null diff --git a/src/main/native-chat/transcript-reader.ts b/src/main/native-chat/transcript-reader.ts index 6cad15a7757f..b90b8681b445 100644 --- a/src/main/native-chat/transcript-reader.ts +++ b/src/main/native-chat/transcript-reader.ts @@ -45,7 +45,12 @@ export async function readNativeChatTranscript( options: ReadTranscriptOptions = {} ): Promise { if (resolveNativeChatTranscriptAgent(agent) === 'opencode') { - return readOpenCodeNativeChatTranscriptFull(sessionId) + return readOpenCodeNativeChatTranscriptFull( + sessionId, + {}, + undefined, + agent === 'zcode' ? 'zcode' : undefined + ) } let filePath: string | null try { diff --git a/src/main/native-chat/transcript-tail-reader.ts b/src/main/native-chat/transcript-tail-reader.ts index fb44470047bf..fce4b630e4e8 100644 --- a/src/main/native-chat/transcript-tail-reader.ts +++ b/src/main/native-chat/transcript-tail-reader.ts @@ -246,7 +246,11 @@ export async function readNativeChatTranscriptTail( | { error: string; notFound?: true } > { if (resolveNativeChatTranscriptAgent(args.agent) === 'opencode') { - return readOpenCodeNativeChatTranscriptTail(args, {}, signal) + return readOpenCodeNativeChatTranscriptTail( + { ...args, agent: args.agent === 'zcode' ? 'zcode' : undefined }, + {}, + signal + ) } const decode = nativeChatLineDecoderForAgent(args.agent) const decodeLifecycle = nativeChatTurnLifecycleDecoderForAgent(args.agent) diff --git a/src/main/native-chat/transcript-watch.ts b/src/main/native-chat/transcript-watch.ts index a5090b4c8008..69bb21e8c714 100644 --- a/src/main/native-chat/transcript-watch.ts +++ b/src/main/native-chat/transcript-watch.ts @@ -267,7 +267,12 @@ export async function subscribeNativeChatTranscript( ): Promise { setupSignal?.throwIfAborted() if (resolveNativeChatTranscriptAgent(args.agent) === 'opencode') { - return subscribeOpenCodeNativeChatTranscript(args, setupSignal) + return subscribeOpenCodeNativeChatTranscript( + args, + setupSignal, + {}, + args.agent === 'zcode' ? 'zcode' : undefined + ) } const decode = nativeChatLineDecoderForAgent(args.agent) if (!decode) { diff --git a/src/shared/native-chat-agent-support.test.ts b/src/shared/native-chat-agent-support.test.ts index 23749bb0dfbf..3da69dd80a0d 100644 --- a/src/shared/native-chat-agent-support.test.ts +++ b/src/shared/native-chat-agent-support.test.ts @@ -15,6 +15,9 @@ describe('resolveNativeChatTranscriptAgent', () => { it('passes codex, grok and omp through and rejects everything else', () => { expect(resolveNativeChatTranscriptAgent('opencode')).toBe('opencode') expect(resolveNativeChatTranscriptAgent('opencode2')).toBe('opencode') + // ZCode keeps an OpenCode-v1-shaped history in its own SQLite DB, so its + // transcripts read through the OpenCode SQLite reader. + expect(resolveNativeChatTranscriptAgent('zcode')).toBe('opencode') expect(resolveNativeChatTranscriptAgent('codex')).toBe('codex') expect(resolveNativeChatTranscriptAgent('grok')).toBe('grok') expect(resolveNativeChatTranscriptAgent('omp')).toBe('omp') @@ -28,6 +31,7 @@ describe('isNativeChatSupportedAgent', () => { it('recognizes the parseable agents and rejects unknown / nullish input', () => { expect(isNativeChatSupportedAgent('opencode')).toBe(true) expect(isNativeChatSupportedAgent('opencode2')).toBe(true) + expect(isNativeChatSupportedAgent('zcode')).toBe(true) expect(isNativeChatSupportedAgent('claude')).toBe(true) expect(isNativeChatSupportedAgent('openclaude')).toBe(true) expect(isNativeChatSupportedAgent('omp')).toBe(true) @@ -43,6 +47,7 @@ describe('nativeChatRequiresLocalTranscript', () => { // native chat has to find their file on a disk this process can read. expect(nativeChatRequiresLocalTranscript('opencode')).toBe(true) expect(nativeChatRequiresLocalTranscript('opencode2')).toBe(true) + expect(nativeChatRequiresLocalTranscript('zcode')).toBe(true) expect(nativeChatRequiresLocalTranscript('grok')).toBe(true) expect(nativeChatRequiresLocalTranscript('omp')).toBe(true) expect(nativeChatRequiresLocalTranscript('claude')).toBe(false) diff --git a/src/shared/native-chat-agent-support.ts b/src/shared/native-chat-agent-support.ts index e2bb9c40cacc..081086bae923 100644 --- a/src/shared/native-chat-agent-support.ts +++ b/src/shared/native-chat-agent-support.ts @@ -11,7 +11,8 @@ export const NATIVE_CHAT_SUPPORTED_AGENT_LIST: readonly TuiAgent[] = [ 'grok', 'omp', 'opencode', - 'opencode2' + 'opencode2', + 'zcode' ] export const NATIVE_CHAT_SUPPORTED_AGENTS: ReadonlySet = new Set( @@ -48,6 +49,12 @@ export function resolveNativeChatTranscriptAgent( if (agent === 'opencode' || agent === 'opencode2') { return 'opencode' } + // Why: ZCode's CLI stores an OpenCode-v1-shaped history in its own SQLite DB + // (~/.zcode/cli/db/db.sqlite), so its transcripts read through the same + // OpenCode SQLite reader as OpenCode 1.x — the agent identity stays zcode. + if (agent === 'zcode') { + return 'opencode' + } if (agent === 'codex' || agent === 'grok' || agent === 'omp') { return agent } From 368fe1633bb219f35f91b7b8f586216659ac1584 Mon Sep 17 00:00:00 2001 From: star-nebula <105629895+star-nebula@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:33:07 +0800 Subject: [PATCH 2/2] feat(native-chat): open a zcode session history as a read-only chat tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ZCode has no structured-session host adapter, so an Agent Session History row could not reach a chat at all: the resume paths only knew how to adopt a conversation into a host-registered structured session. Rows whose agent reads its own store (zcode today) now open as a read-only chat tab. The tab reuses the agent-session content type with the session id as its entity, and AiVaultSessionHistoryChatLayer paints it with the same live-session hook and message list the chat overlay uses — polling the ZCode SQLite store through nativeChat:subscribe, no PTY involved. There is no composer: nothing sends into a conversation the CLI may still be holding. The eligibility gates answer from the history-chat branch alone — no host adoption, capability, or workspace matching — while keeping the remote and empty-content refusals, and reopening focuses the existing tab instead of duplicating it. --- .../components/WorkspacePaneOverlayLayers.tsx | 2 + .../AiVaultSessionHistoryChatLayer.tsx | 106 +++++++++++++++++ .../AiVaultSessionHistoryChatView.tsx | 109 ++++++++++++++++++ .../ai-vault-session-history-chat-open.ts | 59 ++++++++++ .../ai-vault-session-history-chat.ts | 21 ++++ .../ai-vault-session-launch-actions.ts | 6 + ...-vault-session-resume-in-chat-workspace.ts | 11 ++ .../ai-vault-session-resume-in-chat.ts | 15 +++ 8 files changed, 329 insertions(+) create mode 100644 src/renderer/src/components/native-chat/AiVaultSessionHistoryChatLayer.tsx create mode 100644 src/renderer/src/components/native-chat/AiVaultSessionHistoryChatView.tsx create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-session-history-chat-open.ts create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-session-history-chat.ts diff --git a/src/renderer/src/components/WorkspacePaneOverlayLayers.tsx b/src/renderer/src/components/WorkspacePaneOverlayLayers.tsx index f8fa52363390..918c72f914b4 100644 --- a/src/renderer/src/components/WorkspacePaneOverlayLayers.tsx +++ b/src/renderer/src/components/WorkspacePaneOverlayLayers.tsx @@ -3,6 +3,7 @@ import TerminalPaneOverlayLayer from './terminal-pane/TerminalPaneOverlayLayer' import { RetainedBrowserPaneOverlayLayer } from './browser-pane/assemble-chrome/BrowserPaneOverlayLayer' import EmulatorPaneOverlayLayer from './emulator-pane/EmulatorPaneOverlayLayer' import StructuredAgentSessionPaneOverlayLayer from './native-chat/StructuredAgentSessionPaneOverlayLayer' +import AiVaultSessionHistoryChatLayer from './native-chat/AiVaultSessionHistoryChatLayer' import AiVaultSessionDropLayer from './tab-group/AiVaultSessionDropLayer' /** @@ -65,6 +66,7 @@ export function WorkspacePaneOverlayLayers({ worktreeId={worktreeId} isWorktreeActive={isVisible} /> + ) diff --git a/src/renderer/src/components/native-chat/AiVaultSessionHistoryChatLayer.tsx b/src/renderer/src/components/native-chat/AiVaultSessionHistoryChatLayer.tsx new file mode 100644 index 000000000000..b87c9cf5a67b --- /dev/null +++ b/src/renderer/src/components/native-chat/AiVaultSessionHistoryChatLayer.tsx @@ -0,0 +1,106 @@ +import { memo, useMemo } from 'react' +import { useShallow } from 'zustand/react/shallow' +import type { Tab, TabGroup } from '../../../../shared/tab-types' +import { useAppStore } from '@/store' +import { RetainedPaneHost } from '../tab-group/RetainedPaneHost' +import { AiVaultSessionHistoryChatView } from './AiVaultSessionHistoryChatView' + +type HistoryChatTab = Tab & { + contentType: 'agent-session' + agentSessionAgent: 'zcode' +} + +/** An Agent Session History tab this build renders read-only: zcode has no structured host + * adapter, so its history chats are the vault row's tab viewed through the live-session hook. */ +function isAiVaultHistoryChatTab(tab: Tab): tab is HistoryChatTab { + return tab.contentType === 'agent-session' && tab.agentSessionAgent === 'zcode' +} + +const EMPTY_UNIFIED_TABS: readonly Tab[] = [] +const EMPTY_GROUPS: readonly TabGroup[] = [] + +const HistoryChatOverlaySlot = memo(function HistoryChatOverlaySlot({ + tab, + groupId, + isActive, + isFocusedGroup, + onFocusOwningGroup +}: { + tab: HistoryChatTab + groupId: string | undefined + isActive: boolean + isFocusedGroup: boolean + onFocusOwningGroup: ((groupId: string) => void) | undefined +}): React.JSX.Element { + return ( + + + + ) +}) + +/** + * The retained pane hosts for the workspace's Agent Session History chat tabs (zcode today). + * Mirrors `StructuredAgentSessionPaneOverlayLayer`, which owns the same tab content type for + * host-registered structured agents; this layer answers for the agents a chat can only render. + */ +const AiVaultSessionHistoryChatLayer = memo(function AiVaultSessionHistoryChatLayer({ + worktreeId, + isWorktreeActive +}: { + worktreeId: string + isWorktreeActive: boolean +}): React.JSX.Element | null { + const { unifiedTabs, groups, activeGroupId } = useAppStore( + useShallow((state) => ({ + unifiedTabs: state.unifiedTabsByWorktree[worktreeId] ?? EMPTY_UNIFIED_TABS, + groups: state.groupsByWorktree[worktreeId] ?? EMPTY_GROUPS, + activeGroupId: state.activeGroupIdByWorktree[worktreeId] + })) + ) + const focusOwningGroup = useMemo( + () => (groupId: string) => { + useAppStore.getState().focusGroup(worktreeId, groupId) + }, + [worktreeId] + ) + const groupActiveTabById = useMemo( + () => new Map(groups.map((group) => [group.id, group.activeTabId] as const)), + [groups] + ) + const historyTabs = useMemo(() => unifiedTabs.filter(isAiVaultHistoryChatTab), [unifiedTabs]) + if (historyTabs.length === 0) { + return null + } + return ( + <> + {historyTabs.map((tab) => ( + + ))} + + ) +}) + +export default AiVaultSessionHistoryChatLayer diff --git a/src/renderer/src/components/native-chat/AiVaultSessionHistoryChatView.tsx b/src/renderer/src/components/native-chat/AiVaultSessionHistoryChatView.tsx new file mode 100644 index 000000000000..bc17f59cad80 --- /dev/null +++ b/src/renderer/src/components/native-chat/AiVaultSessionHistoryChatView.tsx @@ -0,0 +1,109 @@ +import { useMemo, useRef } from 'react' +import { NATIVE_CHAT_APPEARANCE_ROOT_CLASS } from './native-chat-appearance-style' +import { useNativeChatStoreAppearanceStyle } from './use-native-chat-store-appearance-style' +import { useNativeChatRetainedSession } from './use-native-chat-retained-session' +import { isNativeChatTranscriptUnsettled } from './native-chat-live-session-contract' +import { selectNativeChatViewState } from './native-chat-view-state' +import { NativeChatMessageList } from './NativeChatMessageList' +import { + useNativeChatMessageListHandle, + useNativeChatRevealLatest, + type NativeChatMessageListHandle +} from './use-native-chat-reveal-latest' +import { NativeChatEmptyState } from './NativeChatEmptyState' +import { useNativeChatFontSize } from './use-native-chat-font-size' +import { useNativeChatFind } from './use-native-chat-find' +import { NativeChatFindBar } from './NativeChatFindBar' +import type { NativeChatComposerHandle } from './native-chat-composer-types' +import type { AiVaultAgent } from '../../../../shared/ai-vault-types' +import { agentLabel } from '../../../../shared/ai-vault-session-filters' + +/** + * A read-only chat rendering of one Agent Session History row, mounted as its own tab. + * + * The live-session hook is the same one a terminal's chat overlay uses — the transport routes + * by agent (zcode polls its SQLite store through `nativeChat:subscribe`), so no PTY is involved. + * There is no composer: nothing here can send into a conversation the CLI may still be holding. + */ +export function AiVaultSessionHistoryChatView({ + tabId, + sessionId, + agent, + isVisible, + isFocusedGroup +}: { + tabId: string + sessionId: string + /** The vault row's agent; only store-reading agents reach this view, typed by the vault list. */ + agent: AiVaultAgent + isVisible: boolean + isFocusedGroup: boolean +}): React.JSX.Element { + const paneKey = useMemo(() => `ai-vault-history-chat:${tabId}`, [tabId]) + const session = useNativeChatRetainedSession({ + paneKey, + agent, + sessionId, + enabled: isVisible + }) + const { revealLatest } = useNativeChatRevealLatest() + const messageListRef = useRef(null) + useNativeChatMessageListHandle(messageListRef, revealLatest) + const fontSizeRootRef = useRef(null) + useNativeChatFontSize( + session.readPhase === 'ready' && isVisible && isFocusedGroup, + fontSizeRootRef + ) + // No composer in a read-only view; the hook still needs a (never-focused) ref slot. + const noComposerRef = useRef(null) + const find = useNativeChatFind( + isVisible && isFocusedGroup, + fontSizeRootRef, + noComposerRef, + messageListRef + ) + const appearanceStyle = useNativeChatStoreAppearanceStyle() + const viewState = selectNativeChatViewState(session) + const unsettled = isNativeChatTranscriptUnsettled(session.readPhase) + const agentName = agentLabel(agent) + + return ( +
find.onKeyDownCapture(event)} + className={`${NATIVE_CHAT_APPEARANCE_ROOT_CLASS} flex h-full min-h-0 w-full flex-col`} + style={appearanceStyle} + data-native-chat-scheme={appearanceStyle.colorScheme} + > +
+ {agentName} + · + {sessionId} +
+
+ {find.isOpen ? : null} + {viewState.kind === 'loading' ? ( + + ) : viewState.kind === 'error' ? ( + + ) : viewState.kind === 'empty' ? ( + unsettled ? ( + + ) : ( + + ) + ) : ( + + )} +
+
+ ) +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-history-chat-open.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-history-chat-open.ts new file mode 100644 index 000000000000..b098d884a0f2 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-history-chat-open.ts @@ -0,0 +1,59 @@ +import { toast } from 'sonner' +import type { Tab } from '../../../../shared/tab-types' +import { useAppStore } from '@/store' +import { translate } from '@/i18n/i18n' +import { aiVaultSessionHistoryChatTabId } from './ai-vault-session-history-chat' +import { canOpenAiVaultSessionHistoryChat } from './ai-vault-session-history-chat' +import type { AiVaultSession } from '../../../../shared/ai-vault-types' + +/** + * Handles a history row that opens as a read-only chat tab, returning whether it did. + * + * ZCode has no structured-session host adapter, so its history cannot adopt into a live + * structured chat; instead the row's conversation renders through the same live-session hook + * the chat overlay uses, reading the agent's own store (`nativeChat:subscribe`). The tab + * reuses the `agent-session` content type with the session id as its entity, so the tab bar, + * persistence and close commands all work unchanged; `AiVaultSessionHistoryChatLayer` is the + * body this tab paints. + */ +export function openAiVaultSessionHistoryChatForRow(session: AiVaultSession): boolean { + if (!canOpenAiVaultSessionHistoryChat(session)) { + return false + } + const tab = openAiVaultSessionHistoryChatTab(session) + if (!tab) { + toast.error( + translate( + 'auto.components.right.sidebar.AiVaultPanel.openWorkspaceBeforeResuming', + 'Open a workspace before resuming a session.' + ) + ) + } + return true +} +export function openAiVaultSessionHistoryChatTab(session: AiVaultSession): Tab | null { + const state = useAppStore.getState() + const worktreeId = state.activeWorktreeId + if (!worktreeId) { + return null + } + const tabId = aiVaultSessionHistoryChatTabId(session.sessionId) + const existing = (state.unifiedTabsByWorktree[worktreeId] ?? []).find( + (candidate) => candidate.id === tabId && candidate.contentType === 'agent-session' + ) + if (existing) { + state.focusGroup(worktreeId, existing.groupId) + state.activateTab(existing.id, { worktreeId }) + state.setActiveTabType('agent-session', worktreeId) + return existing + } + const tab = state.createUnifiedTab(worktreeId, 'agent-session', { + id: tabId, + entityId: session.sessionId, + agentSessionAgent: session.agent, + label: session.title, + activate: true + }) + state.setActiveTabType('agent-session', worktreeId) + return tab +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-history-chat.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-history-chat.ts new file mode 100644 index 000000000000..e9f5912c8aad --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-history-chat.ts @@ -0,0 +1,21 @@ +// Whether an Agent Session History row can open as a read-only chat tab, and that tab's identity. +// +// Kept free of store imports so the eligibility gates (`ai-vault-session-resume-in-chat*`) can +// call it during render without widening their module graph; the store-touching opener lives in +// ai-vault-session-history-chat-open.ts. + +import type { AiVaultSession } from '../../../../shared/ai-vault-types' + +/** The tab id a history chat paints under; stable so reopening focuses the existing tab. */ +export function aiVaultSessionHistoryChatTabId(sessionId: string): string { + return `ai-vault-history-chat-${sessionId}` +} + +/** Whether this row's conversation can render as a read-only chat tab today: an agent whose + * transcripts the chat reads through its own store, with no structured chat already open. */ +export function canOpenAiVaultSessionHistoryChat(session: { + agent: AiVaultSession['agent'] + structuredSession?: AiVaultSession['structuredSession'] +}): boolean { + return session.agent === 'zcode' && !session.structuredSession +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts index d2dd3dd78666..6976c9535d2b 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts @@ -24,6 +24,7 @@ import { prepareAiVaultSessionContinuation } from './ai-vault-session-continuati import type { AgentSessionContinuationRequest } from '@/lib/agent-session-continuation' import { activateAiVaultStructuredSession } from '@/lib/activate-ai-vault-structured-session' import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' +import { openAiVaultSessionHistoryChatForRow } from './ai-vault-session-history-chat-open' import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id' import { activateAiVaultResumeWorkspace, @@ -197,6 +198,11 @@ export function useAiVaultSessionLaunchActions({ const handleResumeInNewChat = useCallback( (session: AiVaultSession, targetWorktreeId?: string): void => { + // No structured host to adopt into: the row opens as a read-only chat tab over the + // agent's own store, which is the whole conversation, not a fork of it. + if (openAiVaultSessionHistoryChatForRow(session)) { + return + } if (!isAgentSessionHandleProvider(session.agent)) { return } diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.ts index 16ed2c3a96d5..2ab8a26d2d4d 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.ts @@ -8,6 +8,7 @@ import { useAppStore } from '@/store' import type { AiVaultSession } from '../../../../shared/ai-vault-types' import { normalizeExecutionHostId } from '../../../../shared/execution-host' import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' +import { canOpenAiVaultSessionHistoryChat } from './ai-vault-session-history-chat' import { STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { resolveAiVaultTargetWorkspacePath } from './ai-vault-session-launch-target' import { @@ -33,6 +34,16 @@ export function resolveAiVaultSessionResumeInChatForWorkspace(args: { const targetWorkspacePath = targetWorkspaceId ? resolveAiVaultTargetWorkspacePath(args.targetState, targetWorkspaceId) : null + // A read-only chat tab has no host conversation to find, so the owner/capability questions a + // structured resume asks do not apply; the eligibility answer alone decides. + if (canOpenAiVaultSessionHistoryChat(args.session)) { + return resolveAiVaultSessionResumeInChatEligibility({ + session: args.session, + targetWorkspaceId, + targetWorkspacePath, + structuredRouteAvailable: targetWorkspaceId !== null + }) + } const state = useAppStore.getState() // The conversation lives on the host that recorded it, so only a chat on that same host can // resume it, the rule the terminal resume follows; that host also answers for the capability. diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.ts index ec18262ed098..696444d43d98 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.ts @@ -1,4 +1,5 @@ import { isAntigravityReferenceSession } from '../../../../shared/antigravity-session-origin' +import { canOpenAiVaultSessionHistoryChat } from './ai-vault-session-history-chat' // Whether an Agent Session History row can be resumed into a structured native chat, and where. // // Separate from `ai-vault-session-resume.ts` because the answer is not the same question: the @@ -71,6 +72,20 @@ export function resolveAiVaultSessionResumeInChatEligibility(args: { structuredRouteAvailable: boolean }): AiVaultResumeInChatEligibility { const { session } = args + // A read-only chat tab needs no host adoption — the conversation renders from the agent's own + // store — so the structured-route questions (host, capabilities, workspace) do not apply. + if (canOpenAiVaultSessionHistoryChat(session)) { + if (session.executionHostId !== LOCAL_EXECUTION_HOST_ID) { + return { available: false, reason: 'remote' } + } + if (!isAiVaultSessionResumableContent(session)) { + return { available: false, reason: 'empty' } + } + if (!args.targetWorkspaceId) { + return { available: false, reason: 'workspace' } + } + return { available: true, workspaceId: args.targetWorkspaceId } + } if (isAntigravityReferenceSession(session) || !isAgentSessionHandleProvider(session.agent)) { return { available: false, reason: 'agent' } }