diff --git a/docs/native-chat-codex-tui-parity.md b/docs/native-chat-codex-tui-parity.md new file mode 100644 index 00000000000..89b0d2bd1b9 --- /dev/null +++ b/docs/native-chat-codex-tui-parity.md @@ -0,0 +1,217 @@ +# Native Chat Codex TUI Parity + +This note maps Codex TUI behavior to Orca native chat on branch +`inspect/pr-5824-native-chat`. It is intentionally concrete: the current Orca +surface is a PTY harness around the running TUI, while real native parity should +move selected paths to Codex app-server protocol v2. + +## Source Map + +- Codex TUI composer: `/Users/jinwoohong/stably/codex/codex-rs/tui/src/bottom_pane/chat_composer.rs` +- Slash command parsing and popup: + `/Users/jinwoohong/stably/codex/codex-rs/tui/src/bottom_pane/prompt_args.rs`, + `/Users/jinwoohong/stably/codex/codex-rs/tui/src/bottom_pane/slash_commands.rs`, + `/Users/jinwoohong/stably/codex/codex-rs/tui/src/slash_command.rs`, + `/Users/jinwoohong/stably/codex/codex-rs/tui/src/chatwidget/slash_dispatch.rs` +- Skills and mentions: + `/Users/jinwoohong/stably/codex/codex-rs/tui/src/bottom_pane/skill_popup.rs`, + `/Users/jinwoohong/stably/codex/codex-rs/tui/src/skills_helpers.rs`, + `/Users/jinwoohong/stably/codex/codex-rs/core-skills/src/loader.rs`, + `/Users/jinwoohong/stably/codex/codex-rs/core-skills/src/root_loader.rs`, + `/Users/jinwoohong/stably/codex/codex-rs/core-skills/src/injection.rs` +- Structured input and native protocol: + `/Users/jinwoohong/stably/codex/codex-rs/protocol/src/user_input.rs`, + `/Users/jinwoohong/stably/codex/codex-rs/app-server-protocol/src/protocol/v2/turn.rs`, + `/Users/jinwoohong/stably/codex/codex-rs/app-server-protocol/src/protocol/common.rs` + +## Current Orca Architecture + +Orca native chat currently sends through the hosted terminal PTY. The composer +builds paste bytes, writes them through `sendRuntimePtyInput`, then sends a +delayed Enter. This preserves local and SSH behavior because it uses the same +runtime path as terminal typing. + +That architecture is useful for incremental adoption, but it means native chat +does not own Codex state. It cannot directly set model, reasoning, permissions, +skills, or session lifecycle. It can only type commands into the TUI and observe +agent hooks/transcripts after the fact. + +## Slash Commands + +Codex behavior: + +- The parser accepts a first-line command of `/name `. +- The slash popup uses Codex's `SlashCommand` enum order as presentation order. +- Enter on a selected popup row dispatches the command. Tab completes it into + the draft. +- Some commands accept inline args: `review`, `rename`, `plan`, `goal`, `ide`, + `keymap`, `mcp`, `raw`, `usage`, `pets`, `side`, `resume`, and + `sandbox-add-read-dir`. +- Commands are control actions, not ordinary user chat turns. For example + `/clear` sends `AppEvent::ClearUi`; `/compact` starts compaction; `/model` + opens the model picker; `/skills` opens skill management. + +Current Orca behavior: + +- Slash commands are still typed into the TUI over PTY. +- Native optimistic chat bubbles are suppressed for slash drafts so `/clear` + does not render as a fake queued user message. +- The Codex slash catalog now mirrors the visible TUI command list much more + closely, but it is still a copied catalog, not a live TUI query. + +Recommended route: + +- Short term: keep PTY dispatch for slash commands, but treat them as command + submissions. No optimistic chat bubbles. Enter dispatches; Tab completes. +- Medium term: route commands with app-server equivalents directly. Examples: + `thread/compact/start`, `thread/list`, `thread/archive`, `thread/delete`, + `model/list`, permissions/config reads and writes, `skills/list`. +- Long term: stop maintaining a renderer-side Codex command catalog. Either ask + Codex for the command inventory or host the Codex composer state machine. + +## Skills And `$` + +Codex behavior: + +- `$` opens the skill popup. Rows show display name, description, category tags, + selection state, filtering, sorting, and scrolling. +- Codex discovers skills from repo, user, system, admin, and plugin roots. Repo + scope sorts before user/system/admin. Exact duplicate paths are deduped. +- Skill selection is structured. `UserInput` has `Skill { name, path }`, and + app-server protocol v2 mirrors it. Text `$skill` mentions are only the + fallback path and must be unambiguous. +- Skill injection reads the selected `SKILL.md` by path, records telemetry, and + avoids double-injecting already provided host skill prompts. + +Current Orca behavior: + +- Native chat discovers skills through Orca's skills IPC with the active + terminal tab's cwd. This is important for worktree symlinks like + `.agents/skills`. +- `$` autocomplete inserts plain `$skillName` text. That can work through the + TUI's text fallback, but it is not equivalent to structured + `UserInput::Skill { name, path }`. + +Recommended route: + +- PTY mode: keep `$skill` text insertion, but preserve Codex-like filtering, + scrolling, dedupe, and active-cwd discovery. +- Native mode: retain the selected skill's path and submit + `UserInput::Skill { name, path }` through app-server `turn` input. This avoids + ambiguity when multiple skills share a name and lets Codex inject the exact + file the user selected. + +## Files, Mentions, And Images + +Codex behavior: + +- User input supports `Text` with text elements, `Image`, `LocalImage`, + `Skill`, and `Mention`. +- The TUI has file search/mentions and image placeholders. Large pastes become + placeholders so text element ranges stay aligned. +- Remote image rows are first-class composer attachments and can be removed with + keyboard navigation. + +Current Orca behavior: + +- File attach inserts a path/reference into the draft and relies on the TUI to + interpret it. +- Image paste saves a temp file, then inserts the agent-specific reference. +- Local attachments are blocked for remote sessions because the local path may + not exist on the SSH target. + +Recommended route: + +- PTY mode: keep conservative path insertion and remote-session blocking. +- Native mode: send structured `LocalImage` or `Image` input through Codex + protocol and use remote runtime file transfer semantics for SSH. + +## Model, Reasoning, Permissions + +Codex behavior: + +- `/model`, `/permissions`, `/keymap`, `/vim`, `/experimental`, and related + commands are stateful TUI/app-server surfaces. +- App-server v2 already exposes model listing, config requirements, approval + policies, permission profiles, and reasoning effort fields. + +Current Orca behavior: + +- Native chat does not know or set Codex model/reasoning directly. Typing + `/model` opens Codex's TUI picker. +- Earlier UI controls for model/thinking were removed because they were not + wired to real Codex state. + +Recommended route: + +- Do not re-add model or reasoning dropdowns until they read from and write to + Codex app-server state. +- In PTY mode, expose `/model` as a command shortcut only. + +## Approvals, Elicitations, And Tool UI + +Codex behavior: + +- Approval overlays cover exec approval, permission approval, file change + approval, network approval, MCP elicitation, and request-user-input forms. +- App-server notifications include thread status, waiting-on-approval/user-input + flags, item start/completion, diff/plan updates, and skill changes. + +Current Orca behavior: + +- Native chat has interactive cards sourced from Orca's existing agent status + hooks. This is good for common question/approval flows, but it is not the full + Codex approval overlay model. + +Recommended route: + +- Keep PTY fallback for anything not represented in Orca hooks. +- For Codex-native mode, subscribe to app-server notifications and render + approvals/tool calls from protocol events rather than scraping terminal text. + +## Session And History + +Codex behavior: + +- `/new`, `/resume`, `/fork`, `/archive`, `/delete`, `/compact`, and `/clear` + are session lifecycle commands. +- The composer has local and persistent history; Up/Down recall, Ctrl+R reverse + search, Esc edit/interrupt behavior, Ctrl+J newline, Ctrl+T transcript, and + Ctrl+C quit/interrupt behavior. + +Current Orca behavior: + +- Native chat has small in-memory draft history and Enter/Shift+Enter. +- Session commands are typed into the hosted TUI. + +Recommended route: + +- Short term: keep TUI command dispatch and avoid fake optimistic bubbles for + lifecycle commands. +- Native mode: use app-server thread APIs for lifecycle and expose real thread + transitions in the Orca UI. + +## Priority + +1. Fix PTY-command correctness: Enter dispatches slash commands, Tab completes, + slash commands never render as queued chat turns, interrupt clears working UI. +2. Make `$` skill popup match Codex basics: active cwd, dedupe, scrolling, + filtering, source labels, and no product-specific hardcoding. +3. Keep fake model/thinking controls out until backed by Codex app-server state. +4. Add an app-server integration spike for Codex native mode: `skills/list`, + structured `UserInput::Skill`, model list/settings, and thread lifecycle. +5. Move approvals/tool rendering from hook approximations to protocol events. + +## Test Targets + +- `/clear` from native slash popup dispatches immediately and produces no + pending user bubble. +- `/compact`, `/model`, `/skills`, `/resume`, `/diff`, `/status`, and unknown + slash commands behave like the hosted TUI. +- `$ref-oss` appears exactly once when the worktree has `.agents/skills` as a + symlink. +- Down-arrow in `$` suggestions scrolls the popup window. +- Interrupt during work returns the composer from Stop to Send after the agent + status settles. +- SSH sessions never insert local-only attachment paths as if they were remote + files. diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 118d3524719..b5dc91792c9 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -256,6 +256,7 @@ function equivalentParsedAgentStatusPayload( a.agentType === b.agentType && a.toolName === b.toolName && a.toolInput === b.toolInput && + a.interactivePrompt === b.interactivePrompt && a.lastAssistantMessage === b.lastAssistantMessage && a.interrupted === b.interrupted ) diff --git a/src/main/ipc/native-chat.test.ts b/src/main/ipc/native-chat.test.ts new file mode 100644 index 00000000000..066357f0b4c --- /dev/null +++ b/src/main/ipc/native-chat.test.ts @@ -0,0 +1,259 @@ +import { appendFile, mkdir, mkdtemp, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { handlers, listeners } = vi.hoisted(() => ({ + handlers: new Map unknown>(), + listeners: new Map unknown>() +})) + +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn((channel: string, handler: (_event: unknown, args?: unknown) => unknown) => { + handlers.set(channel, handler) + }), + on: vi.fn((channel: string, handler: (_event: unknown, args?: unknown) => unknown) => { + listeners.set(channel, handler) + }) + } +})) + +import { + clearNativeChatSubscriptions, + clearNativeChatTranscriptCache, + registerNativeChatHandlers +} from './native-chat' + +let tempRoots: string[] = [] + +beforeEach(() => { + handlers.clear() + listeners.clear() + clearNativeChatTranscriptCache() + clearNativeChatSubscriptions() +}) + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +function jsonLines(records: unknown[]): string { + return records.map((record) => JSON.stringify(record)).join('\n') +} + +async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise { + const start = Date.now() + while (!predicate()) { + if (Date.now() - start > timeoutMs) { + throw new Error('timed out waiting for condition') + } + await new Promise((resolve) => setTimeout(resolve, 10)) + } +} + +async function invokeReadSession(args: { + agent: string + sessionId: string + limit?: number +}): Promise { + registerNativeChatHandlers() + const handler = handlers.get('nativeChat:readSession') + if (!handler) { + throw new Error('handler not registered') + } + return handler({}, args) +} + +describe('nativeChat:readSession handler', () => { + it('resolves a Claude transcript and returns the full conversation', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-ipc-')) + tempRoots.push(root) + const projectsDir = join(root, '.claude', 'projects') + const projectDir = join(projectsDir, '-repo') + await mkdir(projectDir, { recursive: true }) + await writeFile( + join(projectDir, 'sess-ipc.jsonl'), + jsonLines([ + { + type: 'user', + uuid: 'u-1', + timestamp: '2026-06-01T10:00:00.000Z', + message: { role: 'user', content: 'Hi' } + }, + { + type: 'assistant', + uuid: 'a-1', + timestamp: '2026-06-01T10:00:01.000Z', + message: { role: 'assistant', content: [{ type: 'text', text: 'Hello' }] } + } + ]) + ) + + // Point homedir-derived Claude root at our fixture via HOME so the resolver + // (which reads homedir() internally) finds the transcript. + const previousHome = process.env.HOME + process.env.HOME = root + try { + const result = (await invokeReadSession({ agent: 'claude', sessionId: 'sess-ipc' })) as { + messages?: unknown[] + error?: string + } + expect(result.error).toBeUndefined() + expect(result.messages).toHaveLength(2) + } finally { + if (previousHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = previousHome + } + } + }) + + it('windows to the most-recent `limit` turns and pages older history when raised', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-ipc-limit-')) + tempRoots.push(root) + const projectDir = join(root, '.claude', 'projects', '-repo') + await mkdir(projectDir, { recursive: true }) + // Five user turns; reading with limit 2 returns only the last two, and a + // larger limit pages in older ones (chronological order preserved). + const records = [1, 2, 3, 4, 5].map((n) => ({ + type: 'user', + uuid: `u-${n}`, + timestamp: `2026-06-01T10:00:0${n}.000Z`, + message: { role: 'user', content: `m${n}` } + })) + await writeFile(join(projectDir, 'sess-limit.jsonl'), jsonLines(records)) + + const previousHome = process.env.HOME + process.env.HOME = root + try { + const windowed = (await invokeReadSession({ + agent: 'claude', + sessionId: 'sess-limit', + limit: 2 + })) as { messages: { id: string }[] } + expect(windowed.messages.map((m) => m.id)).toEqual(['u-4', 'u-5']) + + const wider = (await invokeReadSession({ + agent: 'claude', + sessionId: 'sess-limit', + limit: 4 + })) as { messages: { id: string }[] } + expect(wider.messages.map((m) => m.id)).toEqual(['u-2', 'u-3', 'u-4', 'u-5']) + } finally { + if (previousHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = previousHome + } + } + }) + + it('emits appended messages over nativeChat:appended and tears down on destroy', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-ipc-sub-')) + tempRoots.push(root) + const projectsDir = join(root, '.claude', 'projects') + const projectDir = join(projectsDir, '-repo') + await mkdir(projectDir, { recursive: true }) + const filePath = join(projectDir, 'sess-sub.jsonl') + await writeFile( + filePath, + `${jsonLines([ + { + type: 'user', + uuid: 'u-1', + timestamp: '2026-06-01T10:00:00.000Z', + message: { role: 'user', content: 'Hi' } + } + ])}\n` + ) + + registerNativeChatHandlers() + const subscribe = listeners.get('nativeChat:subscribe') + expect(subscribe).toBeDefined() + + const sent: { channel: string; payload: unknown }[] = [] + let destroyedCb: (() => void) | undefined + const sender = { + id: 1, + isDestroyed: () => false, + once: (event: string, cb: () => void) => { + if (event === 'destroyed') { + destroyedCb = cb + } + }, + send: (channel: string, payload: unknown) => sent.push({ channel, payload }) + } + + const previousHome = process.env.HOME + process.env.HOME = root + try { + subscribe!( + { sender }, + { + subscriptionId: 'sub-1', + agent: 'claude', + sessionId: 'sess-sub' + } + ) + + // The listener dispatches handleSubscribe fire-and-forget; give it a beat + // to resolve the path and install the watcher before we append. + await new Promise((resolve) => setTimeout(resolve, 100)) + + await appendFile( + filePath, + `${JSON.stringify({ + type: 'assistant', + uuid: 'a-1', + timestamp: '2026-06-01T10:00:01.000Z', + message: { role: 'assistant', content: [{ type: 'text', text: 'Hello' }] } + })}\n` + ) + + // Seed-at-0 means the first appended event carries the whole-file re-read; + // the new turn 'a-1' arrives across one of the appended events. Collect ids + // from every appended event and assert the new turn shows up. + const appendedIds = (): string[] => + sent + .filter((s) => s.channel === 'nativeChat:appended') + .flatMap((s) => (s.payload as { messages: { id: string }[] }).messages.map((m) => m.id)) + await waitFor(() => appendedIds().includes('a-1')) + const appendedEvent = sent.find((s) => s.channel === 'nativeChat:appended')! + const payload = appendedEvent.payload as { subscriptionId: string } + expect(payload.subscriptionId).toBe('sub-1') + expect(appendedIds()).toContain('a-1') + + // Destroyed window tears down the watcher without error. + expect(destroyedCb).toBeDefined() + destroyedCb!() + } finally { + if (previousHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = previousHome + } + } + }) + + it('returns an error for an unknown session without throwing', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-ipc-missing-')) + tempRoots.push(root) + const previousHome = process.env.HOME + process.env.HOME = root + try { + const result = (await invokeReadSession({ agent: 'claude', sessionId: 'nope' })) as { + error?: string + } + expect(result.error).toBeTruthy() + } finally { + if (previousHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = previousHome + } + } + }) +}) diff --git a/src/main/ipc/native-chat.ts b/src/main/ipc/native-chat.ts new file mode 100644 index 00000000000..ad6117db0c1 --- /dev/null +++ b/src/main/ipc/native-chat.ts @@ -0,0 +1,168 @@ +import { ipcMain, type IpcMainEvent, type WebContents } from 'electron' +import type { AgentType, NativeChatMessage } from '../../shared/native-chat-types' +import { + clearNativeChatTranscriptCache, + readNativeChatTranscriptCached +} from '../native-chat/transcript-read-cache' +import type { ReadTranscriptResult } from '../native-chat/transcript-reader' +import { + subscribeNativeChatTranscript, + type NativeChatTranscriptSubscription +} from '../native-chat/transcript-watch' + +// Re-export so existing test imports of `clearNativeChatTranscriptCache` from +// this module keep working after the cache moved to transcript-read-cache.ts. +export { clearNativeChatTranscriptCache } + +export type NativeChatReadSessionArgs = { + agent: AgentType + sessionId: string + /** How many of the most-recent turns to return. The renderer starts at the + * default window and raises this to page in older history as it scrolls up. */ + limit?: number + /** Authoritative transcript path from the agent hook (providerSession), used to + * locate the file when the session id no longer names it (recent Claude Code). */ + transcriptPath?: string +} + +// Why: render only the most recent turns so switching to chat view on a long +// session (thousands of messages) doesn't stall on building that many message +// components. The full transcript is still cached; only the returned slice is +// capped. The renderer raises `limit` to page in older history; live appends +// extend it from there. +const DESKTOP_READ_WINDOW = 300 + +function windowTranscript(result: ReadTranscriptResult, limit: number): ReadTranscriptResult { + if (!('messages' in result) || result.messages.length <= limit) { + return result + } + return { ...result, messages: result.messages.slice(-limit) } +} + +async function readSession(args: NativeChatReadSessionArgs): Promise { + const { agent, sessionId } = args + // Clamp to a positive window; default to the desktop window for the first page. + const limit = args.limit && args.limit > 0 ? Math.floor(args.limit) : DESKTOP_READ_WINDOW + // Desktop is full-class: window by count only, no char truncation. + const result = await readNativeChatTranscriptCached(agent, sessionId, args.transcriptPath) + return windowTranscript(result, limit) +} + +export type NativeChatSubscribeArgs = { + /** Renderer-minted id, unique per webContents, echoed back on every emit so + * the renderer can route appends to the right hook instance. */ + subscriptionId: string + agent: AgentType + sessionId: string + /** Authoritative transcript path from the agent hook (providerSession). */ + transcriptPath?: string +} + +export type NativeChatAppendedPayload = { + subscriptionId: string + messages: NativeChatMessage[] +} + +type LiveSubscription = { + subscription: NativeChatTranscriptSubscription +} + +// Why: live subscriptions are keyed by (webContents.id, subscriptionId) so the +// same renderer can watch several panes, and a destroyed window tears down all +// of its watchers — strict teardown to avoid fd leaks (plan U4 risk). +const liveSubscriptions = new Map>() +const senderCleanupRegistered = new Set() + +function teardownSubscription(senderId: number, subscriptionId: string): void { + const bySubId = liveSubscriptions.get(senderId) + const live = bySubId?.get(subscriptionId) + if (!live || !bySubId) { + return + } + live.subscription.unsubscribe() + bySubId.delete(subscriptionId) + if (bySubId.size === 0) { + liveSubscriptions.delete(senderId) + } +} + +function teardownAllForSender(senderId: number): void { + const bySubId = liveSubscriptions.get(senderId) + if (!bySubId) { + return + } + for (const live of bySubId.values()) { + live.subscription.unsubscribe() + } + liveSubscriptions.delete(senderId) + senderCleanupRegistered.delete(senderId) +} + +function registerSenderCleanup(sender: WebContents): void { + if (senderCleanupRegistered.has(sender.id)) { + return + } + senderCleanupRegistered.add(sender.id) + // Strict teardown: a closed/reloaded window releases every watcher it owns. + sender.once('destroyed', () => teardownAllForSender(sender.id)) +} + +async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArgs): Promise { + const sender = event.sender + if (sender.isDestroyed()) { + return + } + const { subscriptionId, agent, sessionId, transcriptPath } = args + // Replace any prior subscription under the same id (session change/resubscribe). + teardownSubscription(sender.id, subscriptionId) + registerSenderCleanup(sender) + + const subscription = await subscribeNativeChatTranscript({ + agent, + sessionId, + transcriptPath, + onAppend: (messages) => { + if (sender.isDestroyed()) { + return + } + const payload: NativeChatAppendedPayload = { subscriptionId, messages } + sender.send('nativeChat:appended', payload) + } + }) + + // The window may have gone away (or the subscription been replaced) while we + // resolved the file path — don't register a now-orphaned watcher. + const stillCurrent = !sender.isDestroyed() + if (!stillCurrent) { + subscription.unsubscribe() + return + } + const bySubId = liveSubscriptions.get(sender.id) ?? new Map() + // A concurrent subscribe with the same id beat us here; honor the latest. + const existing = bySubId.get(subscriptionId) + if (existing) { + existing.subscription.unsubscribe() + } + bySubId.set(subscriptionId, { subscription }) + liveSubscriptions.set(sender.id, bySubId) +} + +/** Test-only: drop all live transcript subscriptions between runs. */ +export function clearNativeChatSubscriptions(): void { + const senderIds = Array.from(liveSubscriptions.keys()) + for (const senderId of senderIds) { + teardownAllForSender(senderId) + } +} + +export function registerNativeChatHandlers(): void { + ipcMain.handle('nativeChat:readSession', (_event, args: NativeChatReadSessionArgs) => + readSession(args) + ) + ipcMain.on('nativeChat:subscribe', (event, args: NativeChatSubscribeArgs) => { + void handleSubscribe(event, args) + }) + ipcMain.on('nativeChat:unsubscribe', (event, args: { subscriptionId: string }) => { + teardownSubscription(event.sender.id, args.subscriptionId) + }) +} diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index 72dd8bc8428..8bd2b511ac3 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -52,6 +52,7 @@ const { registerSkillsHandlersMock, registerWorkspaceSpaceHandlersMock, registerWorkspacePortHandlersMock, + registerNativeChatHandlersMock, registerEmulatorFrameStreamHandlersMock } = vi.hoisted(() => ({ registerCliHandlersMock: vi.fn(), @@ -103,6 +104,7 @@ const { registerSkillsHandlersMock: vi.fn(), registerWorkspaceSpaceHandlersMock: vi.fn(), registerWorkspacePortHandlersMock: vi.fn(), + registerNativeChatHandlersMock: vi.fn(), registerEmulatorFrameStreamHandlersMock: vi.fn() })) @@ -294,6 +296,10 @@ vi.mock('./hosted-review', () => ({ registerHostedReviewHandlers: registerHostedReviewHandlersMock })) +vi.mock('./native-chat', () => ({ + registerNativeChatHandlers: registerNativeChatHandlersMock +})) + import { registerCoreHandlers } from './register-core-handlers' describe('registerCoreHandlers', () => { @@ -346,6 +352,7 @@ describe('registerCoreHandlers', () => { registerSkillsHandlersMock.mockReset() registerWorkspaceSpaceHandlersMock.mockReset() registerWorkspacePortHandlersMock.mockReset() + registerNativeChatHandlersMock.mockReset() registerEmulatorFrameStreamHandlersMock.mockReset() }) @@ -417,6 +424,7 @@ describe('registerCoreHandlers', () => { expect(registerAiVaultHandlersMock).toHaveBeenCalledWith({ getAdditionalCodexHomePaths: getAdditionalAiVaultCodexHomePaths }) + expect(registerNativeChatHandlersMock).toHaveBeenCalled() expect(registerCliHandlersMock).toHaveBeenCalled() expect(registerPreflightHandlersMock).toHaveBeenCalled() expect(registerShellHandlersMock).toHaveBeenCalled() diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index 7bcf9756c51..0f4cb4ca789 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -24,6 +24,7 @@ import { registerRateLimitHandlers } from './rate-limits' import { registerRuntimeHandlers } from './runtime' import { registerRuntimeEnvironmentHandlers } from './runtime-environments' import { registerAiVaultHandlers } from './ai-vault' +import { registerNativeChatHandlers } from './native-chat' import { registerNotificationHandlers } from './notifications' import { registerNotebookHandlers } from './notebook' import { registerOnboardingHandlers } from './onboarding' @@ -164,6 +165,7 @@ export function registerCoreHandlers( registerAiVaultHandlers({ getAdditionalCodexHomePaths: lifecycleOptions.getAdditionalAiVaultCodexHomePaths }) + registerNativeChatHandlers() registerClipboardHandlers(store) registerUpdaterHandlers(store) registerSpeechHandlers(store) diff --git a/src/main/ipc/skills.test.ts b/src/main/ipc/skills.test.ts index b75b7048b08..680163f8b5d 100644 --- a/src/main/ipc/skills.test.ts +++ b/src/main/ipc/skills.test.ts @@ -83,6 +83,14 @@ describe('registerSkillsHandlers', () => { expect(getWslHomeMock).not.toHaveBeenCalled() }) + it('scopes host skill discovery to the active workspace cwd when provided', async () => { + const handler = getDiscoverHandler() + + await handler(null, { cwd: '/repo/worktree' }) + + expect(discoverSkillsMock).toHaveBeenCalledWith({ repos: [], cwd: '/repo/worktree' }) + }) + it('uses the selected project WSL distro for skill discovery', async () => { const handler = getDiscoverHandler() diff --git a/src/main/ipc/skills.ts b/src/main/ipc/skills.ts index e3f2e6a570b..b81fd695790 100644 --- a/src/main/ipc/skills.ts +++ b/src/main/ipc/skills.ts @@ -51,7 +51,8 @@ export function registerSkillsHandlers(store: Store): void { return discoverSkills({ repos: [], homeDir, cwd: homeDir }) } - return discoverSkills({ repos: store.getRepos() }) + const cwd = target?.cwd?.trim() || undefined + return cwd ? discoverSkills({ repos: [], cwd }) : discoverSkills({ repos: store.getRepos() }) } ) } diff --git a/src/main/menu/register-app-menu.test.ts b/src/main/menu/register-app-menu.test.ts index db7660ce09e..39b266b6846 100644 --- a/src/main/menu/register-app-menu.test.ts +++ b/src/main/menu/register-app-menu.test.ts @@ -179,17 +179,21 @@ describe('registerAppMenu', () => { expect(paletteItem?.accelerator).toBeUndefined() }) - it('keeps Edit > Paste on the native Electron paste role in this split', () => { + it('routes Edit > Paste through Orca coordinated paste ownership', () => { const send = vi.fn() getFocusedWindowMock.mockReturnValue({ webContents: { send } }) registerAppMenu(buildMenuOptions()) const editSubmenu = getSubmenu(getTemplate(), 'Edit') - const pasteItem = editSubmenu.find((item) => item.role === 'paste') + const pasteItem = editSubmenu.find((item) => item.label === 'Paste') expect(pasteItem).toBeDefined() - expect(pasteItem?.click).toBeUndefined() - expect(send).not.toHaveBeenCalled() + expect(pasteItem?.role).toBeUndefined() + expect(pasteItem?.accelerator).toBe('CmdOrCtrl+V') + + pasteItem?.click?.({} as never, {} as never, {} as never) + + expect(send).toHaveBeenCalledWith('ui:appMenuPaste') }) it.runIf(!isMac)('puts Settings and Exit under File on Windows/Linux', () => { diff --git a/src/main/menu/register-app-menu.ts b/src/main/menu/register-app-menu.ts index eda524d44a6..a5390e36afc 100644 --- a/src/main/menu/register-app-menu.ts +++ b/src/main/menu/register-app-menu.ts @@ -162,7 +162,15 @@ function buildAndApplyMenu(options: RegisterAppMenuOptions): void { { type: 'separator' }, { role: 'cut' }, { role: 'copy' }, - { role: 'paste' }, + { + label: translateMain('menu.paste', 'Paste'), + accelerator: 'CmdOrCtrl+V', + click: () => { + // Why: a focused terminal/native-chat pane is not a native editable + // control, so raw Electron paste cannot know which Orca surface owns it. + BrowserWindow.getFocusedWindow()?.webContents.send('ui:appMenuPaste') + } + }, { role: 'selectAll' } ] } diff --git a/src/main/native-chat/session-file-resolver.test.ts b/src/main/native-chat/session-file-resolver.test.ts new file mode 100644 index 00000000000..9e41174d169 --- /dev/null +++ b/src/main/native-chat/session-file-resolver.test.ts @@ -0,0 +1,163 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, describe, expect, it } from 'vitest' +import { resolveSessionFilePath } from './session-file-resolver' + +let tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +async function makeRoot(prefix: string): Promise { + const root = await mkdtemp(join(tmpdir(), prefix)) + tempRoots.push(root) + return root +} + +function restoreEnv(key: string, previous: string | undefined): void { + if (previous === undefined) { + delete process.env[key] + } else { + process.env[key] = previous + } +} + +describe('resolveSessionFilePath', () => { + it('globs Claude project subdirs for .jsonl', async () => { + const root = await makeRoot('orca-native-chat-resolve-claude-') + const claudeProjectsDir = join(root, 'claude-projects') + const projectDir = join(claudeProjectsDir, '-Users-ada-repo') + await mkdir(projectDir, { recursive: true }) + const target = join(projectDir, 'sess-123.jsonl') + await writeFile(target, '{}\n') + + const resolved = await resolveSessionFilePath('claude', 'sess-123', { claudeProjectsDir }) + expect(resolved).toBe(target) + }) + + it('matches Codex rollout files by session id suffix', async () => { + const root = await makeRoot('orca-native-chat-resolve-codex-') + const codexSessionsDir = join(root, 'codex-sessions') + const dayDir = join(codexSessionsDir, '2026', '06', '04') + await mkdir(dayDir, { recursive: true }) + const target = join(dayDir, 'rollout-2026-06-04T10-00-00-abc-session.jsonl') + await writeFile(target, '{}\n') + + const resolved = await resolveSessionFilePath('codex', 'abc-session', { + codexSessionsDirs: [codexSessionsDir] + }) + expect(resolved).toBe(target) + }) + + it('resolves a rollout from the orca-managed Codex home (ORCA_USER_DATA_PATH)', async () => { + // Orca launches Codex with its own managed CODEX_HOME, so rollout files land + // under /codex-runtime-home/home/sessions, NOT ~/.codex/sessions. + const root = await makeRoot('orca-native-chat-resolve-managed-') + const managedSessionsDir = join(root, 'codex-runtime-home', 'home', 'sessions') + const dayDir = join(managedSessionsDir, '2026', '06', '19') + await mkdir(dayDir, { recursive: true }) + const target = join(dayDir, 'rollout-2026-06-19T04-20-39-019edf9c-managed.jsonl') + await writeFile(target, '{}\n') + + const previous = process.env.ORCA_USER_DATA_PATH + process.env.ORCA_USER_DATA_PATH = root + try { + const resolved = await resolveSessionFilePath('codex', '019edf9c-managed') + expect(resolved).toBe(target) + } finally { + if (previous === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = previous + } + } + }) + + it('falls back to CODEX_HOME when the managed home has no match', async () => { + const root = await makeRoot('orca-native-chat-resolve-codex-home-') + const managedRoot = join(root, 'managed-userdata') + await mkdir(managedRoot, { recursive: true }) + const codexHome = join(root, 'custom-codex-home') + const dayDir = join(codexHome, 'sessions', '2026', '06', '05') + await mkdir(dayDir, { recursive: true }) + const target = join(dayDir, 'rollout-xyz-session.jsonl') + await writeFile(target, '{}\n') + + const previousCodex = process.env.CODEX_HOME + const previousUserData = process.env.ORCA_USER_DATA_PATH + process.env.CODEX_HOME = codexHome + // Point the managed home at an empty dir so the fallback is exercised. + process.env.ORCA_USER_DATA_PATH = managedRoot + try { + const resolved = await resolveSessionFilePath('codex', 'xyz-session') + expect(resolved).toBe(target) + } finally { + restoreEnv('CODEX_HOME', previousCodex) + restoreEnv('ORCA_USER_DATA_PATH', previousUserData) + } + }) + + it('returns null when no transcript matches', async () => { + const root = await makeRoot('orca-native-chat-resolve-missing-') + const claudeProjectsDir = join(root, 'claude-projects') + await mkdir(claudeProjectsDir, { recursive: true }) + expect(await resolveSessionFilePath('claude', 'nope', { claudeProjectsDir })).toBeNull() + }) + + it('returns null for unsupported agents', async () => { + expect(await resolveSessionFilePath('gemini', 'whatever')).toBeNull() + }) + + it('prefers the hook transcriptPath when it exists (Claude id != file name)', async () => { + // Recent Claude Code names the file with a UUID that differs from the hook + // session_id, so the id glob would miss it — but transcript_path is exact. + const root = await makeRoot('orca-native-chat-resolve-path-') + const claudeProjectsDir = join(root, 'claude-projects') + const projectDir = join(claudeProjectsDir, '-Users-ada-repo') + await mkdir(projectDir, { recursive: true }) + // The real transcript is named by a DIFFERENT id than the hook session id. + const realFile = join(projectDir, 'real-file-uuid.jsonl') + await writeFile(realFile, '{}\n') + + const resolved = await resolveSessionFilePath('claude', 'hook-session-id', { + claudeProjectsDir, + transcriptPath: realFile + }) + expect(resolved).toBe(realFile) + }) + + it('falls back to the id glob when the hook transcriptPath does not exist', async () => { + const root = await makeRoot('orca-native-chat-resolve-path-stale-') + const claudeProjectsDir = join(root, 'claude-projects') + const projectDir = join(claudeProjectsDir, '-Users-ada-repo') + await mkdir(projectDir, { recursive: true }) + const target = join(projectDir, 'sess-xyz.jsonl') + await writeFile(target, '{}\n') + + const resolved = await resolveSessionFilePath('claude', 'sess-xyz', { + claudeProjectsDir, + transcriptPath: join(projectDir, 'does-not-exist.jsonl') + }) + expect(resolved).toBe(target) + }) + + it('ignores a non-jsonl transcriptPath and falls back to the glob', async () => { + const root = await makeRoot('orca-native-chat-resolve-path-ext-') + const claudeProjectsDir = join(root, 'claude-projects') + const projectDir = join(claudeProjectsDir, '-Users-ada-repo') + await mkdir(projectDir, { recursive: true }) + const bogus = join(projectDir, 'not-a-transcript.txt') + await writeFile(bogus, 'x') + const target = join(projectDir, 'sess-ok.jsonl') + await writeFile(target, '{}\n') + + const resolved = await resolveSessionFilePath('claude', 'sess-ok', { + claudeProjectsDir, + transcriptPath: bogus + }) + expect(resolved).toBe(target) + }) +}) diff --git a/src/main/native-chat/session-file-resolver.ts b/src/main/native-chat/session-file-resolver.ts new file mode 100644 index 00000000000..7e94969408f --- /dev/null +++ b/src/main/native-chat/session-file-resolver.ts @@ -0,0 +1,117 @@ +import { existsSync } from 'fs' +import { homedir } from 'os' +import { basename, extname, join } from 'path' +import type { AgentType } from '../../shared/native-chat-types' +import { walkSessionFiles } from '../ai-vault/session-scanner-discovery' +import { getOrcaManagedCodexHomePath } from '../codex/codex-home-paths' + +// Why: these mirror the path constants in ai-vault/session-scanner.ts. Reads +// run in the main process against the runtime's own home directory; over SSH +// the remote main resolves its local home, so we never hardcode an absolute +// user path — homedir()/CODEX_HOME resolution stays runtime-relative and is +// computed per call (not at module load) so it tracks the live home. +function claudeProjectsDir(): string { + return join(homedir(), '.claude', 'projects') +} + +// Why: Orca launches Codex with ORCA_CODEX_HOME pointing at its own managed +// runtime home, so Orca-started Codex rollout files land under +// `/sessions`, NOT `~/.codex/sessions`. Search the managed home +// first (that's where this main process's Codex sessions actually live), then +// fall back to CODEX_HOME/~/.codex so a non-Orca Codex transcript still resolves. +// Duplicates are filtered so a managed-home symlink to ~/.codex isn't scanned twice. +function codexSessionsDirs(): string[] { + const candidates = [ + join(getOrcaManagedCodexHomePath(), 'sessions'), + join(process.env.CODEX_HOME?.trim() || join(homedir(), '.codex'), 'sessions') + ] + return candidates.filter((dir, index) => candidates.indexOf(dir) === index) +} + +export type ResolveSessionFileOptions = { + /** Override the Claude projects root (used by tests / isolated scans). */ + claudeProjectsDir?: string + /** Override the Codex sessions roots, searched in order (tests / isolated + * scans). Defaults to the orca-managed home then CODEX_HOME/~/.codex. */ + codexSessionsDirs?: string[] + /** Authoritative transcript path reported by the agent hook + * (`providerSession.transcriptPath`). When set and the file exists, it is used + * directly — recent Claude Code names the transcript with a UUID that differs + * from the hook session_id, so the id-based glob below would miss it. */ + transcriptPath?: string +} + +/** + * Resolve the on-disk JSONL transcript path for a given agent + session id. + * + * Prefers the hook-reported `transcriptPath` when it exists on disk (authoritative). + * Otherwise: Claude nests transcripts by project slug + * (`~/.claude/projects//.jsonl`), so we glob the projects subdirs for + * `.jsonl`. Codex stores rollout files under date-nested dirs whose file name + * embeds the session id, so we match by the session id appearing in the file name. + * Returns null when no matching transcript exists. + */ +export async function resolveSessionFilePath( + agent: AgentType, + sessionId: string, + options: ResolveSessionFileOptions = {} +): Promise { + // Why: the hook's transcript_path is the exact file the agent is writing, so it + // beats reconstructing a path from the session id. Guard with existsSync so a + // stale/remote path falls through to the id-based search rather than returning + // a non-existent file. + const hookPath = options.transcriptPath?.trim() + if (hookPath && extname(hookPath) === '.jsonl' && existsSync(hookPath)) { + return hookPath + } + + const trimmedId = sessionId.trim() + if (!trimmedId) { + return null + } + + if (agent === 'claude') { + return resolveClaudeSessionFile(trimmedId, options.claudeProjectsDir ?? claudeProjectsDir()) + } + if (agent === 'codex') { + return resolveCodexSessionFile(trimmedId, options.codexSessionsDirs ?? codexSessionsDirs()) + } + return null +} + +async function resolveClaudeSessionFile( + sessionId: string, + projectsDir: string +): Promise { + const targetName = `${sessionId}.jsonl` + const files = await walkSessionFiles(projectsDir, 'claude', [], { + extensions: new Set(['.jsonl']), + filePredicate: (path) => basename(path) === targetName + }) + return files[0] ?? null +} + +async function resolveCodexSessionFile( + sessionId: string, + sessionsDirs: string[] +): Promise { + // Codex rollout file names embed the session id (rollout--.jsonl), so + // match the id as a suffix of the file's base name rather than an exact name. + // Search each candidate root (managed home first) and stop at the first match. + for (const sessionsDir of sessionsDirs) { + if (!existsSync(sessionsDir)) { + continue + } + const files = await walkSessionFiles(sessionsDir, 'codex', [], { + extensions: new Set(['.jsonl']), + filePredicate: (path) => { + const name = basename(path, extname(path)) + return name === sessionId || name.endsWith(`-${sessionId}`) + } + }) + if (files[0]) { + return files[0] + } + } + return null +} diff --git a/src/main/native-chat/transcript-line-decoders.ts b/src/main/native-chat/transcript-line-decoders.ts new file mode 100644 index 00000000000..1a428cbb98a --- /dev/null +++ b/src/main/native-chat/transcript-line-decoders.ts @@ -0,0 +1,185 @@ +// Per-line record→NativeChatMessage decoders, shared by the full transcript +// reader (transcript-reader.ts) and the live tailer (transcript-watch.ts) so +// both paths apply identical record-shape mapping. Each decoder is stateless: +// it takes a single JSONL line plus a stable fallback id and returns one message +// or null (unknown/empty records are skipped, never thrown — plan KTD risk: +// schema drift). `fallbackId` is used only when the record carries no intrinsic +// id; the caller supplies a value unique per line. + +import type { NativeChatBlock, NativeChatMessage } from '../../shared/native-chat-types' +import { + asRecord, + extractString, + parseJsonObject, + timestampMs +} from '../ai-vault/session-scanner-values' +import { claudeContentBlocks, toolResultOutput } from './transcript-record-blocks' + +export function decodeClaudeTranscriptLine( + line: string, + fallbackId: string +): NativeChatMessage | null { + const record = parseJsonObject(line) + if (!record) { + return null + } + const role = record.type + if (role !== 'user' && role !== 'assistant') { + return null + } + const message = asRecord(record.message) + const blocks = claudeContentBlocks(message?.content) + if (blocks.length === 0) { + return null + } + const messageId = extractString(record.uuid) ?? extractString(message?.id) + return { + id: messageId ?? fallbackId, + role: claudeMessageRole(role, blocks), + blocks, + timestamp: parseTimestamp(record.timestamp), + source: 'transcript' + } +} + +// Claude marks reasoning via `thinking` content blocks; when a message is made +// up solely of reasoning, surface it as a reasoning-role message. +function claudeMessageRole( + role: 'user' | 'assistant', + blocks: NativeChatBlock[] +): NativeChatMessage['role'] { + if (role === 'user') { + const onlyToolResults = blocks.every((block) => block.type === 'tool-result') + return onlyToolResults && blocks.length > 0 ? 'tool' : 'user' + } + return role +} + +export function decodeCodexTranscriptLine( + line: string, + fallbackId: string +): NativeChatMessage | null { + const record = parseJsonObject(line) + if (!record) { + return null + } + const payload = asRecord(record.payload) + if (!payload) { + return null + } + const timestamp = parseTimestamp(record.timestamp) + const baseId = extractString(payload.id) ?? fallbackId + + if (record.type === 'response_item') { + return codexResponseItem(payload, baseId, timestamp) + } + if (record.type === 'event_msg') { + return codexEventMessage(payload, baseId, timestamp) + } + return null +} + +function codexResponseItem( + payload: Record, + id: string, + timestamp: number | null +): NativeChatMessage | null { + if (payload.type === 'message') { + const blocks = claudeContentBlocks(payload.content) + if (blocks.length === 0) { + return null + } + const role = + payload.role === 'assistant' ? 'assistant' : payload.role === 'user' ? 'user' : 'system' + return { id, role, blocks, timestamp, source: 'transcript' } + } + if (payload.type === 'reasoning') { + const text = extractString(payload.text) ?? codexSummaryText(payload.summary) + if (!text) { + return null + } + return { + id, + role: 'reasoning', + blocks: [{ type: 'text', text }], + timestamp, + source: 'transcript' + } + } + if (payload.type === 'function_call' || payload.type === 'local_shell_call') { + const name = extractString(payload.name) ?? 'tool' + return { + id, + role: 'assistant', + blocks: [{ type: 'tool-call', name, input: codexCallInput(payload) }], + timestamp, + source: 'transcript' + } + } + if (payload.type === 'function_call_output') { + return { + id, + role: 'tool', + blocks: [codexToolResult(payload.output)], + timestamp, + source: 'transcript' + } + } + return null +} + +function codexEventMessage( + payload: Record, + id: string, + timestamp: number | null +): NativeChatMessage | null { + if (payload.type === 'user_message') { + const text = extractString(payload.message) + return text + ? { id, role: 'user', blocks: [{ type: 'text', text }], timestamp, source: 'transcript' } + : null + } + if (payload.type === 'agent_message') { + const text = extractString(payload.message) + return text + ? { id, role: 'assistant', blocks: [{ type: 'text', text }], timestamp, source: 'transcript' } + : null + } + return null +} + +function codexCallInput(payload: Record): unknown { + if (payload.arguments !== undefined) { + return payload.arguments + } + return payload.input ?? payload.action ?? null +} + +function codexToolResult(output: unknown): NativeChatBlock { + const record = asRecord(output) + const isError = record?.success === false || record?.is_error === true + return { + type: 'tool-result', + output: toolResultOutput(record?.content ?? record?.output ?? output), + ...(isError ? { isError: true } : {}) + } +} + +function codexSummaryText(summary: unknown): string | null { + if (!Array.isArray(summary)) { + return null + } + const parts: string[] = [] + for (const item of summary) { + const text = extractString(asRecord(item)?.text) ?? extractString(item) + if (text) { + parts.push(text) + } + } + return parts.length ? parts.join('\n') : null +} + +function parseTimestamp(value: unknown): number | null { + const parsed = timestampMs(value) + return Number.isFinite(parsed) ? parsed : null +} diff --git a/src/main/native-chat/transcript-read-cache.test.ts b/src/main/native-chat/transcript-read-cache.test.ts new file mode 100644 index 00000000000..04fd40355aa --- /dev/null +++ b/src/main/native-chat/transcript-read-cache.test.ts @@ -0,0 +1,92 @@ +import { mkdir, mkdtemp, rm, utimes, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as TranscriptReader from './transcript-reader' + +// Spy on the underlying reader so we can assert cache hits issue zero reads. +const readSpy = vi.hoisted(() => vi.fn()) +vi.mock('./transcript-reader', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + readNativeChatTranscript: (...args: Parameters) => { + readSpy(...args) + return actual.readNativeChatTranscript(...args) + } + } +}) + +import { + clearNativeChatTranscriptCache, + readNativeChatTranscriptCached +} from './transcript-read-cache' + +let tempRoots: string[] = [] + +function jsonLines(records: unknown[]): string { + return records.map((record) => JSON.stringify(record)).join('\n') +} + +async function seedSession(sessionId: string, turns: number): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-cache-')) + tempRoots.push(root) + const projectDir = join(root, '.claude', 'projects', '-repo') + await mkdir(projectDir, { recursive: true }) + const records = Array.from({ length: turns }, (_unused, n) => ({ + type: 'user', + uuid: `u-${n}`, + timestamp: `2026-06-01T10:00:0${n}.000Z`, + message: { role: 'user', content: `m${n}` } + })) + const filePath = join(projectDir, `${sessionId}.jsonl`) + await writeFile(filePath, jsonLines(records)) + process.env.HOME = root + return filePath +} + +beforeEach(() => { + clearNativeChatTranscriptCache() + readSpy.mockClear() +}) + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +describe('readNativeChatTranscriptCached', () => { + it('returns the same cached object on an mtime hit without re-reading', async () => { + await seedSession('sess-hit', 3) + const first = await readNativeChatTranscriptCached('claude', 'sess-hit') + const second = await readNativeChatTranscriptCached('claude', 'sess-hit') + expect(readSpy).toHaveBeenCalledTimes(1) + // Same reference: the second call served the cached parse. + expect(second).toBe(first) + }) + + it('re-reads when the file mtime changes', async () => { + const filePath = await seedSession('sess-mtime', 2) + await readNativeChatTranscriptCached('claude', 'sess-mtime') + expect(readSpy).toHaveBeenCalledTimes(1) + // Bump mtime into the future to invalidate without changing content shape. + const future = new Date(Date.now() + 5_000) + await utimes(filePath, future, future) + await readNativeChatTranscriptCached('claude', 'sess-mtime') + expect(readSpy).toHaveBeenCalledTimes(2) + }) + + it('clear() empties the cache so the next read re-reads', async () => { + await seedSession('sess-clear', 1) + await readNativeChatTranscriptCached('claude', 'sess-clear') + clearNativeChatTranscriptCache() + await readNativeChatTranscriptCached('claude', 'sess-clear') + expect(readSpy).toHaveBeenCalledTimes(2) + }) + + it('returns an error result for an unknown session without throwing', async () => { + await seedSession('present', 1) + const result = await readNativeChatTranscriptCached('claude', 'absent') + expect('error' in result && result.error).toBeTruthy() + }) +}) diff --git a/src/main/native-chat/transcript-read-cache.ts b/src/main/native-chat/transcript-read-cache.ts new file mode 100644 index 00000000000..84151f1d590 --- /dev/null +++ b/src/main/native-chat/transcript-read-cache.ts @@ -0,0 +1,90 @@ +import { stat } from 'fs/promises' +import type { AgentType } from '../../shared/native-chat-types' +import { resolveSessionFilePath } from './session-file-resolver' +import { readNativeChatTranscript, type ReadTranscriptResult } from './transcript-reader' + +// Why: both the desktop IPC handler and the runtime RPC handler read the same +// host-filesystem transcript, so a single process-global cache keyed by +// agent:sessionId maximizes the hit rate across desktop + every paired +// web/mobile client. Keying by connection instead would defeat the multi-client +// case this feature targets and multiply memory by the connection count. +// The cache stores ONE canonical, unwindowed parse; windowing and per-surface +// truncation stay in the callers so the same parse is reused across all `limit` +// values and every client kind. + +type CachedTranscript = { + result: ReadTranscriptResult + /** mtime of the resolved file when cached; a newer mtime invalidates it. */ + mtimeMs: number +} + +const cache = new Map() + +// Why: cap the cache so a long-lived process browsing many sessions can't grow +// it unbounded. Map preserves insertion order, so evicting the first key drops +// the oldest entry (a simple LRU once re-inserts bump recency; see setCached). +// Entry-count cap is fine for v1; a byte-aware cap is the follow-up if profiling +// shows RSS pressure now that one process serves many remote clients. +const MAX_CACHE_ENTRIES = 50 + +function setCached(key: string, value: CachedTranscript): void { + // Re-insert moves the key to the most-recent position for LRU eviction. + cache.delete(key) + cache.set(key, value) + while (cache.size > MAX_CACHE_ENTRIES) { + const oldest = cache.keys().next().value + if (oldest === undefined) { + break + } + cache.delete(oldest) + } +} + +function cacheKey(agent: AgentType, sessionId: string): string { + return `${agent}:${sessionId}` +} + +async function fileMtimeMs(filePath: string): Promise { + try { + return (await stat(filePath)).mtimeMs + } catch { + return Number.NaN + } +} + +/** + * Read the full transcript for an agent + session, returning the cached parse on + * an mtime hit and re-reading (and re-caching) when the file changed. Returns the + * canonical, unwindowed result; callers apply their own windowing/truncation. + */ +export async function readNativeChatTranscriptCached( + agent: AgentType, + sessionId: string, + /** Hook-reported authoritative transcript path, preferred over the id glob. */ + transcriptPath?: string +): Promise { + const filePath = await resolveSessionFilePath(agent, sessionId, { transcriptPath }) + if (!filePath) { + return { error: `No transcript found for ${agent} session ${sessionId}` } + } + + const key = cacheKey(agent, sessionId) + const mtimeMs = await fileMtimeMs(filePath) + const cached = cache.get(key) + if (cached && Number.isFinite(mtimeMs) && cached.mtimeMs === mtimeMs) { + // Bump recency so a frequently-read session survives eviction. + setCached(key, cached) + return cached.result + } + + const result = await readNativeChatTranscript(agent, sessionId, { filePath }) + if (Number.isFinite(mtimeMs)) { + setCached(key, { result, mtimeMs }) + } + return result +} + +/** Test-only: drop the per-session transcript cache between runs. */ +export function clearNativeChatTranscriptCache(): void { + cache.clear() +} diff --git a/src/main/native-chat/transcript-reader.test.ts b/src/main/native-chat/transcript-reader.test.ts new file mode 100644 index 00000000000..864715011d1 --- /dev/null +++ b/src/main/native-chat/transcript-reader.test.ts @@ -0,0 +1,192 @@ +import { mkdtemp, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, describe, expect, it } from 'vitest' +import { readNativeChatTranscript } from './transcript-reader' + +let tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +function jsonLines(records: unknown[]): string { + return records.map((record) => JSON.stringify(record)).join('\n') +} + +async function writeFixture(prefix: string, records: unknown[]): Promise { + const root = await mkdtemp(join(tmpdir(), prefix)) + tempRoots.push(root) + const filePath = join(root, 'transcript.jsonl') + await writeFile(filePath, jsonLines(records)) + return filePath +} + +describe('readNativeChatTranscript (claude)', () => { + it('returns ordered user/assistant/tool messages with no 5-message cap', async () => { + const records: unknown[] = [] + // 4 user/assistant turns = 8 messages, well past the AI-Vault preview cap. + for (let turn = 0; turn < 4; turn++) { + records.push({ + type: 'user', + uuid: `u-${turn}`, + timestamp: `2026-06-01T10:0${turn}:00.000Z`, + message: { role: 'user', content: `Prompt **${turn}**` } + }) + records.push({ + type: 'assistant', + uuid: `a-${turn}`, + timestamp: `2026-06-01T10:0${turn}:30.000Z`, + message: { role: 'assistant', content: [{ type: 'text', text: `Reply _${turn}_` }] } + }) + } + // A tool_use then a tool_result (carried on a user record). + records.push({ + type: 'assistant', + uuid: 'a-tool', + timestamp: '2026-06-01T10:05:00.000Z', + message: { + role: 'assistant', + content: [{ type: 'tool_use', name: 'Bash', input: { command: 'ls' } }] + } + }) + records.push({ + type: 'user', + uuid: 'u-toolresult', + timestamp: '2026-06-01T10:05:01.000Z', + message: { + role: 'user', + content: [{ type: 'tool_result', content: 'file-a\nfile-b', is_error: false }] + } + }) + + const filePath = await writeFixture('orca-native-chat-claude-', records) + const result = await readNativeChatTranscript('claude', 'sess', { filePath }) + expect('messages' in result).toBe(true) + if (!('messages' in result)) { + return + } + + expect(result.messages.length).toBe(10) + expect(result.messages.length).toBeGreaterThan(5) + expect(result.messages[0]).toMatchObject({ role: 'user', source: 'transcript' }) + // Markdown text preserved verbatim. + expect(result.messages[0].blocks[0]).toEqual({ type: 'text', text: 'Prompt **0**' }) + expect(result.messages[1].blocks[0]).toEqual({ type: 'text', text: 'Reply _0_' }) + + const toolCall = result.messages.find((m) => m.blocks[0]?.type === 'tool-call') + expect(toolCall?.blocks[0]).toEqual({ + type: 'tool-call', + name: 'Bash', + input: { command: 'ls' } + }) + + const toolResult = result.messages.at(-1) + expect(toolResult?.role).toBe('tool') + expect(toolResult?.blocks[0]).toEqual({ type: 'tool-result', output: 'file-a\nfile-b' }) + }) + + it('marks thinking-only assistant content as a reasoning surface', async () => { + const filePath = await writeFixture('orca-native-chat-claude-think-', [ + { + type: 'assistant', + uuid: 'a-think', + timestamp: '2026-06-01T10:00:00.000Z', + message: { role: 'assistant', content: [{ type: 'thinking', thinking: 'pondering' }] } + } + ]) + const result = await readNativeChatTranscript('claude', 'sess', { filePath }) + if (!('messages' in result)) { + throw new Error('expected messages') + } + expect(result.messages[0].blocks[0]).toEqual({ type: 'text', text: 'pondering' }) + }) +}) + +describe('readNativeChatTranscript (codex)', () => { + it('maps tool calls and results to tool-call/tool-result blocks', async () => { + const filePath = await writeFixture('orca-native-chat-codex-', [ + { + type: 'session_meta', + timestamp: '2026-06-01T10:00:00.000Z', + payload: { id: 'codex-sess', cwd: '/repo' } + }, + { + type: 'response_item', + timestamp: '2026-06-01T10:00:01.000Z', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'text', text: 'Run the build' }] + } + }, + { + type: 'response_item', + timestamp: '2026-06-01T10:00:02.000Z', + payload: { type: 'reasoning', summary: [{ type: 'summary_text', text: 'I will run it' }] } + }, + { + type: 'response_item', + timestamp: '2026-06-01T10:00:03.000Z', + payload: { + type: 'function_call', + name: 'shell', + arguments: '{"command":["bash","-lc","make"]}' + } + }, + { + type: 'response_item', + timestamp: '2026-06-01T10:00:04.000Z', + payload: { + type: 'function_call_output', + output: { content: 'build ok', success: true } + } + }, + { + type: 'response_item', + timestamp: '2026-06-01T10:00:05.000Z', + payload: { type: 'message', role: 'assistant', content: [{ type: 'text', text: 'Done.' }] } + } + ]) + + const result = await readNativeChatTranscript('codex', 'codex-sess', { filePath }) + if (!('messages' in result)) { + throw new Error(`expected messages, got error`) + } + + const roles = result.messages.map((m) => m.role) + expect(roles).toEqual(['user', 'reasoning', 'assistant', 'tool', 'assistant']) + + const call = result.messages.find((m) => m.blocks[0]?.type === 'tool-call') + expect(call?.blocks[0]).toEqual({ + type: 'tool-call', + name: 'shell', + input: '{"command":["bash","-lc","make"]}' + }) + + const toolResult = result.messages.find((m) => m.blocks[0]?.type === 'tool-result') + expect(toolResult?.blocks[0]).toEqual({ type: 'tool-result', output: 'build ok' }) + + const reasoning = result.messages.find((m) => m.role === 'reasoning') + expect(reasoning?.blocks[0]).toEqual({ type: 'text', text: 'I will run it' }) + }) +}) + +describe('readNativeChatTranscript (errors)', () => { + it('returns an error for an unreadable/missing file without throwing', async () => { + const result = await readNativeChatTranscript('claude', 'sess', { + filePath: join(tmpdir(), 'orca-native-chat-does-not-exist.jsonl') + }) + expect('error' in result).toBe(true) + }) + + it('returns an error when no transcript can be resolved', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-noresolve-')) + tempRoots.push(root) + const result = await readNativeChatTranscript('claude', 'missing', { + claudeProjectsDir: join(root, 'empty') + }) + expect('error' in result).toBe(true) + }) +}) diff --git a/src/main/native-chat/transcript-reader.ts b/src/main/native-chat/transcript-reader.ts new file mode 100644 index 00000000000..7fe13f0aadf --- /dev/null +++ b/src/main/native-chat/transcript-reader.ts @@ -0,0 +1,66 @@ +import { createReadStream } from 'fs' +import { createInterface } from 'readline' +import type { AgentType, NativeChatMessage } from '../../shared/native-chat-types' +import { errorMessage } from '../ai-vault/session-scanner-values' +import { resolveSessionFilePath, type ResolveSessionFileOptions } from './session-file-resolver' +import { decodeClaudeTranscriptLine, decodeCodexTranscriptLine } from './transcript-line-decoders' + +export type ReadTranscriptResult = { messages: NativeChatMessage[] } | { error: string } + +export type ReadTranscriptOptions = ResolveSessionFileOptions & { + /** Resolve directly to this file, skipping path discovery (used by tests). */ + filePath?: string +} + +/** + * Read the ENTIRE Claude/Codex JSONL transcript for an agent + session id into + * the NativeChatMessage model. Unlike the AI-Vault preview scan, this applies + * NO message cap. Unknown record types are skipped rather than throwing, so a + * single malformed/unrecognized line cannot fail the whole read. The per-line + * record→message mapping is shared with the live tailer (transcript-watch.ts) + * via transcript-line-decoders.ts. + */ +export async function readNativeChatTranscript( + agent: AgentType, + sessionId: string, + options: ReadTranscriptOptions = {} +): Promise { + const filePath = options.filePath ?? (await resolveSessionFilePath(agent, sessionId, options)) + if (!filePath) { + return { error: `No transcript found for ${agent} session ${sessionId}` } + } + try { + if (agent === 'claude') { + return { messages: await readTranscript(filePath, decodeClaudeTranscriptLine) } + } + if (agent === 'codex') { + return { messages: await readTranscript(filePath, decodeCodexTranscriptLine) } + } + return { error: `Unsupported agent for native chat transcript: ${agent}` } + } catch (err) { + return { error: errorMessage(err) } + } +} + +async function readTranscript( + filePath: string, + decode: (line: string, fallbackId: string) => NativeChatMessage | null +): Promise { + const reader = createInterface({ + input: createReadStream(filePath, { encoding: 'utf-8' }), + crlfDelay: Infinity + }) + const messages: NativeChatMessage[] = [] + let index = 0 + for await (const line of reader) { + // Why: fallback id embeds start offset 0 so it matches the live tailer's id + // for the same record (the tailer's first drain reads from offset 0 too). + // Records that re-emit then collapse by id in the assembler — no dup, no drop. + const message = decode(line, `${filePath}:0:${index}`) + if (message) { + messages.push(message) + } + index++ + } + return messages +} diff --git a/src/main/native-chat/transcript-record-blocks.ts b/src/main/native-chat/transcript-record-blocks.ts new file mode 100644 index 00000000000..6355df277e5 --- /dev/null +++ b/src/main/native-chat/transcript-record-blocks.ts @@ -0,0 +1,117 @@ +// Centralized record→block mapping for native-chat transcripts. Kept separate +// from the reader so the Claude and Codex per-record decoders share one place +// to evolve as CLI transcript schemas drift (plan KTD risk: schema drift). + +import type { + NativeChatBlock, + NativeChatImageRefBlock, + NativeChatToolResultBlock +} from '../../shared/native-chat-types' +import { asRecord, extractString } from '../ai-vault/session-scanner-values' + +/** Coerce an arbitrary tool-result payload into a single output string. */ +export function toolResultOutput(value: unknown): string { + if (typeof value === 'string') { + return value + } + if (!Array.isArray(value)) { + const record = asRecord(value) + if (record) { + const text = extractString(record.text) ?? extractString(record.content) + if (text) { + return text + } + } + return value === undefined || value === null ? '' : JSON.stringify(value) + } + const parts: string[] = [] + for (const item of value) { + if (typeof item === 'string') { + parts.push(item) + continue + } + const record = asRecord(item) + const text = extractString(record?.text) ?? extractString(record?.content) + if (text) { + parts.push(text) + } + } + return parts.join('\n') +} + +/** Build the blocks for one Claude content array (string or block[]). */ +export function claudeContentBlocks(content: unknown): NativeChatBlock[] { + if (typeof content === 'string') { + const text = content.trim() + return text ? [{ type: 'text', text: content }] : [] + } + if (!Array.isArray(content)) { + return [] + } + const blocks: NativeChatBlock[] = [] + for (const item of content) { + if (typeof item === 'string') { + if (item.trim()) { + blocks.push({ type: 'text', text: item }) + } + continue + } + const record = asRecord(item) + if (!record) { + continue + } + const block = claudeContentBlock(record) + if (block) { + blocks.push(block) + } + } + return blocks +} + +function claudeContentBlock(record: Record): NativeChatBlock | null { + switch (record.type) { + case 'text': { + const text = extractString(record.text) + return text ? { type: 'text', text } : null + } + case 'thinking': { + // Reasoning surfaces as a text block; the message role marks it as reasoning. + const text = extractString(record.thinking) ?? extractString(record.text) + return text ? { type: 'text', text } : null + } + case 'tool_use': { + const name = extractString(record.name) ?? 'tool' + return { type: 'tool-call', name, input: record.input } + } + case 'tool_result': + return toolResultBlock(record) + case 'image': + return imageRefBlock(record) + default: + return null + } +} + +function toolResultBlock(record: Record): NativeChatToolResultBlock { + return { + type: 'tool-result', + output: toolResultOutput(record.content), + ...(record.is_error === true ? { isError: true } : {}) + } +} + +function imageRefBlock(record: Record): NativeChatImageRefBlock | null { + const source = asRecord(record.source) + const url = extractString(source?.url) ?? extractString(record.url) + const path = extractString(record.path) + const alt = extractString(record.alt) ?? undefined + if (!url && !path) { + return null + } + return { + type: 'image-ref', + ...(path ? { path } : {}), + ...(url ? { url } : {}), + ...(alt ? { alt } : {}) + } +} diff --git a/src/main/native-chat/transcript-watch.test.ts b/src/main/native-chat/transcript-watch.test.ts new file mode 100644 index 00000000000..9c1be087cbf --- /dev/null +++ b/src/main/native-chat/transcript-watch.test.ts @@ -0,0 +1,239 @@ +import { appendFile, mkdtemp, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../shared/native-chat-types' +import { getActiveNativeChatWatcherCount, subscribeNativeChatTranscript } from './transcript-watch' + +let tempRoots: string[] = [] + +beforeEach(() => { + tempRoots = [] +}) + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +async function tempFile(initial: string): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-watch-')) + tempRoots.push(root) + const filePath = join(root, 'rollout.jsonl') + await writeFile(filePath, initial) + return filePath +} + +function claudeLine(uuid: string, role: 'user' | 'assistant', text: string): string { + return `${JSON.stringify({ + type: role, + uuid, + timestamp: '2026-06-01T10:00:00.000Z', + message: { role, content: role === 'user' ? text : [{ type: 'text', text }] } + })}\n` +} + +async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise { + const start = Date.now() + while (!predicate()) { + if (Date.now() - start > timeoutMs) { + throw new Error('timed out waiting for condition') + } + await new Promise((resolve) => setTimeout(resolve, 10)) + } +} + +describe('subscribeNativeChatTranscript', () => { + it('re-emits from the top on first drain so appended turns are never dropped', async () => { + const filePath = await tempFile(claudeLine('u-1', 'user', 'first')) + const batches: NativeChatMessage[][] = [] + + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + onAppend: (messages) => batches.push(messages), + debounceMs: 5 + }) + + await appendFile(filePath, claudeLine('a-1', 'assistant', 'reply')) + await waitFor(() => batches.flat().some((m) => m.id === 'a-1')) + + sub.unsubscribe() + + // Seed-at-0 means the first drain re-reads the whole file; the assembler + // dedups by id. The appended turn must appear; the pre-existing line may + // appear too (collapsed downstream by id). + const ids = batches.flat().map((m) => m.id) + expect(ids).toContain('a-1') + }) + + it('appends a turn in the gap between initial read and first watcher drain exactly once', async () => { + // Simulate the read/subscribe race: a turn lands after the caller's + // readSession EOF but before the watcher's first drain. Seeding at 0 means + // the first drain reads it; the assembler later dedups by deterministic id. + const filePath = await tempFile(claudeLine('u-1', 'user', 'first')) + const seen: NativeChatMessage[] = [] + + // The gap turn is written BEFORE subscribe completes its first drain. + await appendFile(filePath, claudeLine('a-gap', 'assistant', 'raced reply')) + + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + onAppend: (messages) => seen.push(...messages), + debounceMs: 5 + }) + + await waitFor(() => seen.some((m) => m.id === 'a-gap')) + sub.unsubscribe() + + // The raced turn is present, and not duplicated within a single drain pass. + expect(seen.filter((m) => m.id === 'a-gap')).toHaveLength(1) + }) + + it('recovers cleanly when a read throws (subscription not left deaf)', async () => { + const filePath = await tempFile(claudeLine('u-1', 'user', 'hi')) + const seen: NativeChatMessage[] = [] + + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + onAppend: (messages) => seen.push(...messages), + debounceMs: 5 + }) + + // Make the file unreadable mid-flight (EACCES on the read path). The drain's + // try/catch must break and reset `reading` in finally so a later append + // still tails once permissions are restored. + await waitFor(() => seen.some((m) => m.id === 'u-1')) + const { chmod } = await import('fs/promises') + await chmod(filePath, 0o000) + await appendFile(filePath, claudeLine('a-1', 'assistant', 'reply')).catch(() => {}) + // Give the watcher a chance to attempt (and fail) a drain. + await new Promise((resolve) => setTimeout(resolve, 40)) + await chmod(filePath, 0o644) + await appendFile(filePath, claudeLine('a-2', 'assistant', 'recovered')) + + await waitFor(() => seen.some((m) => m.id === 'a-2')) + sub.unsubscribe() + expect(seen.some((m) => m.id === 'a-2')).toBe(true) + }) + + it('releases the watcher on unsubscribe (no leak)', async () => { + const filePath = await tempFile(claudeLine('u-1', 'user', 'hi')) + const before = getActiveNativeChatWatcherCount() + + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + onAppend: () => {}, + debounceMs: 5 + }) + expect(getActiveNativeChatWatcherCount()).toBe(before + 1) + + sub.unsubscribe() + expect(getActiveNativeChatWatcherCount()).toBe(before) + + // Idempotent: a second unsubscribe must not under-count. + sub.unsubscribe() + expect(getActiveNativeChatWatcherCount()).toBe(before) + }) + + it('coalesces rapid successive appends without dropping messages', async () => { + const filePath = await tempFile(claudeLine('u-1', 'user', 'hi')) + const seen: NativeChatMessage[] = [] + + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + onAppend: (messages) => seen.push(...messages), + debounceMs: 10 + }) + + // Fire several appends back-to-back within the debounce window. + await appendFile(filePath, claudeLine('a-1', 'assistant', 'one')) + await appendFile(filePath, claudeLine('a-2', 'assistant', 'two')) + await appendFile(filePath, claudeLine('a-3', 'assistant', 'three')) + + await waitFor(() => ['a-1', 'a-2', 'a-3'].every((id) => seen.some((m) => m.id === id))) + sub.unsubscribe() + + // Order is preserved for the appended turns (the seed re-read may also carry + // the pre-existing u-1, which the assembler dedups downstream). + const appendedIds = seen.map((m) => m.id).filter((id) => id !== 'u-1') + expect(appendedIds).toEqual(['a-1', 'a-2', 'a-3']) + }) + + it('waits for an incomplete trailing JSONL line before advancing the offset', async () => { + const filePath = await tempFile(claudeLine('u-1', 'user', 'hi')) + const seen: NativeChatMessage[] = [] + + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + onAppend: (messages) => seen.push(...messages), + debounceMs: 5 + }) + + await waitFor(() => seen.some((m) => m.id === 'u-1')) + + const line = claudeLine('a-partial', 'assistant', 'split reply') + const splitAt = Math.floor(line.length / 2) + await appendFile(filePath, line.slice(0, splitAt)) + await new Promise((resolve) => setTimeout(resolve, 40)) + expect(seen.some((m) => m.id === 'a-partial')).toBe(false) + + await appendFile(filePath, line.slice(splitAt)) + await waitFor(() => seen.some((m) => m.id === 'a-partial')) + + sub.unsubscribe() + expect(seen.filter((m) => m.id === 'a-partial')).toHaveLength(1) + }) + + it('survives file replacement / rotation (offset reset on shrink)', async () => { + const filePath = await tempFile( + claudeLine('u-1', 'user', 'old') + claudeLine('a-1', 'assistant', 'old-reply') + ) + const seen: NativeChatMessage[] = [] + + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + onAppend: (messages) => seen.push(...messages), + debounceMs: 5 + }) + + // Replace the file with shorter content (simulates rotation to a new, + // smaller session file at the same resolved path). + await writeFile(filePath, claudeLine('u-2', 'user', 'fresh')) + await waitFor(() => seen.some((m) => m.id === 'u-2')) + + // A subsequent append on the rotated file is still tailed. + await appendFile(filePath, claudeLine('a-2', 'assistant', 'fresh-reply')) + await waitFor(() => seen.some((m) => m.id === 'a-2')) + + sub.unsubscribe() + const ids = seen.map((m) => m.id) + expect(ids).toContain('u-2') + expect(ids).toContain('a-2') + }) + + it('returns a no-op unsubscribe when the file cannot be resolved', async () => { + const before = getActiveNativeChatWatcherCount() + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: '', + onAppend: () => {} + }) + expect(getActiveNativeChatWatcherCount()).toBe(before) + // Must not throw. + sub.unsubscribe() + }) +}) diff --git a/src/main/native-chat/transcript-watch.ts b/src/main/native-chat/transcript-watch.ts new file mode 100644 index 00000000000..ab202f59147 --- /dev/null +++ b/src/main/native-chat/transcript-watch.ts @@ -0,0 +1,254 @@ +// Live transcript tailing: watch a resolved session JSONL file and emit only +// the messages parsed from bytes appended since the last read. Modeled on the +// incremental byte-offset read in codex-usage/scanner.ts (parseCodexUsageFile's +// skipInitialBytes), but specialized to the NativeChatMessage record decoders. +// +// Teardown discipline (plan U4 risk: file-watch fd leaks): every subscription +// owns exactly one fs.FSWatcher and one debounce timer. unsubscribe() closes +// the watcher and clears the timer synchronously, and the module tracks the live +// watcher count so tests can assert no watcher survives teardown. + +import { watch, type FSWatcher } from 'fs' +import { open, stat } from 'fs/promises' +import type { Readable } from 'stream' +import type { AgentType, NativeChatMessage } from '../../shared/native-chat-types' +import { resolveSessionFilePath, type ResolveSessionFileOptions } from './session-file-resolver' +import { decodeClaudeTranscriptLine, decodeCodexTranscriptLine } from './transcript-line-decoders' + +export type SubscribeNativeChatTranscriptArgs = ResolveSessionFileOptions & { + agent: AgentType + sessionId: string + /** Called with the newly-appended messages whenever the file grows. Never + * called with an empty array. */ + onAppend: (messages: NativeChatMessage[]) => void + /** Resolve directly to this file, skipping path discovery (used by tests). */ + filePath?: string + /** Coalesce window for rapid fs.watch events (ms). Defaults to 40ms. */ + debounceMs?: number +} + +export type NativeChatTranscriptSubscription = { + /** Closes the watcher and releases the file handle. Idempotent. */ + unsubscribe: () => void +} + +// Why: a single watch event can fire several times for one append; we read from +// the last byte offset so re-entrant reads never re-emit prior messages. Each +// decoder is stateless per-line, so tailing reuses the same record→message +// mapping the full reader uses. +const DEFAULT_DEBOUNCE_MS = 40 + +// Why: process-wide count of live FSWatchers opened by this module. The U4 leak +// test asserts this returns to zero after unsubscribe so a forgotten handle is +// caught deterministically rather than relying on OS fd inspection. +let activeWatcherCount = 0 + +/** Test-only: number of fs watchers this module currently holds open. */ +export function getActiveNativeChatWatcherCount(): number { + return activeWatcherCount +} + +function lineDecoderForAgent( + agent: AgentType +): ((line: string, fallbackId: string) => NativeChatMessage | null) | null { + if (agent === 'claude') { + return decodeClaudeTranscriptLine + } + if (agent === 'codex') { + return decodeCodexTranscriptLine + } + return null +} + +async function fileSize(filePath: string): Promise { + try { + return (await stat(filePath)).size + } catch { + return 0 + } +} + +/** + * Read bytes [start, end) of the file and decode each complete line into a + * NativeChatMessage. Opens its own fd and always closes it (no leak on the read + * path, distinct from the long-lived watcher). Returns the messages plus the + * byte offset actually consumed so a partially-written trailing line is re-read + * on the next append rather than dropped. + */ +async function readAppendedMessages( + filePath: string, + start: number, + decode: (line: string, fallbackId: string) => NativeChatMessage | null +): Promise<{ messages: NativeChatMessage[]; consumedTo: number }> { + const end = await fileSize(filePath) + if (end <= start) { + // File shrank (rotation/replacement) or unchanged — caller resets offset. + return { messages: [], consumedTo: end } + } + + const handle = await open(filePath, 'r') + try { + const stream = handle.createReadStream({ + encoding: 'utf-8', + start, + end: end - 1, + autoClose: false + }) + const { messages, consumedBytes } = await decodeStreamLines(stream, filePath, start, decode) + return { messages, consumedTo: start + consumedBytes } + } finally { + await handle.close() + } +} + +async function decodeStreamLines( + stream: Readable, + filePath: string, + start: number, + decode: (line: string, fallbackId: string) => NativeChatMessage | null +): Promise<{ messages: NativeChatMessage[]; consumedBytes: number }> { + const text = await readStreamText(stream) + const completeEnd = text.lastIndexOf('\n') + if (completeEnd === -1) { + return { messages: [], consumedBytes: 0 } + } + + // Why: transcript writers can flush mid-record. Only advance through + // newline-terminated JSONL so invalid partial JSON is retried on the next + // append instead of being lost forever. + const completeText = text.slice(0, completeEnd + 1) + const lines = completeText.split('\n') + const messages: NativeChatMessage[] = [] + for (const [index, line] of lines.entries()) { + if (!line) { + continue + } + // Fallback id embeds the byte offset so ids stay stable+unique across + // appends even when a record carries no intrinsic id. + const message = decode(line, `${filePath}:${start}:${index}`) + if (message) { + messages.push(message) + } + } + return { messages, consumedBytes: Buffer.byteLength(completeText, 'utf8') } +} + +async function readStreamText(stream: Readable): Promise { + const chunks: string[] = [] + for await (const chunk of stream) { + chunks.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')) + } + return chunks.join('') +} + +/** + * Subscribe to live appends on an agent's transcript file. Returns an + * unsubscribe fn that tears the watcher down completely. + * + * Handles file rotation/replacement: when the file shrinks (a new session id + * resolved to a smaller/newer file, or the file was truncated), the offset is + * reset to 0 so the replacement's content is read from the top. + */ +export async function subscribeNativeChatTranscript( + args: SubscribeNativeChatTranscriptArgs +): Promise { + const { agent, sessionId, onAppend, debounceMs } = args + const decode = lineDecoderForAgent(agent) + const filePath = args.filePath ?? (await resolveSessionFilePath(agent, sessionId, args)) + + if (!filePath || !decode) { + // Nothing watchable — return a no-op teardown so callers can unconditionally + // unsubscribe without null-checks. + return { unsubscribe: () => {} } + } + + // Why: seed the offset at 0 so the FIRST drain re-reads the whole file. This + // closes the read/subscribe race — a turn appended between the caller's + // readSession EOF and the watcher install is still emitted. Re-emitted lines + // collapse by deterministic id in the assembler (no dup, no drop). Subsequent + // drains use the incremental offset so the full re-read happens only once. + let offset = 0 + let closed = false + let reading = false + let pendingReadRequested = false + let debounceTimer: ReturnType | null = null + + async function drain(): Promise { + if (closed) { + return + } + if (reading) { + // A read is already in flight; mark that another pass is needed so rapid + // successive appends coalesce without dropping the trailing one. + pendingReadRequested = true + return + } + reading = true + try { + do { + pendingReadRequested = false + try { + const currentSize = await fileSize(filePath!) + if (currentSize < offset) { + // Rotation/replacement/truncation: re-read from the top. + offset = 0 + } + const { messages, consumedTo } = await readAppendedMessages(filePath!, offset, decode!) + offset = consumedTo + if (!closed && messages.length > 0) { + onAppend(messages) + } + } catch { + // Why: a transient read failure (EACCES/EIO/ENOENT during rotation) + // must not leave the subscription permanently deaf. Stop this drain; + // the finally resets `reading` so a later fs event re-arms the read. + break + } + } while (pendingReadRequested && !closed) + } finally { + reading = false + } + } + + function scheduleDrain(): void { + if (closed) { + return + } + if (debounceTimer) { + clearTimeout(debounceTimer) + } + debounceTimer = setTimeout(() => { + debounceTimer = null + void drain() + }, debounceMs ?? DEFAULT_DEBOUNCE_MS) + } + + let watcher: FSWatcher + try { + watcher = watch(filePath, scheduleDrain) + } catch { + // File vanished between resolve and watch — return a no-op teardown. + return { unsubscribe: () => {} } + } + activeWatcherCount++ + + // Why: on some platforms fs.watch can miss the very first append that lands + // between offset-seed and watcher install. Kick one debounced drain so a + // turn written immediately after subscribe is still picked up. + scheduleDrain() + + return { + unsubscribe: () => { + if (closed) { + return + } + closed = true + if (debounceTimer) { + clearTimeout(debounceTimer) + debounceTimer = null + } + watcher.close() + activeWatcherCount-- + } + } +} diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 9904cc54adc..c48cf0c47d7 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -8621,3 +8621,85 @@ describe('Store host-partitioned workspace sessions', () => { expect(store.getWorkspaceSession('runtime:bad').activeRepoId).toBeNull() }) }) + +describe('Store native-chat tab viewMode persistence', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-')) + }) + + afterEach(() => { + rmSync(testState.dir, { recursive: true, force: true }) + }) + + // Why: a tab persisted in 'chat' must restore to 'chat' (R1), and a tab + // persisted before the field existed must default to 'terminal' — i.e. the + // field is absent on restore — so older sessions stay backward-compatible. + it('round-trips viewMode for unified tabs and defaults legacy tabs to terminal', async () => { + const WORKTREE = 'repo1::/worktree' + writeDataFile({ + schemaVersion: 1, + repos: [makeRepo()], + worktreeMeta: {}, + settings: {}, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: { + activeRepoId: 'r1', + activeWorktreeId: WORKTREE, + activeTabId: 'chat-tab', + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + sleepingAgentSessionsByPaneKey: {}, + unifiedTabs: { + [WORKTREE]: [ + { + id: 'chat-tab', + entityId: 'chat-tab', + groupId: 'g1', + worktreeId: WORKTREE, + contentType: 'terminal', + label: 'Agent', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1, + viewMode: 'chat' + }, + { + // Legacy tab persisted before viewMode existed — no field at all. + id: 'legacy-tab', + entityId: 'legacy-tab', + groupId: 'g1', + worktreeId: WORKTREE, + contentType: 'terminal', + label: 'Legacy', + customLabel: null, + color: null, + sortOrder: 1, + createdAt: 2 + } + ] + }, + tabGroups: { + [WORKTREE]: [ + { + id: 'g1', + worktreeId: WORKTREE, + activeTabId: 'chat-tab', + tabOrder: ['chat-tab', 'legacy-tab'] + } + ] + } + } + }) + + const store = await createStore() + const restored = store.getWorkspaceSession().unifiedTabs?.[WORKTREE] ?? [] + const chatTab = restored.find((tab) => tab.id === 'chat-tab') + const legacyTab = restored.find((tab) => tab.id === 'legacy-tab') + + expect(chatTab?.viewMode).toBe('chat') + // Missing on a legacy tab; renderer hydration treats absent as 'terminal'. + expect(legacyTab?.viewMode).toBeUndefined() + }) +}) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index edc10ea87f2..0c11b668578 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -11111,7 +11111,12 @@ describe('OrcaRuntimeService', () => { title: 'claude agents' }) ) - expect(result.tabs[0]).not.toHaveProperty('agentStatus') + // The stale "working" status is suppressed (no spinner), but agent identity + // is retained so native chat can still address the idle agent's transcript. + const suppressed = result.tabs[0] + expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.state).toBe('done') + expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.agentType).toBe('claude') + expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.terminalTitle).toBeUndefined() }) it('suppresses saved mobile agent status when the current terminal title is neutral', async () => { @@ -11178,7 +11183,11 @@ describe('OrcaRuntimeService', () => { title: 'bash' }) ) - expect(result.tabs[0]).not.toHaveProperty('agentStatus') + // Stale "working" suppressed; agent identity retained for native chat. + const suppressed = result.tabs[0] + expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.state).toBe('done') + expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.agentType).toBe('claude') + expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.terminalTitle).toBeUndefined() }) it('suppresses saved mobile agent status when fresh live OSC title is Claude agents', async () => { @@ -11251,7 +11260,11 @@ describe('OrcaRuntimeService', () => { title: 'claude agents' }) ) - expect(result.tabs[0]).not.toHaveProperty('agentStatus') + // Stale "working" suppressed; agent identity retained for native chat. + const suppressed = result.tabs[0] + expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.state).toBe('done') + expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.agentType).toBe('claude') + expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.terminalTitle).toBeUndefined() }) it('keeps saved PTY bindings pending until the runtime knows the PTY is connected', async () => { @@ -12793,7 +12806,11 @@ describe('OrcaRuntimeService', () => { title: 'claude agents' }) ) - expect(result.tabs[0]).not.toHaveProperty('agentStatus') + // Stale "working" suppressed; agent identity retained for native chat. + const suppressed = result.tabs[0] + expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.state).toBe('done') + expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.agentType).toBe('claude') + expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.terminalTitle).toBeUndefined() }) it('uses fresh neutral PTY titles over stale mobile snapshot and OSC titles', async () => { @@ -12864,7 +12881,11 @@ describe('OrcaRuntimeService', () => { title: 'zsh' }) ) - expect(result.tabs[0]).not.toHaveProperty('agentStatus') + // Stale "working" suppressed; agent identity retained for native chat. + const suppressed = result.tabs[0] + expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.state).toBe('done') + expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.agentType).toBe('claude') + expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.terminalTitle).toBeUndefined() }) it('pushes PTY-backed mobile session readiness changes when a server PTY exits', async () => { @@ -13391,6 +13412,36 @@ describe('OrcaRuntimeService', () => { expect(clearedSurface?.type === 'browser' && clearedSurface.isPinned).toBe(false) }) + it('persists headless tab viewMode and surfaces it through a cold rehydrate', async () => { + const session = makeWorkspaceSessionWithHeadlessTerminal() + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session) + const runtime = new OrcaRuntimeService(runtimeStore as never) + + await runtime.setMobileSessionTabProps(`id:${TEST_WORKTREE_ID}`, { + tabId: 'host-tab', + viewMode: 'chat' + }) + + const persisted = getSession().tabsByWorktree[TEST_WORKTREE_ID]!.find( + (tab) => tab.id === 'host-tab' + )! + expect(persisted.viewMode).toBe('chat') + + const live = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + const liveSurface = live.tabs.find( + (tab) => tab.type === 'terminal' && tab.parentTabId === 'host-tab' + ) + expect(liveSurface?.type === 'terminal' && liveSurface.viewMode).toBe('chat') + + runtime['mobileSessionTabsByWorktree'].delete(TEST_WORKTREE_ID) + runtime['hydrateHeadlessMobileSessionTabsFromWorkspaceSession'](TEST_WORKTREE_ID) + const rehydrated = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + const surface = rehydrated.tabs.find( + (tab) => tab.type === 'terminal' && tab.parentTabId === 'host-tab' + ) + expect(surface?.type === 'terminal' && surface.viewMode).toBe('chat') + }) + it('still persists tab props in serve mode after syncWindowGraph(0) (gate does not fire)', async () => { // Why: the renderer-authoritative gate uses getAvailableAuthoritativeWindow, // and serve startup calls syncWindowGraph(0,...) which sets authoritativeWindowId=0. diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index affb23d6a77..8595f783e4a 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -3293,6 +3293,7 @@ export class OrcaRuntimeService { ...(layout ? { parentLayout: this.cloneTerminalLayoutSnapshot(layout) } : {}), ...(tab.color != null ? { color: tab.color } : {}), ...(tab.isPinned ? { isPinned: true } : {}), + ...(tab.viewMode ? { viewMode: tab.viewMode } : {}), isActive: this.isPersistedTerminalLeafActive(worktreeId, tab.id, leafId, layout) } }) @@ -4208,7 +4209,12 @@ export class OrcaRuntimeService { // was never persisted. Persist to the workspace session + live snapshot. async setMobileSessionTabProps( worktreeSelector: string, - args: { tabId: string; color?: string | null; isPinned?: boolean } + args: { + tabId: string + color?: string | null + isPinned?: boolean + viewMode?: 'terminal' | 'chat' + } ): Promise<{ updated: true }> { const explicitWorktreeId = getExplicitWorktreeIdSelector(worktreeSelector) const worktreeId = @@ -4230,7 +4236,7 @@ export class OrcaRuntimeService { private persistHeadlessSessionTabProps( worktreeId: string, tabId: string, - props: { color?: string | null; isPinned?: boolean } + props: { color?: string | null; isPinned?: boolean; viewMode?: 'terminal' | 'chat' } ): void { const session = this.store?.getWorkspaceSession?.() if (!session || !this.store?.setWorkspaceSession) { @@ -4248,7 +4254,8 @@ export class OrcaRuntimeService { ? { ...tab, ...(props.color !== undefined ? { color: props.color } : {}), - ...(props.isPinned !== undefined ? { isPinned: props.isPinned } : {}) + ...(props.isPinned !== undefined ? { isPinned: props.isPinned } : {}), + ...(props.viewMode !== undefined ? { viewMode: props.viewMode } : {}) } : tab ) @@ -4281,7 +4288,7 @@ export class OrcaRuntimeService { private applyHeadlessSessionTabPropsToSnapshot( worktreeId: string, tabId: string, - props: { color?: string | null; isPinned?: boolean } + props: { color?: string | null; isPinned?: boolean; viewMode?: 'terminal' | 'chat' } ): void { const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) if (!snapshot) { @@ -4296,7 +4303,8 @@ export class OrcaRuntimeService { return { ...tab, ...(props.color !== undefined ? { color: props.color } : {}), - ...(props.isPinned !== undefined ? { isPinned: props.isPinned } : {}) + ...(props.isPinned !== undefined ? { isPinned: props.isPinned } : {}), + ...(props.viewMode !== undefined ? { viewMode: props.viewMode } : {}) } }) if (!changed) { @@ -17818,10 +17826,41 @@ export class OrcaRuntimeService { const title = leafTitle ?? ptyTitle ?? syncedTab?.title ?? tab.title const liveTitleEvidence = leafTitle ?? ptyTitle const liveTitleEvidenceClassification = classifyAgentTitle(liveTitleEvidence) - const agentStatus = + // Why: keep the rich hook-driven status when the agent has a live + // interactive prompt or an active tool — those are authoritative agent + // activity even if the terminal's title isn't agent-classified (e.g. it + // shows a task/branch name). Otherwise the mobile/web client falls back to + // the OSC-title-only status and never sees interactivePrompt (the question + // card never renders). + const hasLiveAgentSignal = + tab.agentStatus?.interactivePrompt != null || tab.agentStatus?.toolName != null + const keepFullAgentStatus = tab.agentStatus && - (liveTitleEvidence === null || liveTitleEvidenceClassification === 'agent') - ? { agentStatus: tab.agentStatus } + (liveTitleEvidence === null || + liveTitleEvidenceClassification === 'agent' || + hasLiveAgentSignal) + const agentStatus = keepFullAgentStatus + ? { agentStatus: tab.agentStatus } + : // Why: when live title evidence says the pane is idle (e.g. the Claude + // agents picker or a neutral shell title), suppress the stale "working" + // state so the client shows no spinner — but retain agent identity + // (agentType + providerSession) so native chat can still address an + // idle agent's transcript. Reset the transient state to 'done'. + tab.agentStatus?.agentType != null + ? { + agentStatus: { + state: 'done' as const, + prompt: '', + updatedAt: tab.agentStatus.updatedAt, + stateStartedAt: tab.agentStatus.stateStartedAt, + paneKey: tab.agentStatus.paneKey, + stateHistory: [], + agentType: tab.agentStatus.agentType, + ...(tab.agentStatus.providerSession + ? { providerSession: tab.agentStatus.providerSession } + : {}) + } + } : null // Why: web/mobile clients hold these handles across renderer graph syncs; // leaf handles are graph-epoch-bound, but PTY handles remain streamable. @@ -17849,6 +17888,7 @@ export class OrcaRuntimeService { ...(tab.parentLayout ? { parentLayout: tab.parentLayout } : {}), ...(tab.color != null ? { color: tab.color } : {}), ...(tab.isPinned ? { isPinned: true } : {}), + ...(tab.viewMode ? { viewMode: tab.viewMode } : {}), isActive: tab.isActive, ...(terminalHandle ? { status: 'ready' as const, terminal: terminalHandle } diff --git a/src/main/runtime/rpc/core.ts b/src/main/runtime/rpc/core.ts index bac75923dc4..1ef817bf552 100644 --- a/src/main/runtime/rpc/core.ts +++ b/src/main/runtime/rpc/core.ts @@ -59,6 +59,11 @@ export type RpcContext = { // Why: WebSocket RPCs authenticate by mobile device token. State-owning // handlers use this to clean up when that paired device disconnects. clientId?: string + // Why: payload windowing/truncation tuned for the constrained mobile payload + // (e.g. native-chat block char cap) must not clip full-screen web/desktop + // clients. Carries the paired device's scope so handlers can gate the diet to + // phones only. Undefined for in-process callers → treat as full-class (no clip). + clientKind?: 'mobile' | 'runtime' // Why: mobile terminal traffic is byte-oriented and bypasses JSON streaming // responses after the binary terminal cutover. Undefined on Unix/socket // transports and non-E2EE WebSocket paths. diff --git a/src/main/runtime/rpc/dispatcher.ts b/src/main/runtime/rpc/dispatcher.ts index 61b58e3aab0..e1c2fcd38f3 100644 --- a/src/main/runtime/rpc/dispatcher.ts +++ b/src/main/runtime/rpc/dispatcher.ts @@ -93,6 +93,7 @@ export class RpcDispatcher { connectionId?: string signal?: AbortSignal clientId?: string + clientKind?: 'mobile' | 'runtime' sendBinary?: (bytes: Uint8Array) => void registerBinaryStreamHandler?: ( streamId: number, @@ -125,6 +126,7 @@ export class RpcDispatcher { requestId: request.id, connectionId: options?.connectionId, clientId: options?.clientId, + clientKind: options?.clientKind, sendBinary: options?.sendBinary, registerBinaryStreamHandler: options?.registerBinaryStreamHandler }) @@ -158,6 +160,7 @@ export class RpcDispatcher { requestId: request.id, connectionId: options?.connectionId, clientId: options?.clientId, + clientKind: options?.clientKind, sendBinary: options?.sendBinary, registerBinaryStreamHandler: options?.registerBinaryStreamHandler }, diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index bff744016c3..8018375a8a0 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -15,6 +15,7 @@ import { ACCOUNT_METHODS } from './accounts' import { PREFLIGHT_METHODS } from './preflight' import { COMPUTER_METHODS } from './computer' import { SESSION_TAB_METHODS } from './session-tabs' +import { NATIVE_CHAT_METHODS } from './native-chat' import { FILE_METHODS } from './files' import { GIT_METHODS } from './git' import { GITHUB_METHODS } from './github' @@ -53,6 +54,7 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [ ...PREFLIGHT_METHODS, ...COMPUTER_METHODS, ...SESSION_TAB_METHODS, + ...NATIVE_CHAT_METHODS, ...FILE_METHODS, ...GIT_METHODS, ...GITHUB_METHODS, diff --git a/src/main/runtime/rpc/methods/native-chat.test.ts b/src/main/runtime/rpc/methods/native-chat.test.ts new file mode 100644 index 00000000000..4d428c9e5f5 --- /dev/null +++ b/src/main/runtime/rpc/methods/native-chat.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it, vi } from 'vitest' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import type { RpcContext } from '../core' + +// Stub the shared cache so the handler returns a deterministic transcript with +// one oversized tool-result block; the test then asserts clip behavior per client. +const OVERSIZED = 'x'.repeat(5000) +const cachedResult = vi.hoisted(() => ({ value: { messages: [] as NativeChatMessage[] } })) +vi.mock('../../../native-chat/transcript-read-cache', () => ({ + readNativeChatTranscriptCached: () => Promise.resolve(cachedResult.value) +})) + +import { NATIVE_CHAT_METHODS } from './native-chat' + +function makeMessage(text: string): NativeChatMessage { + return { + id: 'a-1', + role: 'assistant', + timestamp: 1_717_236_000_000, + source: 'transcript', + blocks: [{ type: 'tool-result', output: text, isError: false }] + } +} + +function readSessionHandler(): (params: unknown, ctx: RpcContext) => Promise { + const method = NATIVE_CHAT_METHODS.find((m) => m.name === 'nativeChat.readSession') + if (!method) { + throw new Error('readSession method not registered') + } + return method.handler as (params: unknown, ctx: RpcContext) => Promise +} + +function ctxWith(clientKind: RpcContext['clientKind']): RpcContext { + return { runtime: {} as RpcContext['runtime'], clientKind } +} + +function firstOutput(result: unknown): string { + const messages = (result as { messages: NativeChatMessage[] }).messages + const block = messages[0].blocks[0] as { output: string } + return block.output +} + +describe('nativeChat.readSession clientKind truncation gating', () => { + it('clips oversized tool output for mobile clients', async () => { + cachedResult.value = { messages: [makeMessage(OVERSIZED)] } + const result = await readSessionHandler()( + { agent: 'claude', sessionId: 's' }, + ctxWith('mobile') + ) + const output = firstOutput(result) + expect(output.length).toBeLessThan(OVERSIZED.length) + expect(output).toContain('truncated') + }) + + it('passes oversized tool output through intact for runtime (web/desktop) clients', async () => { + cachedResult.value = { messages: [makeMessage(OVERSIZED)] } + const result = await readSessionHandler()( + { agent: 'claude', sessionId: 's' }, + ctxWith('runtime') + ) + expect(firstOutput(result)).toBe(OVERSIZED) + }) + + it('defaults to no clip when clientKind is undefined (in-process callers)', async () => { + cachedResult.value = { messages: [makeMessage(OVERSIZED)] } + const result = await readSessionHandler()( + { agent: 'claude', sessionId: 's' }, + ctxWith(undefined) + ) + expect(firstOutput(result)).toBe(OVERSIZED) + }) + + it('windows by count for all client kinds', async () => { + const many = Array.from({ length: 60 }, (_unused, n) => { + const message = makeMessage('small') + return { ...message, id: `m-${n}` } + }) + cachedResult.value = { messages: many } + const result = await readSessionHandler()( + { agent: 'claude', sessionId: 's', limit: 40 }, + ctxWith('runtime') + ) + const messages = (result as { messages: NativeChatMessage[] }).messages + expect(messages).toHaveLength(40) + // Tail-only: the last id survives, the first is dropped. + expect(messages.at(-1)?.id).toBe('m-59') + expect(messages[0].id).toBe('m-20') + }) +}) diff --git a/src/main/runtime/rpc/methods/native-chat.ts b/src/main/runtime/rpc/methods/native-chat.ts new file mode 100644 index 00000000000..f434df1d4ed --- /dev/null +++ b/src/main/runtime/rpc/methods/native-chat.ts @@ -0,0 +1,180 @@ +import { z } from 'zod' +import type { NativeChatBlock, NativeChatMessage } from '../../../../shared/native-chat-types' +import type { AgentType } from '../../../../shared/native-chat-types' +import { readNativeChatTranscriptCached } from '../../../native-chat/transcript-read-cache' +import { subscribeNativeChatTranscript } from '../../../native-chat/transcript-watch' +import { defineMethod, defineStreamingMethod, type RpcAnyMethod, type RpcContext } from '../core' + +// Why: native chat renders an agent's own transcript (Claude/Codex JSONL). The +// desktop reaches the readers via Electron IPC; mobile/web clients reach the +// same pure readers through these runtime RPC methods so the native chat view +// works over the paired connection, not just in the desktop renderer. + +const NativeChatSession = z.object({ + agent: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing agent')) + .transform((v) => v as AgentType), + sessionId: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing session id')), + // How many of the most-recent messages to return. Clients start small for a + // fast first paint and raise it to page older history in as the user scrolls. + limit: z.number().int().positive().max(2000).optional(), + // Optional client-supplied cleanup token. When present, the subscribe handler + // keys the fs-watcher cleanup under it so registration and unsubscribe derive + // from the SAME token (back-compat: falls back to `agent:sessionId` when absent, + // which is exactly what existing mobile clients rely on). + subscriptionId: z.string().min(1).optional(), + // Authoritative transcript path from the agent hook (providerSession), used to + // locate the file directly when the session id no longer names it (recent + // Claude Code). Optional for back-compat with older clients. + transcriptPath: z.string().min(1).optional() +}) + +const NativeChatUnsubscribe = z.object({ + subscriptionId: z.string().min(1).optional() +}) + +// Why: a long agent session can hold thousands of turns (with full tool I/O). +// Shipping all of them over the paired connection and rendering them at once +// freezes the mobile app, so the runtime RPC windows to the most recent slice — +// the conversation tail is what the chat view shows first. The desktop IPC path +// is unaffected (it reads locally with a virtualized list). +// Small first page for a fast initial paint; the client raises `limit` to load +// older history as the user scrolls back. +const MOBILE_NATIVE_CHAT_DEFAULT_WINDOW = 40 +const MOBILE_NATIVE_CHAT_MAX_WINDOW = 2000 +// Why: a single tool result (a big file read, a long diff) can be hundreds of KB. +// The mobile view only previews block bodies, so truncate them on the wire to +// keep the payload small; the marker tells the user content was clipped. +const MOBILE_BLOCK_CHAR_CAP = 4000 +const TRUNCATION_MARKER = '\n… (truncated)' + +function clip(text: string): string { + return text.length > MOBILE_BLOCK_CHAR_CAP + ? text.slice(0, MOBILE_BLOCK_CHAR_CAP) + TRUNCATION_MARKER + : text +} + +function clipBlock(block: NativeChatBlock): NativeChatBlock { + if (block.type === 'text') { + return block.text.length > MOBILE_BLOCK_CHAR_CAP ? { ...block, text: clip(block.text) } : block + } + if (block.type === 'tool-result') { + return block.output.length > MOBILE_BLOCK_CHAR_CAP + ? { ...block, output: clip(block.output) } + : block + } + return block +} + +function sanitizeMessage(message: NativeChatMessage): NativeChatMessage { + return { ...message, blocks: message.blocks.map(clipBlock) } +} + +/** Window a transcript to its most recent `limit` messages so a long session + * can't freeze the client. Windowing by count applies to ALL RPC clients — + * shipping thousands of turns over the paired link is bad for web and mobile + * alike. Char-clipping (the mobile-only payload diet) is applied separately. */ +function windowTranscript( + messages: readonly NativeChatMessage[], + limit = MOBILE_NATIVE_CHAT_DEFAULT_WINDOW +): NativeChatMessage[] { + const window = Math.min(Math.max(limit, 1), MOBILE_NATIVE_CHAT_MAX_WINDOW) + return messages.length > window ? messages.slice(-window) : messages.slice() +} + +/** Apply the windowed slice plus, for `mobile` clients only, oversized-block + * char truncation. Web/desktop (`runtime`, or undefined for in-process callers) + * are full-class surfaces and pass block bodies through untruncated — matching + * the desktop IPC path, which never clips. */ +function windowForClient( + messages: readonly NativeChatMessage[], + clientKind: RpcContext['clientKind'], + limit = MOBILE_NATIVE_CHAT_DEFAULT_WINDOW +): NativeChatMessage[] { + const windowed = windowTranscript(messages, limit) + return clientKind === 'mobile' ? windowed.map(sanitizeMessage) : windowed +} + +export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [ + defineMethod({ + name: 'nativeChat.readSession', + params: NativeChatSession, + handler: async (params, { clientKind }) => { + const result = await readNativeChatTranscriptCached( + params.agent, + params.sessionId, + params.transcriptPath + ) + // Window to the conversation tail (all clients); clip blocks for mobile only. + return 'messages' in result + ? { messages: windowForClient(result.messages, clientKind, params.limit) } + : result + } + }), + defineStreamingMethod({ + name: 'nativeChat.subscribe', + params: NativeChatSession, + handler: async (params, { runtime, connectionId, clientKind }, emit) => { + let closed = false + let unsubscribe = (): void => {} + // Why: the subscriber seeds its read offset at 0, so the first drain emits + // the whole transcript and later drains emit only appended turns. The first + // batch is windowed to the tail (a full transcript would freeze mobile); + // later incremental batches are smaller than the window so they pass through. + // Clients merge by message id, so the initial windowed batch doubles as the + // snapshot. Keyed by the client-supplied subscriptionId when present so + // registration and unsubscribe derive from the same token; otherwise by + // agent:sessionId, which is exactly the token existing mobile clients send to + // unsubscribe (no wire break). + const cleanupToken = params.subscriptionId ?? `${params.agent}:${params.sessionId}` + const subscriptionId = `nativeChat:${connectionId ?? 'local'}:${cleanupToken}` + runtime.registerSubscriptionCleanup( + subscriptionId, + () => { + closed = true + unsubscribe() + emit({ type: 'end' }) + }, + connectionId + ) + if (closed) { + return + } + const subscription = await subscribeNativeChatTranscript({ + agent: params.agent, + sessionId: params.sessionId, + transcriptPath: params.transcriptPath, + onAppend: (messages) => { + if (closed) { + return + } + emit({ type: 'appended', messages: windowForClient(messages, clientKind) }) + } + }) + // The connection may have closed while the file was being resolved. + if (closed) { + subscription.unsubscribe() + return + } + unsubscribe = subscription.unsubscribe + } + }), + defineMethod({ + name: 'nativeChat.unsubscribe', + params: NativeChatUnsubscribe, + handler: async (params, { runtime, connectionId }) => { + const connection = connectionId ?? 'local' + if (params.subscriptionId) { + runtime.cleanupSubscription(`nativeChat:${connection}:${params.subscriptionId}`) + return { unsubscribed: true } + } + runtime.cleanupSubscriptionsByPrefix(`nativeChat:${connection}:`) + return { unsubscribed: true } + } + }) +] diff --git a/src/main/runtime/rpc/methods/session-tabs-schemas.ts b/src/main/runtime/rpc/methods/session-tabs-schemas.ts index 275514a39f5..2c7c3183946 100644 --- a/src/main/runtime/rpc/methods/session-tabs-schemas.ts +++ b/src/main/runtime/rpc/methods/session-tabs-schemas.ts @@ -104,7 +104,9 @@ export const SetTabProps = WorktreeTabSelector.extend({ .pipe(z.string().min(1, 'Missing tab id')), // undefined = leave unchanged; null = clear color / unset. color: z.string().max(64).nullable().optional(), - isPinned: z.boolean().optional() + isPinned: z.boolean().optional(), + // undefined = leave unchanged; no "clear" semantic (absence means default 'terminal'). + viewMode: z.enum(['terminal', 'chat']).optional() }) export const CreateTerminalTab = WorktreeTabSelector.extend({ diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts index 59eb3d04ecc..ebdcc2a7f2c 100644 --- a/src/main/runtime/rpc/methods/session-tabs.ts +++ b/src/main/runtime/rpc/methods/session-tabs.ts @@ -103,7 +103,8 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ runtime.setMobileSessionTabProps(params.worktree, { tabId: params.tabId, ...(params.color !== undefined ? { color: params.color } : {}), - ...(params.isPinned !== undefined ? { isPinned: params.isPinned } : {}) + ...(params.isPinned !== undefined ? { isPinned: params.isPinned } : {}), + ...(params.viewMode !== undefined ? { viewMode: params.viewMode } : {}) }) }), defineStreamingMethod({ diff --git a/src/main/runtime/rpc/methods/skills.ts b/src/main/runtime/rpc/methods/skills.ts index 0c157779068..8719cc528ab 100644 --- a/src/main/runtime/rpc/methods/skills.ts +++ b/src/main/runtime/rpc/methods/skills.ts @@ -1,10 +1,20 @@ +import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' import { discoverSkills } from '../../../skills/discovery' +const SkillDiscoveryParams = z.object({ + cwd: z.string().optional().nullable() +}) + export const SKILL_METHODS: RpcMethod[] = [ defineMethod({ name: 'skills.discover', - params: null, - handler: async (_params, { runtime }) => discoverSkills({ repos: runtime.listRepos() }) + params: SkillDiscoveryParams, + handler: async (params, { runtime }) => { + const cwd = params.cwd?.trim() || undefined + return cwd + ? discoverSkills({ repos: [], cwd }) + : discoverSkills({ repos: runtime.listRepos() }) + } }) ] diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index aebeaf9baad..154f0926145 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -297,6 +297,9 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'session.tabs.subscribeAll', 'session.tabs.unsubscribe', 'session.tabs.unsubscribeAll', + 'nativeChat.readSession', + 'nativeChat.subscribe', + 'nativeChat.unsubscribe', 'settings.get', 'settings.update', 'ssh.connect', @@ -971,6 +974,9 @@ export class OrcaRuntimeRpcServer { await this.dispatcher.dispatchStreaming(request, reply, { connectionId, clientId: token, + // Why: gates the mobile-only payload diet (native-chat char clipping) so + // full-screen web/desktop runtime clients aren't truncated. + clientKind: device.scope, signal: abortRegistration?.signal, sendBinary, registerBinaryStreamHandler: (streamId, handler) => diff --git a/src/main/skills/discovery.test.ts b/src/main/skills/discovery.test.ts index 93a8e59aafc..dcee058f8e2 100644 --- a/src/main/skills/discovery.test.ts +++ b/src/main/skills/discovery.test.ts @@ -77,6 +77,33 @@ describe('skill discovery', () => { expect(skill?.directoryPath).toBe(linkedSkill) }) + it('discovers worktree .agents skill symlinks from the requested cwd', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skills-')) + const home = join(root, 'home') + const worktree = join(root, 'worktree') + const realSkill = join(root, 'central-skills', 'ref-oss') + const linkedSkill = join(worktree, '.agents', 'skills', 'ref-oss') + await mkdir(realSkill, { recursive: true }) + await mkdir(join(worktree, '.agents', 'skills'), { recursive: true }) + await writeFile(join(realSkill, 'SKILL.md'), '# ref-oss\n\nUse local OSS reference repos.') + await symlink(realSkill, linkedSkill, process.platform === 'win32' ? 'junction' : 'dir') + + const result = await discoverSkills({ + homeDir: home, + cwd: worktree, + repos: [] + }) + + expect(result.skills.filter((entry) => entry.name === 'ref-oss')).toMatchObject([ + { + sourceKind: 'repo', + sourceLabel: 'Repo worktree .agents', + directoryPath: linkedSkill, + providers: ['agent-skills'] + } + ]) + }) + it('keeps home classification when cwd points at the same directory as home', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-skills-')) const home = join(root, 'home') diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 8d6d2ba81dc..4ba541aad45 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -92,6 +92,18 @@ function nativeZoomCommandMatchesKeybindings( ) } +function isMacAppPasteInput(input: Electron.Input): boolean { + return ( + process.platform === 'darwin' && + input.type === 'keyDown' && + input.meta && + !input.control && + !input.alt && + !input.shift && + (input.code === 'KeyV' || input.key.toLowerCase() === 'v') + ) +} + // Why: the titlebar is 36px (border-box, 1px border-bottom). The visual // center of the CSS-centered content sits at ~18 CSS px from the top. // At zoom factor z that becomes 18·z window px. Traffic lights are @@ -844,6 +856,14 @@ export function createMainWindow( return } + if (isMacAppPasteInput(input)) { + // Why: native chat/terminal panes can own focus without being native + // editable controls, so route Cmd+V through Orca's paste ownership first. + event.preventDefault() + mainWindow.webContents.send('ui:appMenuPaste') + return + } + const keybindings = opts?.getKeybindings?.() const terminalShortcutContext: KeybindingMatchOptions = { context: terminalInputFocused || floatingTerminalInputFocused ? 'terminal' : 'app', diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 96429261e41..00514423fc5 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -362,6 +362,7 @@ import type { OpenCodeUsageSummary } from '../shared/opencode-usage-types' import type { AiVaultListArgs, AiVaultListResult } from '../shared/ai-vault-types' +import type { AgentType, NativeChatMessage } from '../shared/native-chat-types' import type { TelemetryConsentState } from '../shared/telemetry-consent-types' import type { AgentKind, LaunchSource, RequestKind } from '../shared/telemetry-events' import type { AppStarSource } from '../shared/gh-star-source' @@ -720,6 +721,46 @@ export type AiVaultApi = { listSessions: (args?: AiVaultListArgs) => Promise } +export type NativeChatReadSessionResult = { messages: NativeChatMessage[] } | { error: string } + +/** Messages appended to a live-tailed transcript since the previous emit. */ +export type NativeChatAppendedMessages = NativeChatMessage[] + +/** Wire payload for the `nativeChat:appended` push channel. */ +export type NativeChatAppendedPayload = { + subscriptionId: string + messages: NativeChatAppendedMessages +} + +export type NativeChatSubscribeArgs = { + /** Unique per-caller id, echoed on every append so multiple live panes in + * one renderer don't cross-talk. */ + subscriptionId: string + agent: AgentType + sessionId: string + /** Authoritative transcript path from the agent hook (providerSession). */ + transcriptPath?: string +} + +export type NativeChatApi = { + /** Read the on-disk transcript for an agent + session id, windowed to the most + * recent `limit` turns (defaults to the desktop window). The renderer raises + * `limit` to page in older history as it scrolls to the top. `transcriptPath` + * is the hook-reported authoritative file path, preferred over the id glob. */ + readSession: ( + agent: AgentType, + sessionId: string, + limit?: number, + transcriptPath?: string + ) => Promise + /** Live-tail a transcript: `onAppended` fires with only newly-appended + * messages. Returns an unsubscribe fn that closes the main-process watcher. */ + subscribe: ( + args: NativeChatSubscribeArgs, + onAppended: (messages: NativeChatAppendedMessages) => void + ) => () => void +} + export type AppApi = { /** Returns the app identity currently exposed to native chrome and the titlebar. */ getIdentity: () => Promise @@ -2014,6 +2055,7 @@ export type PreloadApi = { codexUsage: CodexUsageApi openCodeUsage: OpenCodeUsageApi aiVault: AiVaultApi + nativeChat: NativeChatApi fs: { readDir: (args: { dirPath: string; connectionId?: string }) => Promise readFile: (args: { diff --git a/src/preload/index.ts b/src/preload/index.ts index be4ba360c2a..bac40a93a90 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -153,6 +153,12 @@ import type { } from '../shared/automations-types' import type { KeybindingActionId, KeybindingFileSnapshot } from '../shared/keybindings' import type { AiVaultListArgs } from '../shared/ai-vault-types' +import type { AgentType } from '../shared/native-chat-types' +import type { + NativeChatAppendedMessages, + NativeChatAppendedPayload, + NativeChatReadSessionResult +} from './api-types' import { ORCA_EDITOR_PREPARE_HOT_EXIT_EVENT, type EditorPrepareHotExitDetail @@ -3489,6 +3495,40 @@ const api = { ipcRenderer.invoke('aiVault:listSessions', args) }, + nativeChat: { + readSession: ( + agent: AgentType, + sessionId: string, + limit?: number, + transcriptPath?: string + ): Promise => + ipcRenderer.invoke('nativeChat:readSession', { agent, sessionId, limit, transcriptPath }), + /** Start live tailing for a transcript. `onAppended` fires with only the + * newly-appended messages. Returns an unsubscribe fn that closes the + * main-process watcher (subscriptionId routes appends to this caller). */ + subscribe: ( + args: { + subscriptionId: string + agent: AgentType + sessionId: string + transcriptPath?: string + }, + onAppended: (messages: NativeChatAppendedMessages) => void + ): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: NativeChatAppendedPayload) => { + if (payload.subscriptionId === args.subscriptionId) { + onAppended(payload.messages) + } + } + ipcRenderer.on('nativeChat:appended', listener) + ipcRenderer.send('nativeChat:subscribe', args) + return () => { + ipcRenderer.removeListener('nativeChat:appended', listener) + ipcRenderer.send('nativeChat:unsubscribe', { subscriptionId: args.subscriptionId }) + } + } + }, + runtime: { syncWindowGraph: (graph: RuntimeSyncWindowGraph): Promise => ipcRenderer.invoke('runtime:syncWindowGraph', graph), diff --git a/src/renderer/src/components/dictation/DictationController.tsx b/src/renderer/src/components/dictation/DictationController.tsx index 4781ec4f4f8..1d8486becf5 100644 --- a/src/renderer/src/components/dictation/DictationController.tsx +++ b/src/renderer/src/components/dictation/DictationController.tsx @@ -13,6 +13,7 @@ import { recordStoppedSession, waitForStoppedSession } from './dictation-stopped import { translate } from '@/i18n/i18n' import { showDictationStartErrorToast } from './dictation-start-error-toast' import { useHoldDictationGesture } from './use-hold-dictation-gesture' +import { DICTATION_CONTROL_EVENT, type DictationControlAction } from './dictation-control-events' export function DictationController() { const dictationState = useAppStore((s) => s.dictationState) @@ -265,6 +266,35 @@ export function DictationController() { stopDictation ]) + useEffect(() => { + const canDictate = (): boolean => Boolean(settings?.voice?.enabled && settings.voice.sttModel) + const handleControl = (event: Event): void => { + if (!canDictate() || dictationStateRef.current === 'stopping') { + return + } + const action = (event as CustomEvent).detail + if (action === 'start') { + if (dictationStateRef.current === 'idle') { + void startDictation() + } + return + } + if (action === 'stop') { + if (dictationStateRef.current === 'listening' || dictationStateRef.current === 'starting') { + void stopDictation() + } + return + } + if (dictationStateRef.current === 'listening' || dictationStateRef.current === 'starting') { + void stopDictation() + } else { + void startDictation() + } + } + document.addEventListener(DICTATION_CONTROL_EVENT, handleControl) + return () => document.removeEventListener(DICTATION_CONTROL_EVENT, handleControl) + }, [settings?.voice?.enabled, settings?.voice?.sttModel, startDictation, stopDictation]) + useHoldDictationGesture({ dictationStateRef, holdGestureActiveRef, diff --git a/src/renderer/src/components/dictation/dictation-control-events.ts b/src/renderer/src/components/dictation/dictation-control-events.ts new file mode 100644 index 00000000000..14c40e8483b --- /dev/null +++ b/src/renderer/src/components/dictation/dictation-control-events.ts @@ -0,0 +1,9 @@ +export const DICTATION_CONTROL_EVENT = 'dictation:control' + +export type DictationControlAction = 'toggle' | 'start' | 'stop' + +export function dispatchDictationControl(action: DictationControlAction): void { + document.dispatchEvent( + new CustomEvent(DICTATION_CONTROL_EVENT, { detail: action }) + ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx b/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx new file mode 100644 index 00000000000..cbf041f62e3 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx @@ -0,0 +1,55 @@ +import { ShieldQuestion } from 'lucide-react' +import { cn } from '@/lib/utils' +import type { ChatApproval } from './native-chat-interactive-prompt' + +export type NativeChatApprovalCardProps = { + approval: ChatApproval + /** Send the chosen option's literal string to the agent's PTY. */ + onChoose: (send: string) => void +} + +/** + * Native renderer for an agent tool-approval (PermissionRequest) as an + * Allow/Deny card. Each button writes its option's literal `send` string back + * to the agent (a number to allow; ESC to deny). The first option reads as the + * affirmative action and gets the primary styling. + */ +export function NativeChatApprovalCard({ + approval, + onChoose +}: NativeChatApprovalCardProps): React.JSX.Element { + return ( +
+
+
+ +
+

{approval.title}

+ {approval.detail ? ( +

+ {approval.detail} +

+ ) : null} +
+
+
+ {approval.options.map((opt, i) => ( + + ))} +
+
+
+ ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx new file mode 100644 index 00000000000..f0662c52acd --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx @@ -0,0 +1,103 @@ +import { useEffect, useRef } from 'react' +import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' +import type { SlashCommandSuggestion } from './native-chat-composer-state' +import type { DiscoveredSkill } from '../../../../shared/skills' + +export function NativeChatSlashMenu({ + suggestions, + activeIndex, + onChoose +}: { + suggestions: SlashCommandSuggestion[] + activeIndex: number + onChoose: (command: SlashCommandSuggestion) => void +}): React.JSX.Element { + return ( +
+ {suggestions.map((command, index) => ( + + ))} +
+ ) +} + +export function NativeChatMentionHint({ + query, + onAccept +}: { + query: string + onAccept: () => void +}): React.JSX.Element { + return ( + + ) +} + +export function NativeChatSkillMenu({ + suggestions, + activeIndex, + onChoose +}: { + suggestions: DiscoveredSkill[] + activeIndex: number + onChoose: (skill: DiscoveredSkill) => void +}): React.JSX.Element { + const activeItemRef = useRef(null) + + useEffect(() => { + activeItemRef.current?.scrollIntoView({ block: 'nearest' }) + }, [activeIndex, suggestions]) + + return ( +
+ {suggestions.length === 0 ? ( +
+ {translate('components.native-chat.composer.noSkills', 'No matching skills')} +
+ ) : null} + {suggestions.map((skill, index) => ( + + ))} +
+ ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx new file mode 100644 index 00000000000..f5a2f2c7100 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -0,0 +1,424 @@ +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState +} from 'react' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '../../store' +import type { AgentType } from '../../../../shared/agent-status-types' +import { NATIVE_FILE_DROP_TARGET } from '../../../../shared/native-file-drop' +import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection' +import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' +import { sendNativeChatMessage, submitNativeChatPrompt } from './native-chat-runtime-send' +import { getAgentSlashCommands } from './native-chat-agent-commands' +import { emitNativeChatMessageSent } from '@/lib/native-chat-telemetry' +import { + applyMentionSuggestion, + applySkillSuggestion, + applySlashSuggestion, + deriveComposerAutocomplete, + EMPTY_HISTORY, + isSlashCommandDraft, + pushHistory, + slashCommandDispatchText, + type HistoryState, + type SlashCommandSuggestion +} from './native-chat-composer-state' +import { resolveImagePaste } from './native-chat-image-paste' +import { NativeChatComposerField } from './NativeChatComposerField' +import { + nativeChatComposerTargetIsRemote, + type NativeChatResolvedTarget +} from './native-chat-composer-target' +import { useNativeChatSkills } from './use-native-chat-skills' +import { useNativeChatComposerAttachments } from './use-native-chat-composer-attachments' +import { dispatchDictationControl } from '../dictation/dictation-control-events' +import { useNativeChatComposerKeyDown } from './use-native-chat-composer-keydown' + +// Why: a plain ESC byte is what the agent TUIs read as the interrupt key over a +// PTY (matching how xterm forwards Escape). The richer interrupt-intent +// inference (agent-interrupt-intent.ts) is driven by the existing PTY input +// observers, so writing ESC through the same send path feeds that machinery. +const ESC = '\x1b' + +export type NativeChatComposerProps = { + /** Tab hosting the agent; used to resolve the live ptyId + runtime settings. */ + terminalTabId: string + /** Specific split-pane PTY this chat view owns. */ + targetPtyId: string | null + agent: AgentType + /** + * Mobile presence-lock seam (R8): when a mobile client holds the pty, desktop + * sends must be guarded rather than silently dropped. U9 wires the real lock + * state in; until then this defaults to `true` (sendable) and the composer + * already renders the guarded/disabled affordance when it is `false`. + */ + canSend?: boolean + /** True while the hosted TUI reports an in-flight turn; swaps Send to Stop. */ + isWorking?: boolean + /** Interrupt the hosted agent, usually by sending ESC into the PTY. */ + onStop?: () => void + /** Optional optimistic-send hook: called with the sent text so the view can + * render a "queued" echo until the real transcript turn lands (mobile parity). */ + onOptimisticSend?: (text: string, imagePaths?: string[]) => void + /** Called with a dispatched slash command (e.g. `/clear`) so the view can show + * a small "Ran /clear" system line — slash commands aren't chat turns and + * otherwise leave no visible trace that anything happened. */ + onSlashCommand?: (command: string) => void +} + +export type NativeChatComposerHandle = { + focus: () => boolean + insertTypedText: (text: string) => boolean +} + +/** + * Rich native input for the chat view. Sends prompts into the running agent + * through the same verified runtime path as typed input (KTD4), so the agent + * cannot distinguish native input from keystrokes. Enter sends; Shift+Enter + * inserts a newline; multi-line is bracketed-paste wrapped; Esc interrupts. + * Slash-command and `@file` autocomplete are agent-aware; image paste persists a + * temp file and injects the agent-appropriate path (or reports unsupported). + */ +export const NativeChatComposer = forwardRef( + function NativeChatComposer( + { + terminalTabId, + targetPtyId, + agent, + canSend = true, + isWorking = false, + onStop, + onOptimisticSend, + onSlashCommand + }, + ref + ): React.JSX.Element { + const [draft, setDraft] = useState('') + const [caret, setCaret] = useState(0) + const [history, setHistory] = useState(EMPTY_HISTORY) + const [activeSuggestion, setActiveSuggestion] = useState(0) + const [notice, setNotice] = useState(null) + const [dictationPressed, setDictationPressed] = useState(false) + const skills = useNativeChatSkills(agent, terminalTabId) + const textareaRef = useRef(null) + const dictationState = useAppStore((store) => store.dictationState) + const voiceSettings = useAppStore((store) => store.settings?.voice) + const isDictationHoldMode = voiceSettings?.dictationMode === 'hold' + const dictationDisabled = voiceSettings?.enabled !== true || !voiceSettings.sttModel + const isDictating = + dictationPressed || + dictationState === 'starting' || + dictationState === 'listening' || + dictationState === 'stopping' + + const agentCommands = useMemo(() => getAgentSlashCommands(agent), [agent]) + const autocomplete = useMemo( + () => + deriveComposerAutocomplete(draft, caret, agentCommands, agent === 'codex' ? skills : []), + [draft, caret, agentCommands, agent, skills] + ) + + // Resolve the live ptyId for this chat leaf; runtime owner settings route + // local vs remote (SSH) sends. + const resolveTarget = useCallback((): NativeChatResolvedTarget | null => { + if (!targetPtyId) { + return null + } + return { ptyId: targetPtyId, settings: getSettingsForAgentTabRuntimeOwner(terminalTabId) } + }, [targetPtyId, terminalTabId]) + + const hasPty = targetPtyId !== null + const disabled = !hasPty || !canSend + + const syncCaret = useCallback((el: HTMLTextAreaElement) => { + setCaret(el.selectionStart ?? el.value.length) + }, []) + + const { imageAttachments, attachLocalPaths, clearImageAttachments, removeImageAttachment } = + useNativeChatComposerAttachments({ + attachmentScopeKey: targetPtyId ?? terminalTabId, + caret, + resolveTarget, + textareaRef, + setCaret, + setDraft, + setNotice + }) + const sendButtonDisabled = isWorking + ? !hasPty || !onStop + : disabled || (draft.trim() === '' && imageAttachments.length === 0) + + const insertTypedText = useCallback( + (text: string): boolean => { + const textarea = textareaRef.current + if (!textarea || textarea.disabled) { + return false + } + const selectionStart = textarea.selectionStart ?? caret + const selectionEnd = textarea.selectionEnd ?? selectionStart + const next = `${draft.slice(0, selectionStart)}${text}${draft.slice(selectionEnd)}` + const nextCaret = selectionStart + text.length + textarea.focus() + setDraft(next) + setCaret(nextCaret) + setHistory((prev) => ({ entries: prev.entries, index: null })) + setActiveSuggestion(0) + requestAnimationFrame(() => { + textarea.setSelectionRange(nextCaret, nextCaret) + }) + return true + }, + [caret, draft] + ) + + const focus = useCallback((): boolean => { + const textarea = textareaRef.current + if (!textarea || textarea.disabled) { + return false + } + textarea.focus() + return true + }, []) + + useImperativeHandle(ref, () => ({ focus, insertTypedText }), [focus, insertTypedText]) + + useEffect(() => { + return window.api.ui.onFileDrop((payload) => { + if (payload.target !== NATIVE_FILE_DROP_TARGET.composer) { + return + } + attachLocalPaths(payload.paths) + }) + }, [attachLocalPaths]) + + const pickAttachment = useCallback(() => { + void (async () => { + const filePath = await window.api.shell.pickAttachment() + if (!filePath) { + return + } + attachLocalPaths([filePath]) + })() + }, [attachLocalPaths]) + + const focusForDictation = useCallback(() => { + textareaRef.current?.focus() + }, []) + + const toggleDictation = useCallback(() => { + focusForDictation() + dispatchDictationControl('toggle') + }, [focusForDictation]) + + const startHoldDictation = useCallback(() => { + setDictationPressed(true) + focusForDictation() + dispatchDictationControl('start') + }, [focusForDictation]) + + const stopHoldDictation = useCallback(() => { + setDictationPressed(false) + dispatchDictationControl('stop') + }, []) + + const send = useCallback(() => { + const text = draft + const imagePaths = imageAttachments.map((attachment) => attachment.path) + if ((text.trim() === '' && imagePaths.length === 0) || disabled) { + return + } + const target = resolveTarget() + if (!target) { + return + } + // Images are pasted into the hosted TUI as soon as they are attached, so the + // Send action only submits the text body (if any) plus Enter. + if (text.trim().length > 0) { + sendNativeChatMessage(target.settings, target.ptyId, text) + } else { + submitNativeChatPrompt(target.settings, target.ptyId) + } + // Slash commands are TUI controls, not chat turns: don't echo a user bubble, + // but DO surface a small "Ran /clear" system line so the command leaves a + // visible trace instead of seeming to do nothing. + if (isSlashCommandDraft(text)) { + onSlashCommand?.(text.trim()) + } else { + onOptimisticSend?.(text, imagePaths) + } + // Why: U10 telemetry — record adoption + local-vs-remote runtime split. The + // agent prop is the loose AgentType; the emitter narrows unknowns to 'other'. + emitNativeChatMessageSent({ + agent, + runtime: nativeChatComposerTargetIsRemote(target.ptyId) ? 'remote' : 'local' + }) + setHistory((prev) => pushHistory(prev, text)) + setDraft('') + setCaret(0) + clearImageAttachments() + setNotice(null) + }, [ + agent, + clearImageAttachments, + draft, + imageAttachments, + disabled, + resolveTarget, + onOptimisticSend, + onSlashCommand + ]) + + const interrupt = useCallback(() => { + if (isWorking && onStop) { + onStop() + return + } + const target = resolveTarget() + if (!target) { + return + } + sendRuntimePtyInput(target.settings, target.ptyId, ESC) + }, [isWorking, onStop, resolveTarget]) + + const chooseSlash = useCallback((command: SlashCommandSuggestion) => { + const next = applySlashSuggestion(command) + setDraft(next) + setCaret(next.length) + setActiveSuggestion(0) + textareaRef.current?.focus() + }, []) + + const dispatchSlash = useCallback( + (command: SlashCommandSuggestion) => { + const next = slashCommandDispatchText(command) + const target = resolveTarget() + if (!target || disabled) { + return + } + sendNativeChatMessage(target.settings, target.ptyId, next) + // Surface the command as a system line (this is the autocomplete-menu + // dispatch path; the typed-Enter path in `send` does the same). + onSlashCommand?.(next.trim()) + emitNativeChatMessageSent({ + agent, + runtime: nativeChatComposerTargetIsRemote(target.ptyId) ? 'remote' : 'local' + }) + setHistory((prev) => pushHistory(prev, next)) + setDraft('') + setCaret(0) + setActiveSuggestion(0) + setNotice(null) + }, + [agent, disabled, resolveTarget, onSlashCommand] + ) + + const handlePaste = useCallback( + (event: React.ClipboardEvent) => { + const hasImage = Array.from(event.clipboardData.items).some((item) => + item.type.startsWith('image/') + ) + if (!hasImage) { + return + } + event.preventDefault() + // Why: snapshot the caret before the async temp-file round-trip — `caret` + // state can move (further typing/selection) while the await is in flight. + const caretAtPaste = caret + void (async () => { + const tempPath = await window.api.ui.saveClipboardImageAsTempFile() + if (!tempPath) { + return + } + const result = resolveImagePaste(agent, tempPath) + if (result.kind === 'unsupported') { + setNotice( + translate( + 'components.native-chat.composer.imageUnsupported', + 'Image paste is not supported for this agent.' + ) + ) + return + } + attachLocalPaths([result.path]) + setCaret(caretAtPaste) + setNotice(null) + })() + }, + [agent, attachLocalPaths, caret] + ) + + const handleKeyDown = useNativeChatComposerKeyDown({ + autocomplete, + activeSuggestion, + draft, + caret, + history, + chooseSlash, + dispatchSlash, + interrupt, + send, + setActiveSuggestion, + setDraft, + setCaret, + setHistory + }) + + return ( + { + setDraft(value) + setHistory((prev) => ({ entries: prev.entries, index: null })) + syncCaret(element) + setActiveSuggestion(0) + }} + onTextareaSelect={syncCaret} + onKeyDown={handleKeyDown} + onPaste={handlePaste} + onChooseSlash={chooseSlash} + onAcceptMention={() => { + if (autocomplete.mode !== 'mention') { + return + } + const result = applyMentionSuggestion(draft, caret, autocomplete.query) + setDraft(result.draft) + setCaret(result.caret) + textareaRef.current?.focus() + }} + onChooseSkill={(skill) => { + const result = applySkillSuggestion(draft, caret, skill.name) + setDraft(result.draft) + setCaret(result.caret) + setActiveSuggestion(0) + textareaRef.current?.focus() + }} + onRemoveImageAttachment={(id) => removeImageAttachment(id)} + onAttach={pickAttachment} + onDictationToggle={toggleDictation} + onDictationHoldStart={startHoldDictation} + onDictationHoldEnd={stopHoldDictation} + onSend={send} + onStop={onStop} + /> + ) + } +) diff --git a/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx b/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx new file mode 100644 index 00000000000..fb67917b332 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx @@ -0,0 +1,125 @@ +import { ArrowUp, Mic, Plus, Square } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { translate } from '@/i18n/i18n' + +export type NativeChatComposerActionsProps = { + attachDisabled: boolean + dictationDisabled: boolean + sendDisabled: boolean + isWorking: boolean + isDictating: boolean + isDictationHoldMode: boolean + onAttach: () => void + onDictationToggle: () => void + onDictationHoldStart: () => void + onDictationHoldEnd: () => void + onSend: () => void + onStop?: () => void +} + +export function NativeChatComposerActions({ + attachDisabled, + dictationDisabled, + sendDisabled, + isWorking, + isDictating, + isDictationHoldMode, + onAttach, + onDictationToggle, + onDictationHoldStart, + onDictationHoldEnd, + onSend, + onStop +}: NativeChatComposerActionsProps): React.JSX.Element { + const dictationLabel = isDictating + ? translate('components.native-chat.composer.stopDictation', 'Stop dictation') + : translate('components.native-chat.composer.startDictation', 'Start dictation') + return ( +
+ + + + + + {translate('components.native-chat.composer.attach', 'Attach file')} + + +
+ + + + + + {dictationLabel} + + + +
+
+ ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx new file mode 100644 index 00000000000..40cb7b6adf7 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx @@ -0,0 +1,181 @@ +import type { ClipboardEventHandler, KeyboardEventHandler, RefObject } from 'react' +import { Image as ImageIcon, ImageOff, X } from 'lucide-react' +import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' +import { NATIVE_FILE_DROP_TARGET } from '../../../../shared/native-file-drop' +import { basename } from '@/lib/path' +import type { ComposerAutocomplete, SlashCommandSuggestion } from './native-chat-composer-state' +import { + NativeChatMentionHint, + NativeChatSkillMenu, + NativeChatSlashMenu +} from './NativeChatAutocompleteMenus' +import { NativeChatComposerActions } from './NativeChatComposerActions' +import { nativeChatComposerPlaceholder } from './native-chat-composer-target' +import type { DiscoveredSkill } from '../../../../shared/skills' + +export type NativeChatComposerFieldProps = { + textareaRef: RefObject + draft: string + disabled: boolean + hasPty: boolean + canSend: boolean + autocomplete: ComposerAutocomplete + activeSuggestion: number + notice: string | null + imageAttachments: readonly NativeChatComposerImageAttachment[] + sendButtonDisabled: boolean + isWorking: boolean + attachDisabled: boolean + dictationDisabled: boolean + isDictating: boolean + isDictationHoldMode: boolean + onDraftChange: (value: string, element: HTMLTextAreaElement) => void + onTextareaSelect: (element: HTMLTextAreaElement) => void + onKeyDown: KeyboardEventHandler + onPaste: ClipboardEventHandler + onChooseSlash: (command: SlashCommandSuggestion) => void + onAcceptMention: () => void + onChooseSkill: (skill: DiscoveredSkill) => void + onRemoveImageAttachment: (id: string) => void + onAttach: () => void + onDictationToggle: () => void + onDictationHoldStart: () => void + onDictationHoldEnd: () => void + onSend: () => void + onStop?: () => void +} + +export type NativeChatComposerImageAttachment = { + id: string + path: string +} + +export function NativeChatComposerField({ + textareaRef, + draft, + disabled, + hasPty, + canSend, + autocomplete, + activeSuggestion, + notice, + imageAttachments, + sendButtonDisabled, + isWorking, + attachDisabled, + dictationDisabled, + isDictating, + isDictationHoldMode, + onDraftChange, + onTextareaSelect, + onKeyDown, + onPaste, + onChooseSlash, + onAcceptMention, + onChooseSkill, + onRemoveImageAttachment, + onAttach, + onDictationToggle, + onDictationHoldStart, + onDictationHoldEnd, + onSend, + onStop +}: NativeChatComposerFieldProps): React.JSX.Element { + return ( +
+
+
+ {autocomplete.mode === 'slash' && autocomplete.suggestions.length > 0 ? ( + + ) : null} + {autocomplete.mode === 'mention' ? ( + + ) : null} + {autocomplete.mode === 'skill' ? ( + + ) : null} + {notice ? ( +
+ + {notice} +
+ ) : null} +
+ {imageAttachments.length > 0 ? ( +
+ {imageAttachments.map((attachment) => ( +
+ + {basename(attachment.path)} + +
+ ))} +
+ ) : null} +