diff --git a/README.md b/README.md index 00989a01325..b449142d82d 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere. -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.16](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.17](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.17/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) @@ -229,7 +229,7 @@ yay -S stably-orca-bin Pair with your desktop app to monitor and steer your agents from your phone. - **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA) -- **Android:** [Download APK 0.0.16](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk) +- **Android:** [Download APK 0.0.17](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.17/app-release.apk) --- 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/docs/readme/README.es.md b/docs/readme/README.es.md index cb78aa38095..e4bed124354 100644 --- a/docs/readme/README.es.md +++ b/docs/readme/README.es.md @@ -36,7 +36,7 @@ Supervisa y dirige a tus agentes desde el teléfono — recibe una notificación cuando un agente termine y envía instrucciones de seguimiento desde cualquier lugar. -[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) +[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.17/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) @@ -227,7 +227,7 @@ yay -S stably-orca-bin Vincúlala con tu app de escritorio para supervisar y dirigir a tus agentes desde el teléfono. - **iOS:** [Descargar desde App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk) +- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.17/app-release.apk) --- diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md index e2d93abd1f8..68f74264921 100644 --- a/docs/readme/README.ja.md +++ b/docs/readme/README.ja.md @@ -36,7 +36,7 @@ スマートフォンからエージェントを監視・操作 — エージェントの完了を通知で受け取り、どこからでもフォローアップを送信できます。 -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.17/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile) @@ -227,7 +227,7 @@ yay -S stably-orca-bin デスクトップアプリとペアリングして、スマートフォンからエージェントを監視・操作できます。 - **iOS:** [App Store からダウンロード](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk) +- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.17/app-release.apk) --- diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md index 4a63f512b9f..7c8de89e9fe 100644 --- a/docs/readme/README.ko.md +++ b/docs/readme/README.ko.md @@ -36,7 +36,7 @@ 휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다. -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.17/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile) @@ -227,7 +227,7 @@ yay -S stably-orca-bin 데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요. - **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk) +- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.17/app-release.apk) --- diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index b3e97dd77cf..b43cbb37c6e 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -36,7 +36,7 @@ 用手机监控并指挥你的智能体 — 智能体完成时收到通知,随时随地发送后续指令。 -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.17/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile) @@ -227,7 +227,7 @@ yay -S stably-orca-bin 与桌面应用配对,用手机监控并指挥你的智能体。 - **iOS:** [从 App Store 下载](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk) +- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.17/app-release.apk) --- diff --git a/package.json b/package.json index 22e17d96b67..c8f437619a5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.104-rc.6", + "version": "1.4.105-rc.0", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", 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/automations/headless-workspace-create.test.ts b/src/main/automations/headless-workspace-create.test.ts index 3e247af5046..b29496eb283 100644 --- a/src/main/automations/headless-workspace-create.test.ts +++ b/src/main/automations/headless-workspace-create.test.ts @@ -38,6 +38,7 @@ const automation: Automation = { workspaceMode: 'new_per_run', workspaceId: null, baseBranch: 'origin/main', + setupDecision: 'skip', reuseSession: false, timezone: 'UTC', rrule: 'FREQ=DAILY', @@ -67,7 +68,7 @@ describe('headless automation workspace create args', () => { repoSelector: 'repo-1', name: 'auto-nightly-review-run-20260102T0304', baseBranch: 'origin/main', - setupDecision: 'inherit', + setupDecision: 'skip', activate: false, createdWithAgent: 'codex', startupAgent: 'codex', @@ -88,4 +89,18 @@ describe('headless automation workspace create args', () => { } }) }) + + it('falls back to skip for legacy automations without a saved setup decision', () => { + const args = buildHeadlessAutomationWorktreeCreateArgs({ + automation: { ...automation, setupDecision: undefined }, + run: { + id: 'run-1', + title: 'Nightly review run', + scheduledFor: Date.UTC(2026, 0, 2, 3, 4, 5) + }, + repo + }) + + expect(args.setupDecision).toBe('skip') + }) }) diff --git a/src/main/automations/headless-workspace-create.ts b/src/main/automations/headless-workspace-create.ts index 497ad85573b..7e6e2f43e18 100644 --- a/src/main/automations/headless-workspace-create.ts +++ b/src/main/automations/headless-workspace-create.ts @@ -36,7 +36,7 @@ export function buildHeadlessAutomationWorktreeCreateArgs({ repoSelector: repo.id, name: buildHeadlessAutomationWorkspaceName(run.title, run.scheduledFor), baseBranch: automation.baseBranch ?? undefined, - setupDecision: 'inherit', + setupDecision: automation.setupDecision ?? 'skip', activate: false, createdWithAgent: automation.agentId, startupAgent: automation.agentId, diff --git a/src/main/index.ts b/src/main/index.ts index f8ab73b4aa2..6f27111f841 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -147,6 +147,7 @@ import { type SyntheticAgentTitleProfile } from '../shared/synthetic-agent-title' import type { AgentStatusState } from '../shared/agent-status-types' +import { resolveTuiAgentPermissionMode } from '../shared/tui-agent-permissions' import { KeybindingService } from './keybindings/keybinding-service' import { applyElectronProxySettings } from './network/proxy-settings' import { preserveAgentAuthBeforeRestart } from './agent-auth-restart-preservation' @@ -855,7 +856,20 @@ function openMainWindow(): BrowserWindow { // Why: some native OSC titles miss terminal idle/permission frames. // Inject hook-derived frames so the renderer title tracker updates too. const profile = getSyntheticAgentTitleProfile(payload.agentType) - if (profile && shouldDriveSyntheticAgentTitleFromHook(payload.agentType, payload.state)) { + const suppressSyntheticCodexAutoApprovalTitle = + payload.agentType === 'codex' && + (payload.state === 'waiting' || payload.state === 'blocked') + ? shouldSuppressCodexAutoApprovalSyntheticTitleFromHook({ + agentType: payload.agentType, + state: payload.state, + launchConfig: runtime?.getAgentStatusLaunchConfigForPaneKey(paneKey, { launchToken }) + }) + : false + if ( + profile && + shouldDriveSyntheticAgentTitleFromHook(payload.agentType, payload.state) && + !suppressSyntheticCodexAutoApprovalTitle + ) { driveSyntheticTitleFromHook(paneKey, payload.state, profile) } } @@ -1282,6 +1296,32 @@ function driveSyntheticTitleFromHook( }) } +function shouldSuppressCodexAutoApprovalSyntheticTitleFromHook(args: { + agentType: string | null | undefined + state: AgentStatusState + launchConfig: + | { + agentArgs?: string | null + agentEnv?: Record | null + } + | null + | undefined +}): boolean { + if (args.agentType !== 'codex' || (args.state !== 'waiting' && args.state !== 'blocked')) { + return false + } + if (!args.launchConfig) { + return false + } + return ( + resolveTuiAgentPermissionMode({ + agent: 'codex', + agentArgs: args.launchConfig.agentArgs, + agentEnv: args.launchConfig.agentEnv + }) === 'yolo' + ) +} + app.whenReady().then(async () => { logStartupMilestone('app-ready') electronApp.setAppUserModelId(devInstanceIdentity.appUserModelId) 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/preflight-command-exec.ts b/src/main/ipc/preflight-command-exec.ts new file mode 100644 index 00000000000..4b807ba8afb --- /dev/null +++ b/src/main/ipc/preflight-command-exec.ts @@ -0,0 +1,93 @@ +import { execFile } from 'child_process' +import path from 'path' +import { promisify } from 'util' +import { buildLocalPreflightEnv } from './preflight-local-env' +import { runPreflightCommandInWsl } from './preflight-wsl-command' +import type { WslPreflightTarget } from './preflight-wsl-agent-detection' + +const execFileAsync = promisify(execFile) +export const PREFLIGHT_COMMAND_TIMEOUT_MS = 5000 + +export type PreflightCommandResult = { stdout: string; stderr: string } + +export function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'` +} + +async function withPreflightTimeout(command: string, commandPromise: Promise): Promise { + let timeout: ReturnType | null = null + try { + return await Promise.race([ + commandPromise, + new Promise((_resolve, reject) => { + timeout = setTimeout(() => { + const error = Object.assign(new Error(`Timed out running ${command}`), { + code: 'ETIMEDOUT' + }) + reject(error) + }, PREFLIGHT_COMMAND_TIMEOUT_MS) + if (typeof timeout.unref === 'function') { + timeout.unref() + } + }) + ]) + } finally { + if (timeout) { + clearTimeout(timeout) + } + } +} + +export async function execLocalPreflightCommand( + command: string, + args: string[] +): Promise { + const env = buildLocalPreflightEnv() + const commandPromise = execFileAsync(command, args, { + encoding: 'utf-8', + timeout: PREFLIGHT_COMMAND_TIMEOUT_MS, + ...(env ? { env } : {}) + }) as Promise + + return withPreflightTimeout(command, commandPromise) +} + +export async function execCommandInWsl( + target: WslPreflightTarget, + command: string +): Promise { + const commandPromise = runPreflightCommandInWsl(target, command, PREFLIGHT_COMMAND_TIMEOUT_MS) + return withPreflightTimeout('wsl.exe', commandPromise) +} + +export async function isCommandAvailable( + command: string, + wslTarget?: WslPreflightTarget +): Promise { + try { + await (wslTarget + ? execCommandInWsl(wslTarget, `${shellQuote(command)} --version`) + : execLocalPreflightCommand(command, ['--version'])) + return true + } catch { + return false + } +} + +export async function isCommandOnPath( + command: string, + wslTarget?: WslPreflightTarget +): Promise { + const finder = process.platform === 'win32' ? 'where' : 'which' + try { + const { stdout } = wslTarget + ? await execCommandInWsl(wslTarget, `command -v ${shellQuote(command)}`) + : await execLocalPreflightCommand(finder, [command]) + return stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .some((line) => path.isAbsolute(line)) + } catch { + return false + } +} diff --git a/src/main/ipc/preflight-remote-windows-terminal-capabilities.ts b/src/main/ipc/preflight-remote-windows-terminal-capabilities.ts new file mode 100644 index 00000000000..f28886cdaec --- /dev/null +++ b/src/main/ipc/preflight-remote-windows-terminal-capabilities.ts @@ -0,0 +1,30 @@ +import { getActiveMultiplexer } from './ssh' + +export type RemoteWindowsTerminalCapabilities = { + wslAvailable: boolean + wslDistros: string[] + pwshAvailable: boolean + gitBashAvailable: boolean + hostPlatform: NodeJS.Platform | null +} + +const EMPTY_REMOTE_WINDOWS_TERMINAL_CAPABILITIES: RemoteWindowsTerminalCapabilities = { + wslAvailable: false, + wslDistros: [], + pwshAvailable: false, + gitBashAvailable: false, + hostPlatform: null +} + +export async function detectRemoteWindowsTerminalCapabilities(args: { + connectionId: string +}): Promise { + const mux = getActiveMultiplexer(args.connectionId) + if (!mux || mux.isDisposed()) { + return EMPTY_REMOTE_WINDOWS_TERMINAL_CAPABILITIES + } + const result = (await mux.request('preflight.detectWindowsTerminalCapabilities', {})) as + | RemoteWindowsTerminalCapabilities + | undefined + return result ?? EMPTY_REMOTE_WINDOWS_TERMINAL_CAPABILITIES +} diff --git a/src/main/ipc/preflight.test.ts b/src/main/ipc/preflight.test.ts index d3ffef854ef..944ee1b4418 100644 --- a/src/main/ipc/preflight.test.ts +++ b/src/main/ipc/preflight.test.ts @@ -708,6 +708,35 @@ describe('preflight', () => { expect(request).not.toHaveBeenCalled() }) + it('sends remote Windows shell capability probes through the SSH preflight path', async () => { + const request = vi.fn().mockResolvedValue({ + wslAvailable: true, + wslDistros: ['Ubuntu'], + pwshAvailable: true, + gitBashAvailable: true, + hostPlatform: 'win32' + }) + getActiveMultiplexerMock.mockReturnValue({ + isDisposed: () => false, + request + }) + + registerPreflightHandlers() + + await expect( + handlers['preflight:detectRemoteWindowsTerminalCapabilities'](undefined, { + connectionId: 'ssh-1' + }) + ).resolves.toEqual({ + wslAvailable: true, + wslDistros: ['Ubuntu'], + pwshAvailable: true, + gitBashAvailable: true, + hostPlatform: 'win32' + }) + expect(request).toHaveBeenCalledWith('preflight.detectWindowsTerminalCapabilities', {}) + }) + it('detects agents from the selected WSL distro for a WSL workspace', async () => { Object.defineProperty(process, 'platform', { configurable: true, diff --git a/src/main/ipc/preflight.ts b/src/main/ipc/preflight.ts index fbe00db4605..cc14ca490b6 100644 --- a/src/main/ipc/preflight.ts +++ b/src/main/ipc/preflight.ts @@ -1,7 +1,4 @@ import { ipcMain } from 'electron' -import { execFile } from 'child_process' -import { promisify } from 'util' -import path from 'path' import { getTuiAgentDetectCommands, TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' import type { PathSource, ShellHydrationFailureReason } from '../../shared/types' import { hydrateShellPath, mergePathSegments } from '../startup/hydrate-shell-path' @@ -11,13 +8,20 @@ import { getGiteaAuthStatus } from '../gitea/client' import { _resetKnownHostsCache } from '../gitlab/gl-utils' import { getActiveMultiplexer } from './ssh' import { detectWslCommandsOnPath, type WslPreflightTarget } from './preflight-wsl-agent-detection' -import { runPreflightCommandInWsl } from './preflight-wsl-command' import { detectCommandsInInstallDirs } from './local-agent-install-dir-detection' -import { buildLocalPreflightEnv } from './preflight-local-env' import { getPreflightWslTarget, type PreflightRuntimeContext } from './preflight-runtime-target' import { hydrateShellPathForAgentDetection } from './agent-detection-shell-path' -const execFileAsync = promisify(execFile) -const PREFLIGHT_COMMAND_TIMEOUT_MS = 5000 +import { + execCommandInWsl, + execLocalPreflightCommand, + isCommandAvailable, + isCommandOnPath, + shellQuote +} from './preflight-command-exec' +import { + detectRemoteWindowsTerminalCapabilities, + type RemoteWindowsTerminalCapabilities +} from './preflight-remote-windows-terminal-capabilities' export type PreflightStatus = { git: { installed: boolean } @@ -44,6 +48,9 @@ export type PreflightStatus = { } } +export { detectRemoteWindowsTerminalCapabilities } +export type { RemoteWindowsTerminalCapabilities } + // Why: cache the result so repeated Landing mounts don't re-spawn processes. // The check only runs once per app session — relaunch to re-check. let cached: PreflightStatus | null = null @@ -53,92 +60,6 @@ export function _resetPreflightCache(): void { cached = null } -function shellQuote(value: string): string { - return `'${value.replace(/'/g, "'\\''")}'` -} - -type PreflightCommandResult = { stdout: string; stderr: string } - -// Why: a broken PATH shim or auth helper should not keep startup/settings -// preflight IPC pending forever; WSL probes already use the same deadline. -async function withPreflightTimeout(command: string, commandPromise: Promise): Promise { - let timeout: ReturnType | null = null - try { - return await Promise.race([ - commandPromise, - new Promise((_resolve, reject) => { - timeout = setTimeout(() => { - const error = Object.assign(new Error(`Timed out running ${command}`), { - code: 'ETIMEDOUT' - }) - reject(error) - }, PREFLIGHT_COMMAND_TIMEOUT_MS) - if (typeof timeout.unref === 'function') { - timeout.unref() - } - }) - ]) - } finally { - if (timeout) { - clearTimeout(timeout) - } - } -} - -async function execLocalPreflightCommand( - command: string, - args: string[] -): Promise { - const env = buildLocalPreflightEnv() - const commandPromise = execFileAsync(command, args, { - encoding: 'utf-8', - timeout: PREFLIGHT_COMMAND_TIMEOUT_MS, - ...(env ? { env } : {}) - }) as Promise - - return withPreflightTimeout(command, commandPromise) -} - -async function execCommandInWsl( - target: WslPreflightTarget, - command: string -): Promise<{ stdout: string; stderr: string }> { - const commandPromise = runPreflightCommandInWsl(target, command, PREFLIGHT_COMMAND_TIMEOUT_MS) - return withPreflightTimeout('wsl.exe', commandPromise) -} - -async function isCommandAvailable( - command: string, - wslTarget?: WslPreflightTarget -): Promise { - try { - await (wslTarget - ? execCommandInWsl(wslTarget, `${shellQuote(command)} --version`) - : execLocalPreflightCommand(command, ['--version'])) - return true - } catch { - return false - } -} - -// Why: `which`/`where` is faster than spawning the agent binary itself and avoids -// triggering any agent-specific startup side-effects. This gives a reliable -// PATH-based check without requiring `--version` support from each agent. -async function isCommandOnPath(command: string, wslTarget?: WslPreflightTarget): Promise { - const finder = process.platform === 'win32' ? 'where' : 'which' - try { - const { stdout } = wslTarget - ? await execCommandInWsl(wslTarget, `command -v ${shellQuote(command)}`) - : await execLocalPreflightCommand(finder, [command]) - return stdout - .split(/\r?\n/) - .map((line) => line.trim()) - .some((line) => path.isAbsolute(line)) - } catch { - return false - } -} - const KNOWN_AGENT_COMMANDS = Object.entries(TUI_AGENT_CONFIG).flatMap(([id, config]) => getTuiAgentDetectCommands(config).map((cmd) => ({ id, @@ -378,4 +299,11 @@ export function registerPreflightHandlers(): void { return detectRemoteAgents(args) } ) + + ipcMain.handle( + 'preflight:detectRemoteWindowsTerminalCapabilities', + async (_event, args: { connectionId: string }): Promise => { + return detectRemoteWindowsTerminalCapabilities(args) + } + ) } 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 a898997a8d3..b016e679b61 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -1366,6 +1366,51 @@ describe('Store', () => { expect(reloaded.listAutomations()[0].reuseSession).toBe(false) }) + it('persists setup decisions only for new-per-run automations', async () => { + const store = await createStore() + store.addRepo(makeRepo()) + + const newPerRun = store.createAutomation({ + name: 'Fresh', + prompt: 'Run checks', + agentId: 'claude', + projectId: 'r1', + workspaceMode: 'new_per_run', + setupDecision: 'run', + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: new Date('2026-05-13T00:00:00Z').getTime() + }) + const existing = store.createAutomation({ + name: 'Reuse', + prompt: 'Summarize changes', + agentId: 'claude', + projectId: 'r1', + workspaceMode: 'existing', + workspaceId: 'wt1', + setupDecision: 'run', + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: new Date('2026-05-13T00:00:00Z').getTime() + }) + + expect(newPerRun.setupDecision).toBe('run') + expect(existing.setupDecision).toBeUndefined() + + const skipped = store.updateAutomation(newPerRun.id, { setupDecision: 'skip' }) + const switchedToExisting = store.updateAutomation(newPerRun.id, { + workspaceMode: 'existing', + workspaceId: 'wt1' + }) + + expect(skipped.setupDecision).toBe('skip') + expect(switchedToExisting.setupDecision).toBeUndefined() + expect( + store.updateAutomation(existing.id, { workspaceMode: 'new_per_run', setupDecision: 'run' }) + .setupDecision + ).toBe('run') + }) + it('derives automation source and run contexts from the project host setup', async () => { const store = await createStore() store.addRepo( @@ -8672,3 +8717,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/persistence.ts b/src/main/persistence.ts index d8d8f065d1a..99ebeb1f792 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -775,13 +775,27 @@ function normalizeAutomationPrecheckResult( } function normalizeAutomationSessionReuse(automation: Automation): Automation { + const setupDecision = normalizeAutomationSetupDecisionForWorkspaceMode( + automation.workspaceMode, + automation.setupDecision + ) return { ...automation, precheck: normalizeAutomationPrecheck(automation.precheck), + setupDecision, reuseSession: automation.workspaceMode === 'existing' && automation.reuseSession === true } } +function normalizeAutomationSetupDecisionForWorkspaceMode( + workspaceMode: Automation['workspaceMode'], + setupDecision: unknown +): Automation['setupDecision'] { + return workspaceMode === 'new_per_run' && (setupDecision === 'run' || setupDecision === 'skip') + ? setupDecision + : undefined +} + function getAutomationContextsForRepo( repo: Repo | undefined, projectHostSetups: readonly ProjectHostSetup[] @@ -4110,6 +4124,10 @@ export class Store { workspaceMode: input.workspaceMode, workspaceId: input.workspaceMode === 'existing' ? (input.workspaceId ?? null) : null, baseBranch: input.workspaceMode === 'new_per_run' ? (input.baseBranch ?? null) : null, + setupDecision: normalizeAutomationSetupDecisionForWorkspaceMode( + input.workspaceMode, + input.setupDecision + ), reuseSession: input.workspaceMode === 'existing' ? (input.reuseSession ?? false) : false, timezone: input.timezone, rrule: input.rrule, @@ -4177,6 +4195,12 @@ export class Store { ? (updates.baseBranch ?? null) : (current.baseBranch ?? null) : null, + setupDecision: + workspaceMode === 'new_per_run' + ? Object.hasOwn(updates, 'setupDecision') + ? normalizeAutomationSetupDecisionForWorkspaceMode(workspaceMode, updates.setupDecision) + : normalizeAutomationSetupDecisionForWorkspaceMode(workspaceMode, current.setupDecision) + : undefined, reuseSession: workspaceMode === 'existing' ? (updates.reuseSession ?? current.reuseSession ?? false) diff --git a/src/main/providers/ssh-pty-provider.test.ts b/src/main/providers/ssh-pty-provider.test.ts index 7a6af3da39a..1523f38f02f 100644 --- a/src/main/providers/ssh-pty-provider.test.ts +++ b/src/main/providers/ssh-pty-provider.test.ts @@ -67,6 +67,26 @@ describe('SshPtyProvider', () => { }) }) + it('forwards explicit shellOverride and terminalWindowsWslDistro to the relay mux', async () => { + mux.request.mockResolvedValue({ id: 'pty-2' }) + + await provider.spawn({ + cols: 120, + rows: 40, + shellOverride: 'powershell.exe', + terminalWindowsWslDistro: 'Ubuntu' + }) + + expect(mux.request).toHaveBeenCalledWith('pty.spawn', { + cols: 120, + rows: 40, + cwd: undefined, + env: { [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true' }, + shellOverride: 'powershell.exe', + terminalWindowsWslDistro: 'Ubuntu' + }) + }) + it('preserves an explicit remote Powerlevel10k wizard env value', async () => { mux.request.mockResolvedValue({ id: 'pty-2' }) diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts index c33910bad95..7284f2353ba 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -136,6 +136,10 @@ export class SshPtyProvider implements IPtyProvider { // Pi-compatible agent is being launched, while commandDelivery tells it // whether to submit the command itself for runtime-owned background PTYs. ...(opts.command ? { command: opts.command } : {}), + ...(opts.shellOverride !== undefined ? { shellOverride: opts.shellOverride } : {}), + ...(opts.terminalWindowsWslDistro !== undefined + ? { terminalWindowsWslDistro: opts.terminalWindowsWslDistro } + : {}), ...(opts.commandDelivery ? { commandDelivery: opts.commandDelivery } : {}), ...(opts.startupCommandDelivery ? { startupCommandDelivery: opts.startupCommandDelivery } diff --git a/src/main/runtime/orca-runtime-automations.test.ts b/src/main/runtime/orca-runtime-automations.test.ts index 9920e5c12ff..0c6d32d215d 100644 --- a/src/main/runtime/orca-runtime-automations.test.ts +++ b/src/main/runtime/orca-runtime-automations.test.ts @@ -82,6 +82,7 @@ describe('OrcaRuntimeService automation methods', () => { agentId: 'codex', repo: 'repo-1', workspaceMode: 'new_per_run', + setupDecision: 'skip', rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', dtstart: 1 }) @@ -94,7 +95,8 @@ describe('OrcaRuntimeService automation methods', () => { agentId: 'codex', projectId: 'repo-1', workspaceMode: 'new_per_run', - workspaceId: null + workspaceId: null, + setupDecision: 'skip' }) ) expect(automation.id).toBe('auto-1') @@ -127,6 +129,15 @@ describe('OrcaRuntimeService automation methods', () => { expect(store.updateAutomation).toHaveBeenCalledWith('auto-1', { baseBranch: null }) }) + it('passes setup decision updates through the shared store', async () => { + const store = makeStore([existingAutomation]) + const runtime = new OrcaRuntimeService(store as never) + + await runtime.updateAutomation('auto-1', { setupDecision: 'run' }) + + expect(store.updateAutomation).toHaveBeenCalledWith('auto-1', { setupDecision: 'run' }) + }) + it('passes session reuse updates for existing-workspace automations', async () => { const existing = { ...existingAutomation, diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 5f8cd7fc52b..0c11b668578 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -30,6 +30,7 @@ import { createSetupRunnerScript, getEffectiveHooks, getEffectiveHooksFromConfig, + getDefaultTabsLaunch, hasHooksFile, loadHooks, parseOrcaYaml, @@ -526,6 +527,7 @@ function resetRuntimeTestMocks(): void { vi.mocked(createSetupRunnerScript).mockReset() vi.mocked(getEffectiveHooks).mockReset() vi.mocked(getEffectiveHooksFromConfig).mockReset() + vi.mocked(getDefaultTabsLaunch).mockReset() vi.mocked(loadHooks).mockReset() vi.mocked(hasHooksFile).mockReset() vi.mocked(parseOrcaYaml).mockReset() @@ -534,6 +536,7 @@ function resetRuntimeTestMocks(): void { vi.mocked(shouldRunSetupForCreate).mockImplementation((_repo, decision) => decision === 'run') vi.mocked(getEffectiveHooks).mockReturnValue(null) vi.mocked(getEffectiveHooksFromConfig).mockReturnValue(null) + vi.mocked(getDefaultTabsLaunch).mockReturnValue(undefined) vi.mocked(loadHooks).mockReturnValue(null) vi.mocked(hasHooksFile).mockReturnValue(false) vi.mocked(parseOrcaYaml).mockReturnValue(null) @@ -3391,7 +3394,11 @@ describe('OrcaRuntimeService', () => { startup: { command: 'claude' } }) + // Why: runtime now provisions setup itself (fire-and-forget) and omits it + // from the RPC result so the caller does not double-spawn. The async spawn + // means we wait for both the agent and setup PTYs before asserting. expect(result.setup).toBeUndefined() + await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2)) expect(spawn).toHaveBeenNthCalledWith( 1, expect.objectContaining({ @@ -3539,7 +3546,7 @@ describe('OrcaRuntimeService', () => { setupDecision: 'run' }) - expect(spawn).toHaveBeenCalledTimes(2) + await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2)) const initialEnv = (spawn.mock.calls[0]![0] as { env?: Record }).env ?? {} const setupEnv = (spawn.mock.calls[1]![0] as { env?: Record }).env ?? {} expect(setupEnv.ORCA_TAB_ID).toBe(initialEnv.ORCA_TAB_ID) @@ -11104,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 () => { @@ -11171,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 () => { @@ -11244,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 () => { @@ -12786,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 () => { @@ -12857,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 () => { @@ -13384,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. @@ -18776,6 +18834,7 @@ describe('OrcaRuntimeService', () => { }) expect(result.setup).toBeUndefined() expect(activateWorktree).not.toHaveBeenCalled() + await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2)) expect(spawn).toHaveBeenNthCalledWith( 1, expect.objectContaining({ @@ -18892,7 +18951,10 @@ describe('OrcaRuntimeService', () => { expect(createSetupRunnerScript).toHaveBeenCalled() expect(runHook).not.toHaveBeenCalled() - expect(spawn).toHaveBeenCalledTimes(2) + // Why: runtime now provisions setup fire-and-forget, so the setup PTY spawns + // on a later tick. The wait-for-setup guarantee is enforced by the shell + // nonce/marker in the wrapped commands below, not by JS spawn ordering. + await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2)) const startupCommand = (spawn.mock.calls[0]![0] as { command: string }).command const setupCommand = (spawn.mock.calls[1]![0] as { command: string }).command const nonceMatch = startupCommand.match(/if \[ "\$seen" = ([0-9a-f-]+) \]/) @@ -18963,7 +19025,8 @@ describe('OrcaRuntimeService', () => { startup: { command: 'claude' } }) - expect(spawn).toHaveBeenCalledTimes(2) + // Why: setup now spawns fire-and-forget on a later tick; wait for both PTYs. + await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2)) expect(spawn).toHaveBeenNthCalledWith(1, expect.objectContaining({ command: 'claude' })) expect(spawn).toHaveBeenNthCalledWith( 2, @@ -19108,7 +19171,7 @@ describe('OrcaRuntimeService', () => { }) expect(activateWorktree).not.toHaveBeenCalled() - expect(spawn).toHaveBeenCalledTimes(2) + await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2)) const mainEnv = (spawn.mock.calls[0]![0] as { env?: Record }).env ?? {} const setupEnv = (spawn.mock.calls[1]![0] as { env?: Record }).env ?? {} expectStablePaneKeyEnv(mainEnv) @@ -19188,6 +19251,94 @@ describe('OrcaRuntimeService', () => { expect(spawn).toHaveBeenCalledTimes(1) }) + it('materializes default tabs for inactive local managed worktree creates', async () => { + const metaById: Record = {} + const runtimeStore = { + ...store, + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: (worktreeId: string, meta: Partial) => { + metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta } + return metaById[worktreeId] + } + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + const spawn = vi + .fn() + .mockResolvedValueOnce({ id: 'pty-default-dev' }) + .mockResolvedValueOnce({ id: 'pty-default-test' }) + const revealTerminalSession = vi + .fn() + .mockResolvedValueOnce({ tabId: 'tab-default-dev' }) + .mockResolvedValueOnce({ tabId: 'tab-default-test' }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession, + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + + computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-default-tabs') + ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-default-tabs') + vi.mocked(getDefaultTabsLaunch).mockReturnValue({ + runCommands: true, + tabs: [ + { title: 'Dev', command: 'pnpm dev' }, + { title: 'Test', command: 'pnpm test' } + ] + }) + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: '/tmp/workspaces/runtime-default-tabs', + head: 'def', + branch: 'runtime-default-tabs', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'runtime-default-tabs', + setupDecision: 'run' + }) + + expect(result.defaultTabs).toEqual({ + runCommands: true, + tabs: [ + { title: 'Dev', command: 'pnpm dev' }, + { title: 'Test', command: 'pnpm test' } + ] + }) + await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2)) + expect(spawn.mock.calls[0]![0]).toMatchObject({ command: 'pnpm dev' }) + expect(spawn.mock.calls[1]![0]).toMatchObject({ command: 'pnpm test' }) + expect(revealTerminalSession).toHaveBeenNthCalledWith( + 1, + result.worktree.id, + expect.objectContaining({ title: 'Dev', activate: false }) + ) + expect(revealTerminalSession).toHaveBeenNthCalledWith( + 2, + result.worktree.id, + expect.objectContaining({ title: 'Test', activate: false }) + ) + }) + it('uses desktop task agent selection and bracketed-pastes startup drafts for local worktrees', async () => { detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValue(['claude']) const metaById: Record = {} @@ -19656,7 +19807,7 @@ describe('OrcaRuntimeService', () => { activate: true }) - expect(spawn).toHaveBeenCalledTimes(2) + await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2)) expect(spawn).toHaveBeenNthCalledWith( 1, expect.objectContaining({ diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index f8dc05fcfa7..8595f783e4a 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -2305,6 +2305,7 @@ export class OrcaRuntimeService { workspaceMode: target.workspaceMode, workspaceId: target.workspaceId, baseBranch: input.baseBranch, + setupDecision: input.setupDecision, reuseSession: input.reuseSession, timezone: input.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone, rrule: input.rrule, @@ -2341,6 +2342,9 @@ export class OrcaRuntimeService { if (hasRuntimeAutomationUpdateValue(updates, 'baseBranch')) { patch.baseBranch = updates.baseBranch } + if (hasRuntimeAutomationUpdateValue(updates, 'setupDecision')) { + patch.setupDecision = updates.setupDecision + } if (hasRuntimeAutomationUpdateValue(updates, 'reuseSession')) { patch.reuseSession = updates.reuseSession } @@ -3289,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) } }) @@ -4204,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 = @@ -4226,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) { @@ -4244,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 ) @@ -4277,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) { @@ -4292,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) { @@ -11937,6 +11949,102 @@ export class OrcaRuntimeService { }) } + private async createDefaultTabTerminals( + worktreeSelector: string, + worktreeId: string, + defaultTabs: CreateWorktreeResult['defaultTabs'] | undefined + ): Promise { + if (!defaultTabs || defaultTabs.tabs.length === 0 || !this.ptyController?.spawn) { + return [] + } + const handles: string[] = [] + for (const template of defaultTabs.tabs) { + try { + const command = template.command?.trim() + const terminal = await this.createTerminal(worktreeSelector, { + ...(template.title ? { title: template.title } : {}), + ...(command && defaultTabs.runCommands ? { command } : {}) + }) + handles.push(terminal.handle) + if (template.color && terminal.tabId) { + await this.setMobileSessionTabProps(`id:${worktreeId}`, { + tabId: terminal.tabId, + color: template.color + }) + } + } catch (error) { + console.warn(`[worktree-create] Failed to create default tab for ${worktreeId}:`, error) + } + } + return handles + } + + private async provisionManagedWorktreeTerminals(args: { + worktreeSelector: string + worktreeId: string + worktreePath: string + setup?: CreateWorktreeResult['setup'] + defaultTabs?: CreateWorktreeResult['defaultTabs'] + primaryTerminalHandle?: string | null + hasStartupTerminal: boolean + setupCommandPlatform: 'windows' | 'posix' + // Why: when the agent startup is sequenced to wait for setup + // (waitForAgentStartup), the startup PTY runs a wrapper that already embeds + // the setup command. Pass that wrapped command through so the Setup tab runs + // the same script the agent is waiting on instead of a bare runner. + wrappedSetupCommand?: string + }): Promise<{ setupSpawned: boolean }> { + if (!this.ptyController?.spawn) { + return { setupSpawned: false } + } + let setupSpawned = false + try { + const defaultTabHandles = await this.createDefaultTabTerminals( + args.worktreeSelector, + args.worktreeId, + args.defaultTabs + ) + let primaryTerminalHandle = args.primaryTerminalHandle ?? defaultTabHandles[0] ?? null + const setupLaunchMode = + ( + this.requireStore().getSettings() as Partial< + Pick + > + ).setupScriptLaunchMode ?? 'new-tab' + if (!args.hasStartupTerminal && !primaryTerminalHandle) { + const terminal = await this.createTerminal(args.worktreeSelector) + primaryTerminalHandle = terminal.handle + } + if (args.setup) { + const setupCommand = + args.wrappedSetupCommand ?? + buildSetupRunnerCommand(args.setup.runnerScriptPath, args.setupCommandPlatform) + const shouldSplitSetup = + primaryTerminalHandle && + (setupLaunchMode === 'split-vertical' || setupLaunchMode === 'split-horizontal') + await (shouldSplitSetup + ? this.splitTerminal(primaryTerminalHandle!, { + direction: setupLaunchMode === 'split-horizontal' ? 'horizontal' : 'vertical', + command: setupCommand, + env: args.setup.envVars, + activate: false + }) + : this.createTerminal(args.worktreeSelector, { + title: 'Setup', + command: setupCommand, + env: args.setup.envVars + })) + setupSpawned = true + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.warn( + `[worktree-create] Failed to create setup/default terminals for ${args.worktreePath}: ${message}` + ) + } + return { setupSpawned } + } + private async waitForStartupFollowupReady( handle: string, expectedProcess: string @@ -12772,6 +12880,10 @@ export class OrcaRuntimeService { this.notifyWorktreesChanged(repo.id) const shouldActivate = args.activate === true || args.runHooks === true let didSpawnStartup = false + // Why: tracks whether runtime itself launched the setup script (via + // provisionManagedWorktreeTerminals). When true, renderer activation and the + // RPC return value must omit setup so the client does not spawn it a second + // time. Mirrors the wait-for-agent setup contract from #6298. let didSpawnSetup = false let startupTerminalHandle: string | null = null let startupTerminalTabId: string | null = null @@ -12839,53 +12951,39 @@ export class OrcaRuntimeService { console.warn(`[worktree-create] ${warning}`) } } - if (didSpawnStartup && setup && this.ptyController?.spawn) { - try { - // Why: reveal-on-adopt can create the startup tab before renderer - // activation handles setup. Honor the same split-vs-tab setting here - // because renderer activation will skip setup once the startup tab exists. - const setupCommand = - wrappedSetupCommandStr ?? - buildSetupRunnerCommand( - setup.runnerScriptPath, - getSetupRunnerCommandPlatformForPath( - setup.runnerScriptPath, - process.platform === 'win32' ? 'windows' : 'posix' - ) - ) - const setupLaunchMode = - (this.store.getSettings() as Partial>) - .setupScriptLaunchMode ?? 'new-tab' - if (setupLaunchMode === 'split-vertical' || setupLaunchMode === 'split-horizontal') { - if (!startupTerminalHandle) { - throw new Error('startup_terminal_missing') - } - await this.splitTerminal(startupTerminalHandle, { - direction: setupLaunchMode === 'split-horizontal' ? 'horizontal' : 'vertical', - command: setupCommand, - env: setup.envVars, - activate: false - }) - } else { - await this.createTerminal(`id:${worktree.id}`, { - title: 'Setup', - command: setupCommand, - env: setup.envVars - }) - } - didSpawnSetup = true - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - warning = warning - ? `${warning} Also failed to create the setup terminal for ${worktreePath}: ${message}` - : `Failed to create the setup terminal for ${worktreePath}: ${message}` - console.warn(`[worktree-create] ${warning}`) - } - } if (shouldActivate) { // Why: plain CLI creates should not steal the user's current workspace. // Explicit activation and hook-running still use renderer activation so // the user can watch prompts/output in a visible pane. + const runtimeWillProvisionTerminals = didSpawnStartup && Boolean(setup || defaultTabs) + if (runtimeWillProvisionTerminals) { + // Why: once runtime spawned the startup PTY, renderer activation may see + // an existing terminal and skip setup/default tabs. Await provisioning so + // a failed setup spawn falls back to renderer activation (which still + // carries the wrapped command for retry); #6298's wait-for-setup + // guarantee is enforced by the shell marker, not by spawn timing. + const provisioned = await this.provisionManagedWorktreeTerminals({ + worktreeSelector: `id:${worktree.id}`, + worktreeId: worktree.id, + worktreePath, + ...(setup ? { setup } : {}), + ...(defaultTabs ? { defaultTabs } : {}), + primaryTerminalHandle: startupTerminalHandle, + hasStartupTerminal: didSpawnStartup, + setupCommandPlatform: setup + ? isWindowsAbsolutePathLike(setup.runnerScriptPath) + ? 'windows' + : 'posix' + : 'posix', + // Why: carry the wait-for-agent wrapped setup command (#6298) so the + // Setup tab runs the same script the sequenced agent waits on. + ...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {}) + }) + didSpawnSetup = provisioned.setupSpawned + } + // Why: when runtime spawned setup, omit it from activation. When setup + // spawn failed, fall through with the wrapped command so renderer + // activation retries it. const activationSetup = didSpawnSetup ? undefined : setup @@ -12896,54 +12994,50 @@ export class OrcaRuntimeService { : {}) } : undefined + const activationDefaultTabs = runtimeWillProvisionTerminals ? undefined : defaultTabs if (effectiveStartup && !didSpawnStartup) { this.notifyActivateWorktree( repo.id, worktree.id, activationSetup, effectiveStartup, - defaultTabs + activationDefaultTabs ) } else { - this.notifyActivateWorktree(repo.id, worktree.id, activationSetup, undefined, defaultTabs) + this.notifyActivateWorktree( + repo.id, + worktree.id, + activationSetup, + undefined, + activationDefaultTabs + ) + } + } else if (this.ptyController?.spawn && (setup || defaultTabs || didSpawnStartup)) { + // Why: inactive terminal materialization matches normal worktree creation, + // but setup/default tab failures must not gate automation dispatch. + void this.provisionManagedWorktreeTerminals({ + worktreeSelector: `id:${worktree.id}`, + worktreeId: worktree.id, + worktreePath, + ...(setup ? { setup } : {}), + ...(defaultTabs ? { defaultTabs } : {}), + primaryTerminalHandle: startupTerminalHandle, + hasStartupTerminal: didSpawnStartup, + setupCommandPlatform: setup + ? isWindowsAbsolutePathLike(setup.runnerScriptPath) + ? 'windows' + : 'posix' + : 'posix', + ...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {}) + }) + // Why: runtime owns setup spawning here, so the RPC result must omit setup + // to keep the headless/mobile caller from launching it a second time. + if (setup) { + didSpawnSetup = true } } else if (this.ptyController?.spawn) { try { - let initialTerminalHandle: string | null = null - if (!didSpawnStartup) { - const terminal = await this.createTerminal(`id:${worktree.id}`) - initialTerminalHandle = terminal.handle - } - if (setup && !didSpawnSetup) { - const setupCommand = - wrappedSetupCommandStr ?? - buildSetupRunnerCommand( - setup.runnerScriptPath, - getSetupRunnerCommandPlatformForPath( - setup.runnerScriptPath, - process.platform === 'win32' ? 'windows' : 'posix' - ) - ) - const setupLaunchMode = - (this.store.getSettings() as Partial>) - .setupScriptLaunchMode ?? 'new-tab' - const shouldSplitSetup = - initialTerminalHandle && - (setupLaunchMode === 'split-vertical' || setupLaunchMode === 'split-horizontal') - await (shouldSplitSetup - ? this.splitTerminal(initialTerminalHandle!, { - direction: setupLaunchMode === 'split-horizontal' ? 'horizontal' : 'vertical', - command: setupCommand, - env: setup.envVars, - activate: false - }) - : this.createTerminal(`id:${worktree.id}`, { - title: 'Setup', - command: setupCommand, - env: setup.envVars - })) - didSpawnSetup = true - } + await this.createTerminal(`id:${worktree.id}`) } catch (err) { const message = err instanceof Error ? err.message : String(err) warning = warning @@ -13093,6 +13187,8 @@ export class OrcaRuntimeService { let warning = result.warning let didSpawnStartup = false + // Why: same no-double-spawn contract as the local path — once runtime + // provisions setup, omit it from activation and the RPC result. let didSpawnSetup = false let startupTerminalHandle: string | null = null let startupTerminalTabId: string | null = null @@ -13156,48 +13252,35 @@ export class OrcaRuntimeService { } } - if (didSpawnStartup && result.setup && this.ptyController?.spawn) { - try { - // Why: remote/mobile task creates spawn the agent terminal in runtime, - // so renderer activation never receives the setup payload. Runtime - // must apply the same user-selected split-vs-tab setup placement. - const setupCommand = - wrappedSetupCommandStr ?? - buildSetupRunnerCommand( - result.setup.runnerScriptPath, - getSetupRunnerCommandPlatformForPath(result.setup.runnerScriptPath, 'posix') - ) - const setupLaunchMode = - (this.store.getSettings() as Partial>) - .setupScriptLaunchMode ?? 'new-tab' - if (setupLaunchMode === 'split-vertical' || setupLaunchMode === 'split-horizontal') { - if (!startupTerminalHandle) { - throw new Error('startup_terminal_missing') - } - await this.splitTerminal(startupTerminalHandle, { - direction: setupLaunchMode === 'split-horizontal' ? 'horizontal' : 'vertical', - command: setupCommand, - env: result.setup.envVars, - activate: false - }) - } else { - await this.createTerminal(`path:${result.worktree.path}`, { - title: 'Setup', - command: setupCommand, - env: result.setup.envVars - }) - } - didSpawnSetup = true - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - warning = warning - ? `${warning} Also failed to create the setup terminal for ${result.worktree.path}: ${message}` - : `Failed to create the setup terminal for ${result.worktree.path}: ${message}` - } - } - const shouldActivate = args.activate === true || args.runHooks === true if (shouldActivate) { + const runtimeWillProvisionTerminals = + didSpawnStartup && Boolean(result.setup || result.defaultTabs) + if (runtimeWillProvisionTerminals) { + // Why: remote/mobile task creates spawn the agent terminal in runtime, + // so renderer activation may not materialize setup/default tabs. Await so + // a failed setup spawn falls back to renderer activation for retry. + const provisioned = await this.provisionManagedWorktreeTerminals({ + worktreeSelector: `path:${result.worktree.path}`, + worktreeId: result.worktree.id, + worktreePath: result.worktree.path, + ...(result.setup ? { setup: result.setup } : {}), + ...(result.defaultTabs ? { defaultTabs: result.defaultTabs } : {}), + primaryTerminalHandle: startupTerminalHandle, + hasStartupTerminal: didSpawnStartup, + setupCommandPlatform: result.setup + ? isWindowsAbsolutePathLike(result.setup.runnerScriptPath) + ? 'windows' + : 'posix' + : 'posix', + // Why: carry the wait-for-agent wrapped setup command (#6298) so the + // remote Setup tab runs the same script the sequenced agent waits on. + ...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {}) + }) + didSpawnSetup = provisioned.setupSpawned + } + // Why: omit setup from activation when runtime spawned it; on spawn + // failure fall through with the wrapped command so renderer retries. const activationSetup = didSpawnSetup ? undefined : result.setup @@ -13208,13 +13291,14 @@ export class OrcaRuntimeService { : {}) } : undefined + const activationDefaultTabs = runtimeWillProvisionTerminals ? undefined : result.defaultTabs if (args.startup && !didSpawnStartup) { this.notifyActivateWorktree( repo.id, result.worktree.id, activationSetup, args.startup, - result.defaultTabs + activationDefaultTabs ) } else { this.notifyActivateWorktree( @@ -13222,36 +13306,41 @@ export class OrcaRuntimeService { result.worktree.id, activationSetup, undefined, - result.defaultTabs + activationDefaultTabs ) } } - if (!args.startup && !shouldActivate && this.ptyController?.spawn) { + if ( + !shouldActivate && + this.ptyController?.spawn && + (result.setup || result.defaultTabs || didSpawnStartup) + ) { + // Why: inactive terminal materialization matches normal worktree creation, + // but setup/default tab failures must not gate automation dispatch. + void this.provisionManagedWorktreeTerminals({ + worktreeSelector: `path:${result.worktree.path}`, + worktreeId: result.worktree.id, + worktreePath: result.worktree.path, + ...(result.setup ? { setup: result.setup } : {}), + ...(result.defaultTabs ? { defaultTabs: result.defaultTabs } : {}), + primaryTerminalHandle: startupTerminalHandle, + hasStartupTerminal: didSpawnStartup, + setupCommandPlatform: result.setup + ? isWindowsAbsolutePathLike(result.setup.runnerScriptPath) + ? 'windows' + : 'posix' + : 'posix', + ...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {}) + }) + // Why: runtime owns setup spawning here, so omit setup from the RPC result + // to keep the headless/mobile caller from launching it a second time. + if (result.setup) { + didSpawnSetup = true + } + } else if (!shouldActivate && this.ptyController?.spawn) { try { - const terminal = await this.createTerminal(`path:${result.worktree.path}`) - if (result.setup && !didSpawnSetup) { - const setupCommand = buildSetupRunnerCommand( - result.setup.runnerScriptPath, - getSetupRunnerCommandPlatformForPath(result.setup.runnerScriptPath, 'posix') - ) - const setupLaunchMode = - (this.store.getSettings() as Partial>) - .setupScriptLaunchMode ?? 'new-tab' - await (setupLaunchMode === 'split-vertical' || setupLaunchMode === 'split-horizontal' - ? this.splitTerminal(terminal.handle, { - direction: setupLaunchMode === 'split-horizontal' ? 'horizontal' : 'vertical', - command: setupCommand, - env: result.setup.envVars, - activate: false - }) - : this.createTerminal(`path:${result.worktree.path}`, { - title: 'Setup', - command: setupCommand, - env: result.setup.envVars - })) - didSpawnSetup = true - } + await this.createTerminal(`path:${result.worktree.path}`) } catch (err) { const message = err instanceof Error ? err.message : String(err) warning = warning @@ -17737,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. @@ -17768,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 } @@ -17939,6 +18060,20 @@ export class OrcaRuntimeService { return this.getTerminalHandleForPaneKey(paneKey) ?? undefined } + getAgentStatusLaunchConfigForPaneKey( + paneKey: string, + args?: { launchToken?: string } + ): SleepingAgentLaunchConfig | undefined { + const pty = this.getPtyRecordForPaneKey(paneKey) + if (!pty?.launchConfig) { + return undefined + } + if (pty.launchToken === null || pty.launchToken !== args?.launchToken) { + return undefined + } + return copySleepingAgentLaunchConfig(pty.launchConfig) + } + private buildAgentOrchestrationByPaneKey(): | Record | undefined { @@ -18049,6 +18184,23 @@ export class OrcaRuntimeService { return null } + private getPtyRecordForPaneKey(paneKey: string): RuntimePtyWorktreeRecord | null { + const parsed = parsePaneKey(paneKey) + if (parsed) { + const leaf = this.leaves.get(this.getLeafKey(parsed.tabId, parsed.leafId)) + const pty = leaf?.ptyId ? this.ptysById.get(leaf.ptyId) : undefined + if (pty) { + return pty + } + } + for (const pty of this.ptysById.values()) { + if (pty.paneKey === paneKey) { + return pty + } + } + return null + } + private getPaneKeyForTerminalHandle(handle: string): string | null { const livePty = this.getLivePtyForHandle(handle) if (livePty?.pty.paneKey) { 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/automations.test.ts b/src/main/runtime/rpc/methods/automations.test.ts index fe918875d22..c5a8cf37440 100644 --- a/src/main/runtime/rpc/methods/automations.test.ts +++ b/src/main/runtime/rpc/methods/automations.test.ts @@ -48,6 +48,7 @@ describe('automation RPC methods', () => { providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' } }, repo: 'repo-1', + setupDecision: 'skip', reuseSession: true, rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', dtstart: 1 @@ -58,6 +59,7 @@ describe('automation RPC methods', () => { id: 'auto-1', updates: { enabled: false, + setupDecision: 'run', reuseSession: false, rrule: '0 9 * * 1-5', dtstart: 2 @@ -79,12 +81,18 @@ describe('automation RPC methods', () => { runContext: expect.objectContaining({ hostId: 'runtime:gpu' }), sourceContext: expect.objectContaining({ hostId: 'local' }), repo: 'repo-1', + setupDecision: 'skip', reuseSession: true }) ) expect(runtime.updateAutomation).toHaveBeenCalledWith( 'auto-1', - expect.objectContaining({ enabled: false, reuseSession: false, rrule: '0 9 * * 1-5' }) + expect.objectContaining({ + enabled: false, + setupDecision: 'run', + reuseSession: false, + rrule: '0 9 * * 1-5' + }) ) expect(runtime.deleteAutomation).toHaveBeenCalledWith('auto-1') expect(runtime.runAutomationNow).toHaveBeenCalledWith('auto-1') diff --git a/src/main/runtime/rpc/methods/automations.ts b/src/main/runtime/rpc/methods/automations.ts index 858e80596d0..d4e74b719fe 100644 --- a/src/main/runtime/rpc/methods/automations.ts +++ b/src/main/runtime/rpc/methods/automations.ts @@ -22,6 +22,7 @@ const TuiAgent = requiredString('Missing provider').refine(isTuiAgent, { }) const AutomationWorkspaceMode = z.enum(['existing', 'new_per_run']).optional() +const SetupDecision = z.enum(['inherit', 'run', 'skip']).optional() const ExecutionHostId = requiredString('Missing host id').transform((value, ctx) => { const hostId = normalizeExecutionHostId(value) if (!hostId) { @@ -109,6 +110,7 @@ const AutomationCreate = z.object({ workspace: OptionalString, workspaceMode: AutomationWorkspaceMode, baseBranch: OptionalPlainString, + setupDecision: SetupDecision, reuseSession: OptionalBoolean, timezone: OptionalString, rrule: AutomationSchedule, @@ -129,6 +131,7 @@ const AutomationUpdateFields = z.object({ workspaceMode: AutomationWorkspaceMode, // Why: update patches distinguish omitted from null so callers can clear a saved base branch. baseBranch: OptionalNullablePlainString, + setupDecision: SetupDecision, reuseSession: OptionalBoolean, timezone: OptionalString, rrule: AutomationSchedule.optional(), 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/preflight.test.ts b/src/main/runtime/rpc/methods/preflight.test.ts index d28b97b8e53..e8f1ef82930 100644 --- a/src/main/runtime/rpc/methods/preflight.test.ts +++ b/src/main/runtime/rpc/methods/preflight.test.ts @@ -7,11 +7,13 @@ import { PREFLIGHT_METHODS } from './preflight' const { detectInstalledAgentsWithShellPathHydrationMock, detectRemoteAgentsMock, + detectRemoteWindowsTerminalCapabilitiesMock, refreshShellPathAndDetectAgentsMock, runPreflightCheckMock } = vi.hoisted(() => ({ detectInstalledAgentsWithShellPathHydrationMock: vi.fn(), detectRemoteAgentsMock: vi.fn(), + detectRemoteWindowsTerminalCapabilitiesMock: vi.fn(), refreshShellPathAndDetectAgentsMock: vi.fn(), runPreflightCheckMock: vi.fn() })) @@ -19,6 +21,7 @@ const { vi.mock('../../../ipc/preflight', () => ({ detectInstalledAgentsWithShellPathHydration: detectInstalledAgentsWithShellPathHydrationMock, detectRemoteAgents: detectRemoteAgentsMock, + detectRemoteWindowsTerminalCapabilities: detectRemoteWindowsTerminalCapabilitiesMock, refreshShellPathAndDetectAgents: refreshShellPathAndDetectAgentsMock, runPreflightCheck: runPreflightCheckMock })) @@ -81,4 +84,36 @@ describe('preflight RPC methods', () => { expect(detectRemoteAgentsMock).toHaveBeenCalledWith({ connectionId: 'ssh-1' }) expect(response).toMatchObject({ ok: true, result: ['claude'] }) }) + + it('detects remote Windows terminal capabilities through runtime RPC', async () => { + detectRemoteWindowsTerminalCapabilitiesMock.mockResolvedValueOnce({ + wslAvailable: true, + wslDistros: ['Ubuntu'], + pwshAvailable: true, + gitBashAvailable: true, + hostPlatform: 'win32' + }) + const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: PREFLIGHT_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('preflight.detectRemoteWindowsTerminalCapabilities', { + connectionId: 'ssh-1' + }) + ) + + expect(detectRemoteWindowsTerminalCapabilitiesMock).toHaveBeenCalledWith({ + connectionId: 'ssh-1' + }) + expect(response).toMatchObject({ + ok: true, + result: { + wslAvailable: true, + wslDistros: ['Ubuntu'], + pwshAvailable: true, + gitBashAvailable: true, + hostPlatform: 'win32' + } + }) + }) }) diff --git a/src/main/runtime/rpc/methods/preflight.ts b/src/main/runtime/rpc/methods/preflight.ts index 4e0f06201e9..aafbb96319c 100644 --- a/src/main/runtime/rpc/methods/preflight.ts +++ b/src/main/runtime/rpc/methods/preflight.ts @@ -2,6 +2,7 @@ import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' import { detectRemoteAgents, + detectRemoteWindowsTerminalCapabilities, detectInstalledAgentsWithShellPathHydration, refreshShellPathAndDetectAgents, runPreflightCheck @@ -13,6 +14,9 @@ const PreflightCheck = z.object({ const PreflightDetectRemoteAgents = z.object({ connectionId: z.string().min(1) }) +const PreflightDetectRemoteWindowsTerminalCapabilities = z.object({ + connectionId: z.string().min(1) +}) export const PREFLIGHT_METHODS: RpcMethod[] = [ defineMethod({ @@ -30,6 +34,11 @@ export const PREFLIGHT_METHODS: RpcMethod[] = [ params: PreflightDetectRemoteAgents, handler: async (params) => detectRemoteAgents(params) }), + defineMethod({ + name: 'preflight.detectRemoteWindowsTerminalCapabilities', + params: PreflightDetectRemoteWindowsTerminalCapabilities, + handler: async (params) => detectRemoteWindowsTerminalCapabilities(params) + }), defineMethod({ name: 'preflight.refreshAgents', params: null, 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 7a485016fe6..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' @@ -548,6 +549,13 @@ export type PreflightApi = { detectAgents: (args?: PreflightRuntimeContext) => Promise refreshAgents: (args?: PreflightRuntimeContext) => Promise detectRemoteAgents: (args: { connectionId: string }) => Promise + detectRemoteWindowsTerminalCapabilities: (args: { connectionId: string }) => Promise<{ + wslAvailable: boolean + wslDistros: string[] + pwshAvailable: boolean + gitBashAvailable: boolean + hostPlatform: NodeJS.Platform | null + }> } // Why: renderer-facing mirror of the daemon's `SessionInfo` + protocolVersion @@ -713,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 @@ -2007,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 d29a1b701c7..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 @@ -1771,7 +1777,16 @@ const api = { refreshAgents: (args?: PreflightRuntimeContext): Promise => ipcRenderer.invoke('preflight:refreshAgents', args), detectRemoteAgents: (args: { connectionId: string }): Promise => - ipcRenderer.invoke('preflight:detectRemoteAgents', args) + ipcRenderer.invoke('preflight:detectRemoteAgents', args), + detectRemoteWindowsTerminalCapabilities: (args: { + connectionId: string + }): Promise<{ + wslAvailable: boolean + wslDistros: string[] + pwshAvailable: boolean + gitBashAvailable: boolean + hostPlatform: NodeJS.Platform | null + }> => ipcRenderer.invoke('preflight:detectRemoteWindowsTerminalCapabilities', args) }, notifications: { @@ -3480,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/relay/preflight-handler.test.ts b/src/relay/preflight-handler.test.ts index 6d2f9339b9e..668354b03db 100644 --- a/src/relay/preflight-handler.test.ts +++ b/src/relay/preflight-handler.test.ts @@ -4,6 +4,14 @@ const { execFileAsyncMock } = vi.hoisted(() => ({ execFileAsyncMock: vi.fn() })) +const { isPwshAvailableMock, isWslAvailableMock, listWslDistrosMock, isGitBashAvailableMock } = + vi.hoisted(() => ({ + isPwshAvailableMock: vi.fn(), + isWslAvailableMock: vi.fn(), + listWslDistrosMock: vi.fn(), + isGitBashAvailableMock: vi.fn() + })) + vi.mock('child_process', () => { const execFileWithPromisify = Object.assign(vi.fn(), { [Symbol.for('nodejs.util.promisify.custom')]: execFileAsyncMock @@ -11,11 +19,19 @@ vi.mock('child_process', () => { return { execFile: execFileWithPromisify } }) +vi.mock('../main/pwsh', () => ({ isPwshAvailable: isPwshAvailableMock })) +vi.mock('../main/wsl', () => ({ + isWslAvailable: isWslAvailableMock, + listWslDistros: listWslDistrosMock +})) +vi.mock('../main/git-bash', () => ({ isGitBashAvailable: isGitBashAvailableMock })) + import { buildCommandLookupSpec, buildCommandLookupSpecs, hasAbsoluteCommandPath, - isCommandOnPathForRelay + isCommandOnPathForRelay, + PreflightHandler } from './preflight-handler' function lookupArgs(command: string, mode: '-lc' | '-ilc' = '-lc'): string[] { @@ -43,6 +59,10 @@ function fishLookupArgs(command: string): string[] { beforeEach(() => { execFileAsyncMock.mockReset() + isPwshAvailableMock.mockReset() + isWslAvailableMock.mockReset() + listWslDistrosMock.mockReset() + isGitBashAvailableMock.mockReset() }) describe('buildCommandLookupSpec', () => { @@ -214,3 +234,45 @@ describe('hasAbsoluteCommandPath', () => { ).toBe(true) }) }) + +describe('PreflightHandler', () => { + it('reports remote Windows shell capabilities through the SSH preflight path', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + isWslAvailableMock.mockReturnValue(true) + listWslDistrosMock.mockReturnValue(['Ubuntu']) + isPwshAvailableMock.mockReturnValue(true) + isGitBashAvailableMock.mockReturnValue(true) + + const requestHandlers = new Map) => Promise>() + const dispatcher = { + onRequest: vi.fn( + (method: string, handler: (params: Record) => Promise) => { + requestHandlers.set(method, handler) + } + ) + } + + new PreflightHandler(dispatcher as never) + + try { + const handler = requestHandlers.get('preflight.detectWindowsTerminalCapabilities') + expect(handler).toBeDefined() + await expect(handler!({})).resolves.toEqual({ + wslAvailable: true, + wslDistros: ['Ubuntu'], + pwshAvailable: true, + gitBashAvailable: true, + hostPlatform: 'win32' + }) + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + }) +}) diff --git a/src/relay/preflight-handler.ts b/src/relay/preflight-handler.ts index a0382d77275..cdfc42c2b21 100644 --- a/src/relay/preflight-handler.ts +++ b/src/relay/preflight-handler.ts @@ -4,6 +4,9 @@ import { promisify } from 'util' import path, { win32 } from 'path' import type { RelayDispatcher } from './dispatcher' import { buildRelayCommandEnv } from './relay-command-env' +import { isPwshAvailable } from '../main/pwsh' +import { isWslAvailable, listWslDistros } from '../main/wsl' +import { isGitBashAvailable } from '../main/git-bash' const execFileAsync = promisify(execFile) @@ -33,6 +36,9 @@ export class PreflightHandler { private registerHandlers(): void { this.dispatcher.onRequest('preflight.detectAgents', (p) => this.detectAgents(p)) + this.dispatcher.onRequest('preflight.detectWindowsTerminalCapabilities', () => + this.detectWindowsTerminalCapabilities() + ) } // Why: the client sends the command list rather than importing TUI_AGENT_CONFIG @@ -54,6 +60,28 @@ export class PreflightHandler { return { agents: [...new Set(results.filter((r) => r.installed).map((r) => r.id))] } } + private async detectWindowsTerminalCapabilities(): Promise<{ + wslAvailable: boolean + wslDistros: string[] + pwshAvailable: boolean + gitBashAvailable: boolean + hostPlatform: NodeJS.Platform | null + }> { + const [wslAvailable, pwshAvailable, gitBashAvailable] = await Promise.all([ + Promise.resolve(isWslAvailable()).catch(() => false), + Promise.resolve(isPwshAvailable()).catch(() => false), + Promise.resolve(isGitBashAvailable()).catch(() => false) + ]) + const wslDistros = wslAvailable ? await Promise.resolve(listWslDistros()).catch(() => []) : [] + return { + wslAvailable, + wslDistros, + pwshAvailable, + gitBashAvailable, + hostPlatform: process.platform + } + } + // Why: SSH exec channels give the relay a minimal environment without shell // startup files sourced. Ask the user's configured shell so agent dirs added // by zsh/bash/fish startup hooks match the remote terminal experience. diff --git a/src/relay/pty-handler.test.ts b/src/relay/pty-handler.test.ts index 3c9b0597030..321458c65da 100644 --- a/src/relay/pty-handler.test.ts +++ b/src/relay/pty-handler.test.ts @@ -4,6 +4,8 @@ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../shared/ssh-types' +import * as gitBash from '../main/git-bash' +import * as ptyShellUtils from './pty-shell-utils' import { resolveSetupAgentSequenceLaunchCommand, SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV @@ -169,6 +171,155 @@ describe('PtyHandler', () => { expect(handler.activePtyCount).toBe(1) }) + it('uses an explicit shell override and falls back to the default shell otherwise', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + const resolveDefaultShellSpy = vi + .spyOn(ptyShellUtils, 'resolveDefaultShell') + .mockReturnValue('/default-shell') + try { + await dispatcher.callRequest('pty.spawn', { + cols: 80, + rows: 24, + shellOverride: 'powershell.exe' + }) + expect(mockPtySpawn).toHaveBeenCalledWith( + 'powershell.exe', + expect.any(Array), + expect.any(Object) + ) + + mockPtySpawn.mockClear() + + await dispatcher.callRequest('pty.spawn', { cols: 80, rows: 24 }) + expect(mockPtySpawn).toHaveBeenCalledWith( + '/default-shell', + expect.any(Array), + expect.any(Object) + ) + } finally { + resolveDefaultShellSpy.mockRestore() + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + }) + + it('ignores Windows shell overrides on non-Windows relay hosts', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'linux' + }) + const resolveDefaultShellSpy = vi + .spyOn(ptyShellUtils, 'resolveDefaultShell') + .mockReturnValue('/default-shell') + try { + await dispatcher.callRequest('pty.spawn', { + cols: 80, + rows: 24, + shellOverride: 'powershell.exe' + }) + + expect(mockPtySpawn).toHaveBeenCalledWith( + '/default-shell', + expect.any(Array), + expect.any(Object) + ) + } finally { + resolveDefaultShellSpy.mockRestore() + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + }) + + it('rejects unsupported shell overrides on Windows relay hosts', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + try { + await expect( + dispatcher.callRequest('pty.spawn', { + cols: 80, + rows: 24, + shellOverride: 'notepad.exe' + }) + ).rejects.toThrow('Unsupported Windows shell override') + expect(mockPtySpawn).not.toHaveBeenCalled() + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + }) + + it('resolves the Git Bash sentinel to the remote bash.exe path on Windows', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + const resolveGitBashSpy = vi + .spyOn(gitBash, 'resolveWindowsGitBashShellPath') + .mockReturnValue('C:\\Program Files\\Git\\bin\\bash.exe') + try { + await dispatcher.callRequest('pty.spawn', { + cols: 80, + rows: 24, + shellOverride: 'git-bash' + }) + + expect(resolveGitBashSpy).toHaveBeenCalledWith('git-bash') + expect(mockPtySpawn).toHaveBeenCalledWith( + 'C:\\Program Files\\Git\\bin\\bash.exe', + expect.any(Array), + expect.any(Object) + ) + } finally { + resolveGitBashSpy.mockRestore() + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + }) + + it('passes the selected WSL distro to relay launches on Windows', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + try { + await dispatcher.callRequest('pty.spawn', { + cols: 80, + rows: 24, + shellOverride: 'wsl.exe', + terminalWindowsWslDistro: 'Ubuntu-24.04' + }) + + expect(mockPtySpawn).toHaveBeenCalledWith( + 'wsl.exe', + ['-d', 'Ubuntu-24.04'], + expect.any(Object) + ) + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + }) + it('keeps SSH spawn commands as hints unless provider delivery is requested', async () => { await dispatcher.callRequest('pty.spawn', { command: 'echo renderer-owned' }) diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 8ac4259d3ee..5f4fff86610 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -1,6 +1,8 @@ /* oxlint-disable max-lines */ import type { IPty } from 'node-pty' import type * as NodePty from 'node-pty' +import { resolveWindowsGitBashShellPath } from '../main/git-bash' +import { WINDOWS_GIT_BASH_SHELL } from '../shared/windows-terminal-shell' import type { RelayDispatcher, RequestContext } from './dispatcher' import { resolveDefaultShell, @@ -128,6 +130,32 @@ const ALLOWED_SIGNALS = new Set([ 'SIGUSR2' ]) +const ALLOWED_WINDOWS_SHELL_OVERRIDES = new Set([ + 'powershell.exe', + 'powershell', + 'pwsh.exe', + 'pwsh', + 'cmd.exe', + 'cmd', + 'wsl.exe', + 'wsl', + WINDOWS_GIT_BASH_SHELL +]) + +function resolvePtyShellOverride(shellOverride: string): string { + if (!shellOverride) { + return '' + } + if (process.platform !== 'win32') { + return '' + } + const normalized = shellOverride.toLowerCase() + if (!ALLOWED_WINDOWS_SHELL_OVERRIDES.has(normalized)) { + throw new Error(`Unsupported Windows shell override: ${shellOverride}`) + } + return resolveWindowsGitBashShellPath(shellOverride) ?? shellOverride +} + type SerializedPtyEntry = { id: string pid: number @@ -497,7 +525,10 @@ export class PtyHandler { const rows = (params.rows as number) || 24 const cwd = (params.cwd as string) || resolveDefaultCwd() const env = params.env as Record | undefined - const shell = resolveDefaultShell() + const shellOverride = + typeof params.shellOverride === 'string' ? params.shellOverride.trim() : '' + const resolvedShellOverride = resolvePtyShellOverride(shellOverride) + const shell = resolvedShellOverride || resolveDefaultShell() const id = `pty-${this.nextId++}` // Why: server-side augmenter values (ORCA_AGENT_HOOK_* and plugin overlay @@ -509,6 +540,8 @@ export class PtyHandler { // because no renderer TerminalPane exists to type the command. const paneKey = typeof env?.ORCA_PANE_KEY === 'string' ? env.ORCA_PANE_KEY : undefined const command = typeof params.command === 'string' ? params.command : undefined + const terminalWindowsWslDistro = + typeof params.terminalWindowsWslDistro === 'string' ? params.terminalWindowsWslDistro : null const commandDelivery = params.commandDelivery === 'provider' ? 'provider' : 'renderer' const shouldProviderDeliverCommand = commandDelivery === 'provider' && command !== undefined const spawnEnv = this.buildSpawnEnv(env, { id, paneKey, shell, command }) @@ -523,6 +556,7 @@ export class PtyHandler { // Why: renderer- and provider-delivered startup commands both use this // marker; the side responsible for delivery also strips it from output. const shellLaunch = getRelayShellLaunchConfig(shell, spawnEnv, process.platform, { + terminalWindowsWslDistro, emitReadyMarker: shouldEmitShellReadyMarker }) diff --git a/src/relay/pty-shell-launch.ts b/src/relay/pty-shell-launch.ts index d2ee25e2d41..935f48d46d4 100644 --- a/src/relay/pty-shell-launch.ts +++ b/src/relay/pty-shell-launch.ts @@ -25,7 +25,10 @@ function shellBasename(shellPath: string): string { return shellPath.replace(/\\/g, '/').split('/').pop()?.toLowerCase() ?? '' } -function windowsShellArgs(shellName: string): string[] | null { +function windowsShellArgs( + shellName: string, + options: { terminalWindowsWslDistro?: string | null } = {} +): string[] | null { if (shellName === 'powershell.exe' || shellName === 'powershell') { return ['-NoLogo'] } @@ -35,6 +38,10 @@ function windowsShellArgs(shellName: string): string[] | null { if (shellName === 'cmd.exe' || shellName === 'cmd') { return [] } + if (shellName === 'wsl.exe' || shellName === 'wsl') { + const distro = options.terminalWindowsWslDistro?.trim() + return distro ? ['-d', distro] : [] + } return null } @@ -247,14 +254,20 @@ export function getRelayShellLaunchConfig( shellPath: string, env: Record, platform: NodeJS.Platform = process.platform, - options: { emitReadyMarker?: boolean } = {} + options: { emitReadyMarker?: boolean; terminalWindowsWslDistro?: string | null } = {} ): RelayShellLaunchConfig { const shellName = shellBasename(shellPath) const emitReadyMarker = options.emitReadyMarker === true if (platform === 'win32') { // Why: pwsh also exists on POSIX remotes; Windows-specific shell args must // only apply when the relay itself is running on native Windows. - return { args: windowsShellArgs(shellName) ?? [], env: {} } + return { + args: + windowsShellArgs(shellName, { + terminalWindowsWslDistro: options.terminalWindowsWslDistro + }) ?? [], + env: {} + } } if (shellName !== 'zsh' && shellName !== 'bash') { diff --git a/src/renderer/src/components/automations/AutomationEditorDialog.tsx b/src/renderer/src/components/automations/AutomationEditorDialog.tsx index 6d113de78a4..f595290d2a2 100644 --- a/src/renderer/src/components/automations/AutomationEditorDialog.tsx +++ b/src/renderer/src/components/automations/AutomationEditorDialog.tsx @@ -6,7 +6,15 @@ import type { AutomationSchedulePreset, AutomationWorkspaceMode } from '../../../../shared/automations-types' -import type { GlobalSettings, Repo, TuiAgent, Worktree } from '../../../../shared/types' +import type { + GlobalSettings, + OrcaHooks, + ProjectHostSetup, + Repo, + SetupDecision, + TuiAgent, + Worktree +} from '../../../../shared/types' import { isValidAutomationCronSchedule, isValidAutomationSchedule @@ -32,6 +40,7 @@ export type AutomationDraft = { workspaceMode: AutomationWorkspaceMode workspaceId: string baseBranch: string + setupDecision?: Extract reuseSession: boolean precheckCommand: string precheckTimeoutSeconds: string @@ -53,6 +62,9 @@ type AutomationEditorDialogProps = { canSave: boolean createTarget: AutomationCreateTarget repos: Repo[] + projectHostSetups: ProjectHostSetup[] + automationYamlHooksByRepoKey: Record + getAutomationHooksCacheKey: (repoId: string) => string repoMap: Map worktrees: Worktree[] settings: GlobalSettings | null @@ -62,6 +74,7 @@ type AutomationEditorDialogProps = { onCreateTargetChange: (target: AutomationCreateTarget) => void onOpenChange: (open: boolean) => void onDraftChange: (updater: (current: AutomationDraft) => AutomationDraft) => void + onSetupDecisionTouched: () => void onApplyTemplate: (template: AutomationTemplate) => void onSave: () => void } @@ -74,6 +87,9 @@ export function AutomationEditorDialog({ canSave, createTarget, repos, + projectHostSetups, + automationYamlHooksByRepoKey, + getAutomationHooksCacheKey, repoMap, worktrees, settings, @@ -83,6 +99,7 @@ export function AutomationEditorDialog({ onCreateTargetChange, onOpenChange, onDraftChange, + onSetupDecisionTouched, onApplyTemplate, onSave }: AutomationEditorDialogProps): React.JSX.Element { @@ -159,6 +176,9 @@ export function AutomationEditorDialog({ isSaving={isSaving} canSave={canSave} repos={repos} + projectHostSetups={projectHostSetups} + automationYamlHooksByRepoKey={automationYamlHooksByRepoKey} + getAutomationHooksCacheKey={getAutomationHooksCacheKey} repoMap={repoMap} worktrees={worktrees} settings={settings} @@ -170,6 +190,7 @@ export function AutomationEditorDialog({ onProjectChange={onProjectChange} getRepoHostLabel={getRepoHostLabel} onDraftChange={onDraftChange} + onSetupDecisionTouched={onSetupDecisionTouched} onOpenChange={onOpenChange} onSave={onSave} /> diff --git a/src/renderer/src/components/automations/AutomationEditorDialogFooter.tsx b/src/renderer/src/components/automations/AutomationEditorDialogFooter.tsx index 22edb5f40a2..62a7be7aab7 100644 --- a/src/renderer/src/components/automations/AutomationEditorDialogFooter.tsx +++ b/src/renderer/src/components/automations/AutomationEditorDialogFooter.tsx @@ -7,11 +7,18 @@ import AgentCombobox from '@/components/agent/AgentCombobox' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' import type { AutomationWorkspaceMode } from '../../../../shared/automations-types' -import type { GlobalSettings, Repo, Worktree } from '../../../../shared/types' +import type { + GlobalSettings, + OrcaHooks, + ProjectHostSetup, + Repo, + Worktree +} from '../../../../shared/types' import type { AgentCatalogEntry } from '@/lib/agent-catalog' import { Field } from './automation-page-parts' import { AutomationMissedRunGraceField } from './AutomationMissedRunGraceField' import { AutomationSessionField } from './AutomationSessionField' +import { AutomationSetupDecisionField } from './AutomationSetupDecisionField' import { CreateFromPicker } from './CreateFromPicker' import { WorkspaceCombobox } from './WorkspaceCombobox' import AutomationProjectCombobox from './AutomationProjectCombobox' @@ -25,6 +32,9 @@ type AutomationEditorDialogFooterProps = { isSaving: boolean canSave: boolean repos: Repo[] + projectHostSetups: ProjectHostSetup[] + automationYamlHooksByRepoKey: Record + getAutomationHooksCacheKey: (repoId: string) => string repoMap: Map worktrees: Worktree[] settings: GlobalSettings | null @@ -36,6 +46,7 @@ type AutomationEditorDialogFooterProps = { onProjectChange: (projectId: string) => void getRepoHostLabel?: (repo: Repo) => string | null | undefined onDraftChange: (updater: (current: AutomationDraft) => AutomationDraft) => void + onSetupDecisionTouched: () => void onOpenChange: (open: boolean) => void onSave: () => void } @@ -48,6 +59,9 @@ export function AutomationEditorDialogFooter({ isSaving, canSave, repos, + projectHostSetups, + automationYamlHooksByRepoKey, + getAutomationHooksCacheKey, repoMap, worktrees, settings, @@ -59,6 +73,7 @@ export function AutomationEditorDialogFooter({ onProjectChange, getRepoHostLabel, onDraftChange, + onSetupDecisionTouched, onOpenChange, onSave }: AutomationEditorDialogFooterProps): React.JSX.Element { @@ -233,6 +248,18 @@ export function AutomationEditorDialogFooter({ onDraftChange={onDraftChange} /> + {/* Why: a full-width row below the columned controls — the setup + switch is a per-automation preference, not a peer of the compact + column pickers, so it reads cleaner spanning the dialog. */} +
diff --git a/src/renderer/src/components/automations/AutomationSetupDecisionField.tsx b/src/renderer/src/components/automations/AutomationSetupDecisionField.tsx new file mode 100644 index 00000000000..2731dba8164 --- /dev/null +++ b/src/renderer/src/components/automations/AutomationSetupDecisionField.tsx @@ -0,0 +1,117 @@ +import React from 'react' +import { Check, ChevronDown } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import { getVisibleAutomationSetupDecision } from './automation-setup-decision' +import type { AutomationCreateTarget, AutomationDraft } from './AutomationEditorDialog' +import type { OrcaHooks, ProjectHostSetup, Repo } from '../../../../shared/types' + +type AutomationSetupDecisionFieldProps = { + createTarget: AutomationCreateTarget + draft: AutomationDraft + repos: readonly Repo[] + projectHostSetups: readonly ProjectHostSetup[] + yamlHooks?: OrcaHooks | null + onDraftChange: (updater: (current: AutomationDraft) => AutomationDraft) => void + onSetupDecisionTouched: () => void +} + +export function AutomationSetupDecisionField({ + createTarget, + draft, + repos, + projectHostSetups, + yamlHooks, + onDraftChange, + onSetupDecisionTouched +}: AutomationSetupDecisionFieldProps): React.JSX.Element | null { + const [advancedOpen, setAdvancedOpen] = React.useState(false) + const defaultDecision = getVisibleAutomationSetupDecision({ + createTarget, + workspaceMode: draft.workspaceMode, + repoId: draft.projectId, + repos, + projectHostSetups, + yamlHooks + }) + if (!defaultDecision) { + return null + } + const checked = (draft.setupDecision ?? defaultDecision) === 'run' + const label = translate( + 'auto.components.automations.AutomationSetupDecisionField.5a7863909c', + 'Run setup for each new workspace' + ) + return ( + // Why: the setup choice is a power-user knob, so tuck it behind the same + // Advanced disclosure grammar the New Workspace composer uses. +
+ +
+
+
+ +

+ {translate( + 'auto.components.automations.AutomationSetupDecisionField.874b72195b', + "When this automation creates a workspace, prepare it the same way creating a worktree by hand does — run the project's setup and open its terminal tabs." + )} +

+
+
+
+
+ ) +} diff --git a/src/renderer/src/components/automations/AutomationsPage.tsx b/src/renderer/src/components/automations/AutomationsPage.tsx index eec62ea8413..322f2170f4d 100644 --- a/src/renderer/src/components/automations/AutomationsPage.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.tsx @@ -64,7 +64,7 @@ import { TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY } from '../../../../shared/proto import type { PreflightStatus } from '../../../../preload/api-types' import type { RuntimeStatus } from '../../../../shared/runtime-types' import type { TaskSourceContext } from '../../../../shared/task-source-context' -import type { Repo, Worktree } from '../../../../shared/types' +import type { OrcaHooks, Repo, Worktree } from '../../../../shared/types' import { getWorktreePathBasenameFromId } from '../../../../shared/worktree-id' import { buildAutomationCronSchedule, @@ -107,9 +107,17 @@ import { } from './AutomationEditorDialog' import { AutomationRunPageFrame } from './AutomationRunPageFrame' import { AutomationRunHistory } from './AutomationRunHistory' +import { + getAutomationSetupDecisionDraftValue, + getVisibleAutomationSetupDecision, + resolveAutomationSetupDecisionForSave +} from './automation-setup-decision' import { getAutomationTemplates, type AutomationTemplate } from './automation-templates' import { getAutomationTargetAvailability } from './automation-target-availability' import { buildAutomationRunContextForRepo } from './automation-run-context' +import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' +import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' +import { checkRuntimeHooks } from '@/runtime/runtime-hooks-client' import { getRepoBackedProviderAvailability, type RuntimeProviderPreflightStatus @@ -167,6 +175,16 @@ function getAutomationHostTargetKey(target: AutomationHostTarget): string { return target.kind === 'environment' ? `environment:${target.environmentId}` : 'local' } +function getAutomationHostTargetFromKey(key: string | null): AutomationHostTarget | null { + if (!key) { + return null + } + if (key.startsWith('environment:')) { + return { kind: 'environment', environmentId: key.slice('environment:'.length) } + } + return { kind: 'local' } +} + function getDefaultWorktree(worktrees: readonly Worktree[]): Worktree | null { return worktrees.find((worktree) => worktree.isMainWorktree) ?? worktrees[0] ?? null } @@ -446,6 +464,15 @@ export default function AutomationsPage(): React.JSX.Element { const completionInFlightRef = useRef>(new Set()) const rerunRunIdsInFlightRef = useRef>(new Set()) const workspaceNameCacheRef = useRef>(new Map()) + const setupDecisionPolicyDefaultRef = useRef(undefined) + const setupDecisionDefaultSignatureRef = useRef(null) + const setupDecisionTouchedRef = useRef(false) + const automationHookCheckPromisesRef = useRef< + Map> + >(new Map()) + const [automationYamlHooksByRepoKey, setAutomationYamlHooksByRepoKey] = useState< + Record + >({}) const [draft, setDraft] = useState({ name: '', prompt: '', @@ -454,6 +481,7 @@ export default function AutomationsPage(): React.JSX.Element { workspaceMode: 'existing', workspaceId: '', baseBranch: '', + setupDecision: undefined, reuseSession: false, precheckCommand: '', precheckTimeoutSeconds: '60', @@ -531,6 +559,76 @@ export default function AutomationsPage(): React.JSX.Element { }), [selectedAutomationRuns.runs, worktreeMap] ) + const getDraftSetupDecisionDefault = useCallback( + ( + candidate: Pick + ): AutomationDraft['setupDecision'] => { + const settingsForRepo = getSettingsForRepoRuntimeOwner( + { repos, settings }, + candidate.projectId + ) + const hookKey = `${settingsForRepo.activeRuntimeEnvironmentId ?? 'local'}:${candidate.projectId}` + return getVisibleAutomationSetupDecision({ + createTarget, + workspaceMode: candidate.workspaceMode, + repoId: candidate.projectId, + repos, + projectHostSetups, + yamlHooks: automationYamlHooksByRepoKey[hookKey] + }) + }, + [automationYamlHooksByRepoKey, createTarget, projectHostSetups, repos, settings] + ) + const getAutomationHooksCacheKey = useCallback( + (repoId: string): string => { + const settingsForRepo = getSettingsForRepoRuntimeOwner({ repos, settings }, repoId) + return `${settingsForRepo.activeRuntimeEnvironmentId ?? 'local'}:${repoId}` + }, + [repos, settings] + ) + const loadAutomationYamlHooksForRepo = useCallback( + async (repoId: string): Promise => { + const key = getAutomationHooksCacheKey(repoId) + if (Object.prototype.hasOwnProperty.call(automationYamlHooksByRepoKey, key)) { + return automationYamlHooksByRepoKey[key] ?? null + } + const existingPromise = automationHookCheckPromisesRef.current.get(key) + if (existingPromise) { + return (await existingPromise).hooks + } + const settingsForRepo = getSettingsForRepoRuntimeOwner({ repos, settings }, repoId) + const promise = checkRuntimeHooks(settingsForRepo, repoId) + .then((result) => ({ + hooks: result.status === 'error' ? null : ((result.hooks as OrcaHooks | null) ?? null), + ok: result.status !== 'error' + })) + .catch(() => ({ hooks: null, ok: false })) + automationHookCheckPromisesRef.current.set(key, promise) + const { hooks, ok } = await promise + automationHookCheckPromisesRef.current.delete(key) + if (!ok) { + return hooks + } + setAutomationYamlHooksByRepoKey((current) => + Object.prototype.hasOwnProperty.call(current, key) ? current : { ...current, [key]: hooks } + ) + return hooks + }, + [automationYamlHooksByRepoKey, getAutomationHooksCacheKey, repos, settings] + ) + const getDraftSetupDecisionDefaultSignature = useCallback( + (candidate: Pick): string => + [ + createTarget, + candidate.workspaceMode, + candidate.projectId, + getDraftSetupDecisionDefault(candidate) ?? 'none' + ].join(':'), + [createTarget, getDraftSetupDecisionDefault] + ) + const markSetupDecisionTouched = useCallback((): void => { + setupDecisionTouchedRef.current = true + }, []) // Why: keep the detail tab scoped even while the selected-run fetch catches up. const selectedRunsSource = selected && selectedAutomationRuns.automationId === selected.id @@ -547,6 +645,10 @@ export default function AutomationsPage(): React.JSX.Element { () => worktreesByRepo[draft.projectId] ?? [], [draft.projectId, worktreesByRepo] ) + const automationHostTarget = useMemo( + () => getAutomationHostTargetFromKey(automationHostTargetKey), + [automationHostTargetKey] + ) useEffect(() => { for (const [workspaceId, worktree] of worktreeMap) { @@ -801,6 +903,7 @@ export default function AutomationsPage(): React.JSX.Element { projectHostSetups, sshConnectionStates, runtimeStatusByEnvironmentId, + automationHostTarget, sourceHostAvailability: automationSourceHostAvailabilityById.get(selected.id) }) : null @@ -969,7 +1072,7 @@ export default function AutomationsPage(): React.JSX.Element { pendingAutomationRunNavigation.hostId ? getAutomationTargetFromHostId(pendingAutomationRunNavigation.hostId) : selected - ? getAutomationOwnerTarget(selected) + ? getAutomationOwnerTarget(selected, automationHostTarget) : getAutomationListTarget(settings) void listAutomationRunsForTarget(target, automationId).then((nextRuns) => { if (!cancelled) { @@ -979,7 +1082,7 @@ export default function AutomationsPage(): React.JSX.Element { return () => { cancelled = true } - }, [pendingAutomationRunNavigation, selected, selected?.id, runs, settings]) + }, [automationHostTarget, pendingAutomationRunNavigation, selected, selected?.id, runs, settings]) useEffect(() => { const onAutomationsChanged = (): void => { @@ -1087,6 +1190,50 @@ export default function AutomationsPage(): React.JSX.Element { } }, [draft.projectId, draft.workspaceId, worktreesByRepo]) + useEffect(() => { + if ( + !createOpen || + createTarget !== 'orca' || + draft.workspaceMode !== 'new_per_run' || + !draft.projectId + ) { + return + } + void loadAutomationYamlHooksForRepo(draft.projectId) + }, [ + createOpen, + createTarget, + draft.projectId, + draft.workspaceMode, + loadAutomationYamlHooksForRepo + ]) + + useEffect(() => { + if (!createOpen) { + setupDecisionPolicyDefaultRef.current = undefined + setupDecisionDefaultSignatureRef.current = null + setupDecisionTouchedRef.current = false + return + } + const nextDefault = getDraftSetupDecisionDefault(draft) + const nextSignature = getDraftSetupDecisionDefaultSignature(draft) + if (setupDecisionDefaultSignatureRef.current !== nextSignature) { + setupDecisionDefaultSignatureRef.current = nextSignature + setupDecisionTouchedRef.current = false + } + const previousDefault = setupDecisionPolicyDefaultRef.current + setupDecisionPolicyDefaultRef.current = nextDefault + const shouldApplyPolicyDefault = + !setupDecisionTouchedRef.current && + (nextDefault === undefined || + draft.setupDecision === undefined || + draft.setupDecision === previousDefault) + if (!shouldApplyPolicyDefault || draft.setupDecision === nextDefault) { + return + } + setDraft((current) => ({ ...current, setupDecision: nextDefault })) + }, [createOpen, draft, getDraftSetupDecisionDefault, getDraftSetupDecisionDefaultSignature]) + const applyTemplateToDraft = useCallback((template: AutomationTemplate): void => { setDraft((current) => ({ ...current, @@ -1109,6 +1256,7 @@ export default function AutomationsPage(): React.JSX.Element { ...current, agentId: 'hermes', workspaceMode: 'existing', + setupDecision: undefined, reuseSession: false })) } @@ -1128,6 +1276,7 @@ export default function AutomationsPage(): React.JSX.Element { workspaceMode: 'existing', workspaceId: target.workspaceId, baseBranch: '', + setupDecision: undefined, reuseSession: false, precheckCommand: '', precheckTimeoutSeconds: '60', @@ -1182,6 +1331,10 @@ export default function AutomationsPage(): React.JSX.Element { workspaceMode: latest.workspaceMode, workspaceId: latest.workspaceId ?? '', baseBranch: latest.baseBranch ?? '', + setupDecision: getAutomationSetupDecisionDraftValue({ + workspaceMode: latest.workspaceMode, + persistedSetupDecision: latest.setupDecision + }), reuseSession: latest.workspaceMode === 'existing' && latest.reuseSession, precheckCommand: latest.precheck?.command ?? '', precheckTimeoutSeconds: String(latest.precheck?.timeoutSeconds ?? 60), @@ -1229,6 +1382,7 @@ export default function AutomationsPage(): React.JSX.Element { workspaceMode: 'existing', workspaceId, baseBranch: '', + setupDecision: undefined, reuseSession: false, precheckCommand: '', precheckTimeoutSeconds: '60', @@ -1435,6 +1589,28 @@ export default function AutomationsPage(): React.JSX.Element { repos, projectHostSetups }) + let setupDecision = resolveAutomationSetupDecisionForSave({ + createTarget, + workspaceMode: draft.workspaceMode, + repoId: draft.projectId, + repos, + projectHostSetups, + yamlHooks: + createTarget === 'orca' && draft.workspaceMode === 'new_per_run' + ? await loadAutomationYamlHooksForRepo(draft.projectId) + : null, + draftSetupDecision: draft.setupDecision + }) + if (setupDecision === 'run') { + const trustDecision = await ensureHooksConfirmed( + useAppStore.getState(), + draft.projectId, + 'setup' + ) + if (trustDecision === 'skip') { + setupDecision = 'skip' + } + } if (!runContext) { toast.error( translate( @@ -1467,6 +1643,7 @@ export default function AutomationsPage(): React.JSX.Element { workspaceMode: draft.workspaceMode, workspaceId: draft.workspaceId, baseBranch: draft.baseBranch.trim() || null, + setupDecision, reuseSession: draft.workspaceMode === 'existing' && draft.reuseSession, timezone, missedRunGraceMinutes @@ -1478,7 +1655,7 @@ export default function AutomationsPage(): React.JSX.Element { } const automation = editingAutomationId ? currentAutomation - ? await updateAutomationForTarget(currentAutomation, updates) + ? await updateAutomationForTarget(currentAutomation, updates, automationHostTarget) : await window.api.automations.update({ id: editingAutomationId, updates @@ -1493,6 +1670,7 @@ export default function AutomationsPage(): React.JSX.Element { workspaceMode: draft.workspaceMode, workspaceId: draft.workspaceId, baseBranch: draft.baseBranch.trim() || null, + setupDecision, reuseSession: draft.workspaceMode === 'existing' && draft.reuseSession, timezone, rrule, @@ -1539,12 +1717,16 @@ export default function AutomationsPage(): React.JSX.Element { } const toggleAutomation = async (automation: Automation): Promise => { - await updateAutomationForTarget(automation, { enabled: !automation.enabled }) + await updateAutomationForTarget( + automation, + { enabled: !automation.enabled }, + automationHostTarget + ) await refresh() } const deleteAutomation = async (automation: Automation): Promise => { - await deleteAutomationForTarget(automation) + await deleteAutomationForTarget(automation, automationHostTarget) if (useAppStore.getState().selectedAutomationId === automation.id) { selectAutomationId(null) } @@ -1616,13 +1798,14 @@ export default function AutomationsPage(): React.JSX.Element { projectHostSetups, sshConnectionStates, runtimeStatusByEnvironmentId, + automationHostTarget, sourceHostAvailability: automationSourceHostAvailabilityById.get(automation.id) }) if (!availability.canRunNow) { toast.error(availability.message) return } - await runAutomationNowForTarget(automation) + await runAutomationNowForTarget(automation, automationHostTarget) useAppStore.getState().recordFeatureInteraction('automation-run') await hydratePersistedUIState() await refresh() @@ -1640,7 +1823,7 @@ export default function AutomationsPage(): React.JSX.Element { rerunRunIdsInFlightRef.current.add(runId) setRerunRunIdsInFlight(new Set(rerunRunIdsInFlightRef.current)) try { - await runAutomationNowForTarget(automation) + await runAutomationNowForTarget(automation, automationHostTarget) await hydratePersistedUIState() await refresh() toast.message( @@ -2015,6 +2198,9 @@ export default function AutomationsPage(): React.JSX.Element { isEditingExternal={editingExternalTarget !== null} createTarget={createTarget} repos={repos} + projectHostSetups={projectHostSetups} + automationYamlHooksByRepoKey={automationYamlHooksByRepoKey} + getAutomationHooksCacheKey={getAutomationHooksCacheKey} repoMap={repoMap} worktrees={worktrees} settings={settings} @@ -2024,6 +2210,7 @@ export default function AutomationsPage(): React.JSX.Element { onCreateTargetChange={handleCreateTargetChange} onOpenChange={setCreateOpen} onDraftChange={setDraft} + onSetupDecisionTouched={markSetupDecisionTouched} onApplyTemplate={applyTemplateToDraft} onSave={() => void saveAutomation()} /> @@ -2217,6 +2404,7 @@ export default function AutomationsPage(): React.JSX.Element { projectHostSetups, sshConnectionStates, runtimeStatusByEnvironmentId, + automationHostTarget, sourceHostAvailability: automationSourceHostAvailabilityById.get(automation.id) }) const workspaceLabel = diff --git a/src/renderer/src/components/automations/automation-host-client.test.ts b/src/renderer/src/components/automations/automation-host-client.test.ts index e2073cd04d4..e9a5bc03bc2 100644 --- a/src/renderer/src/components/automations/automation-host-client.test.ts +++ b/src/renderer/src/components/automations/automation-host-client.test.ts @@ -4,7 +4,8 @@ import { createAutomationForTarget, getAutomationListTarget, listAutomationsForTarget, - runAutomationNowForTarget + runAutomationNowForTarget, + updateAutomationForTarget } from './automation-host-client' import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' @@ -94,6 +95,7 @@ describe('automation host client', () => { projectId: automation.projectId, workspaceMode: automation.workspaceMode, workspaceId: null, + setupDecision: 'run', timezone: automation.timezone, rrule: automation.rrule, dtstart: automation.dtstart @@ -114,6 +116,7 @@ describe('automation host client', () => { expect.objectContaining({ repo: 'repo-1', workspace: undefined, + setupDecision: 'run', runContext: automation.runContext }), { timeoutMs: 15_000 } @@ -126,4 +129,41 @@ describe('automation host client', () => { { timeoutMs: 15_000 } ) }) + + it('updates and manually runs SSH-host automations through the remote server that listed them', async () => { + const automation = makeAutomation({ + runContext: { + kind: 'workspace-run', + projectId: 'github:stablyai/orca', + hostId: 'ssh:devbox', + projectHostSetupId: 'setup-devbox', + repoId: 'repo-1', + path: '/srv/orca' + } + }) + const sourceTarget = { kind: 'environment' as const, environmentId: 'gpu' } + vi.mocked(callRuntimeRpc) + .mockResolvedValueOnce({ automation: { ...automation, name: 'Updated' } }) + .mockResolvedValueOnce({ run: { id: 'run-1', automationId: automation.id } }) + + await updateAutomationForTarget(automation, { name: 'Updated' }, sourceTarget) + await runAutomationNowForTarget(automation, sourceTarget) + + expect(mockApi.automations.update).not.toHaveBeenCalled() + expect(mockApi.automations.runNow).not.toHaveBeenCalled() + expect(callRuntimeRpc).toHaveBeenNthCalledWith( + 1, + sourceTarget, + 'automation.update', + { id: automation.id, updates: { name: 'Updated' } }, + { timeoutMs: 15_000 } + ) + expect(callRuntimeRpc).toHaveBeenNthCalledWith( + 2, + sourceTarget, + 'automation.runNow', + { id: automation.id }, + { timeoutMs: 15_000 } + ) + }) }) diff --git a/src/renderer/src/components/automations/automation-host-client.ts b/src/renderer/src/components/automations/automation-host-client.ts index a657c88ad8f..b3569b4b7b2 100644 --- a/src/renderer/src/components/automations/automation-host-client.ts +++ b/src/renderer/src/components/automations/automation-host-client.ts @@ -43,8 +43,12 @@ export function getAutomationListTarget( } export function getAutomationOwnerTarget( - automation: Pick + automation: Pick, + sourceTarget?: AutomationHostTarget | null ): AutomationHostTarget { + if (sourceTarget?.kind === 'environment') { + return sourceTarget + } return getAutomationTargetFromHostId(automation.runContext?.hostId) } @@ -121,9 +125,10 @@ export async function createAutomationForTarget(input: AutomationCreateInput): P export async function updateAutomationForTarget( automation: Automation, - updates: AutomationUpdateInput + updates: AutomationUpdateInput, + sourceTarget?: AutomationHostTarget | null ): Promise { - const target = getAutomationOwnerTarget(automation) + const target = getAutomationOwnerTarget(automation, sourceTarget) if (target.kind === 'local') { return await window.api.automations.update({ id: automation.id, updates }) } @@ -136,8 +141,11 @@ export async function updateAutomationForTarget( return result.automation } -export async function deleteAutomationForTarget(automation: Automation): Promise { - const target = getAutomationOwnerTarget(automation) +export async function deleteAutomationForTarget( + automation: Automation, + sourceTarget?: AutomationHostTarget | null +): Promise { + const target = getAutomationOwnerTarget(automation, sourceTarget) if (target.kind === 'local') { await window.api.automations.delete({ id: automation.id }) return @@ -145,8 +153,11 @@ export async function deleteAutomationForTarget(automation: Automation): Promise await callRuntimeRpc(target, 'automation.delete', { id: automation.id }, { timeoutMs: 15_000 }) } -export async function runAutomationNowForTarget(automation: Automation): Promise { - const target = getAutomationOwnerTarget(automation) +export async function runAutomationNowForTarget( + automation: Automation, + sourceTarget?: AutomationHostTarget | null +): Promise { + const target = getAutomationOwnerTarget(automation, sourceTarget) if (target.kind === 'local') { return await window.api.automations.runNow({ id: automation.id }) } diff --git a/src/renderer/src/components/automations/automation-setup-decision.test.ts b/src/renderer/src/components/automations/automation-setup-decision.test.ts new file mode 100644 index 00000000000..b46d76da980 --- /dev/null +++ b/src/renderer/src/components/automations/automation-setup-decision.test.ts @@ -0,0 +1,278 @@ +import { describe, expect, it } from 'vitest' +import type { ProjectHostSetup, Repo } from '../../../../shared/types' +import { + getAutomationSetupDecisionDraftValue, + getVisibleAutomationSetupDecision, + resolveAutomationSetupDecisionForSave +} from './automation-setup-decision' + +const repo: Repo = { + id: 'repo-1', + path: '/repo', + displayName: 'orca', + badgeColor: 'blue', + addedAt: 1, + kind: 'git', + hookSettings: { + mode: 'override', + setupRunPolicy: 'run-by-default', + scripts: { setup: 'pnpm install', archive: '' } + } +} + +function setup(overrides: Partial = {}): ProjectHostSetup { + return { + id: 'setup-1', + projectId: 'project-1', + hostId: 'local', + repoId: 'repo-1', + path: '/repo', + displayName: 'orca', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +describe('automation setup decision defaults', () => { + it.each([ + ['run-by-default', 'run'], + ['skip-by-default', 'skip'], + ['ask', 'skip'] + ] as const)('maps %s setup policy to %s', (setupRunPolicy, expectedDecision) => { + expect( + getVisibleAutomationSetupDecision({ + createTarget: 'orca', + workspaceMode: 'new_per_run', + repoId: 'repo-1', + repos: [ + { + ...repo, + hookSettings: { + mode: 'override', + setupRunPolicy, + scripts: { setup: 'pnpm install', archive: '' } + } + } + ], + projectHostSetups: [], + yamlHooks: null + }) + ).toBe(expectedDecision) + }) + + it('hides setup choice outside Orca new-run automations with setup', () => { + const baseArgs = { + repoId: 'repo-1', + repos: [repo], + projectHostSetups: [], + yamlHooks: null + } + + expect( + getVisibleAutomationSetupDecision({ + ...baseArgs, + createTarget: 'orca', + workspaceMode: 'existing' + }) + ).toBeUndefined() + expect( + getVisibleAutomationSetupDecision({ + ...baseArgs, + createTarget: 'hermes', + workspaceMode: 'new_per_run' + }) + ).toBeUndefined() + expect( + getVisibleAutomationSetupDecision({ + ...baseArgs, + createTarget: 'orca', + workspaceMode: 'new_per_run', + repos: [ + { + ...repo, + hookSettings: { mode: 'override', scripts: { setup: '', archive: '' } } + } + ] + }) + ).toBeUndefined() + }) + + it('uses ready project-host setup hook settings before repo fallback', () => { + expect( + getVisibleAutomationSetupDecision({ + createTarget: 'orca', + workspaceMode: 'new_per_run', + repoId: 'repo-1', + repos: [repo], + projectHostSetups: [ + setup({ + hookSettings: { + mode: 'override', + setupRunPolicy: 'skip-by-default', + scripts: { setup: 'bun install', archive: '' } + } + }) + ], + yamlHooks: null + }) + ).toBe('skip') + }) + + it('shows setup choice for shared orca.yaml setup and default tabs', () => { + expect( + getVisibleAutomationSetupDecision({ + createTarget: 'orca', + workspaceMode: 'new_per_run', + repoId: 'repo-1', + repos: [ + { + ...repo, + hookSettings: { + mode: 'override', + setupRunPolicy: 'run-by-default', + scripts: { setup: '', archive: '' } + } + } + ], + projectHostSetups: [], + yamlHooks: { + scripts: { setup: 'pnpm install' }, + defaultTabs: [] + } + }) + ).toBe('run') + + expect( + getVisibleAutomationSetupDecision({ + createTarget: 'orca', + workspaceMode: 'new_per_run', + repoId: 'repo-1', + repos: [ + { + ...repo, + hookSettings: { + mode: 'override', + setupRunPolicy: 'skip-by-default', + scripts: { setup: '', archive: '' } + } + } + ], + projectHostSetups: [], + yamlHooks: { + scripts: {}, + defaultTabs: [{ title: 'Dev', command: 'pnpm dev' }] + } + }) + ).toBe('skip') + }) + + it('omits setup decision when no setup source is visible', () => { + expect( + resolveAutomationSetupDecisionForSave({ + createTarget: 'orca', + workspaceMode: 'new_per_run', + repoId: 'repo-1', + repos: [ + { + ...repo, + hookSettings: { + mode: 'override', + setupRunPolicy: 'ask', + scripts: { setup: '', archive: '' } + } + } + ], + projectHostSetups: [], + yamlHooks: null, + draftSetupDecision: undefined + }) + ).toBeUndefined() + expect( + resolveAutomationSetupDecisionForSave({ + createTarget: 'orca', + workspaceMode: 'new_per_run', + repoId: 'repo-1', + repos: [ + { + ...repo, + hookSettings: { + mode: 'override', + setupRunPolicy: 'run-by-default', + scripts: { setup: '', archive: '' } + } + } + ], + projectHostSetups: [], + yamlHooks: null, + draftSetupDecision: undefined + }) + ).toBeUndefined() + + expect( + resolveAutomationSetupDecisionForSave({ + createTarget: 'orca', + workspaceMode: 'new_per_run', + repoId: 'repo-1', + repos: [ + { + ...repo, + hookSettings: { + mode: 'override', + setupRunPolicy: 'skip-by-default', + scripts: { setup: '', archive: '' } + } + } + ], + projectHostSetups: [], + yamlHooks: null, + draftSetupDecision: undefined + }) + ).toBeUndefined() + }) + + it('fails closed when saving before shared hook inspection is available', () => { + expect( + resolveAutomationSetupDecisionForSave({ + createTarget: 'orca', + workspaceMode: 'new_per_run', + repoId: 'repo-1', + repos: [ + { + ...repo, + hookSettings: { + mode: 'override', + setupRunPolicy: 'run-by-default', + scripts: { setup: '', archive: '' } + } + } + ], + projectHostSetups: [], + draftSetupDecision: undefined + }) + ).toBe('skip') + }) + + it('loads legacy new-run automations as skip when editing', () => { + expect( + getAutomationSetupDecisionDraftValue({ + workspaceMode: 'new_per_run', + persistedSetupDecision: undefined + }) + ).toBe('skip') + expect( + getAutomationSetupDecisionDraftValue({ + workspaceMode: 'new_per_run', + persistedSetupDecision: 'run' + }) + ).toBe('run') + expect( + getAutomationSetupDecisionDraftValue({ + workspaceMode: 'existing', + persistedSetupDecision: undefined + }) + ).toBeUndefined() + }) +}) diff --git a/src/renderer/src/components/automations/automation-setup-decision.ts b/src/renderer/src/components/automations/automation-setup-decision.ts new file mode 100644 index 00000000000..f565e439f34 --- /dev/null +++ b/src/renderer/src/components/automations/automation-setup-decision.ts @@ -0,0 +1,102 @@ +import type { AutomationWorkspaceMode } from '../../../../shared/automations-types' +import type { + OrcaHooks, + ProjectHostSetup, + Repo, + SetupDecision, + SetupRunPolicy +} from '../../../../shared/types' +import { getSetupConfig } from '@/lib/new-workspace' + +type AutomationSetupSource = { + setupScript: string + setupRunPolicy: SetupRunPolicy +} + +function getAutomationSetupSource( + repoId: string, + repos: readonly Repo[], + projectHostSetups: readonly ProjectHostSetup[], + yamlHooks: OrcaHooks | null | undefined +): AutomationSetupSource | null { + const setup = projectHostSetups.find( + (candidate) => candidate.repoId === repoId && candidate.setupState === 'ready' + ) + const repo = repos.find((candidate) => candidate.id === repoId) + const hookSettings = setup?.hookSettings ?? repo?.hookSettings + const setupConfig = getSetupConfig( + hookSettings ? { hookSettings } : repo, + yamlHooks === undefined ? null : yamlHooks + ) + if (!setupConfig) { + return null + } + return { + setupScript: setupConfig.command, + setupRunPolicy: hookSettings?.setupRunPolicy ?? 'run-by-default' + } +} + +export function getAutomationSetupDefaultDecision( + source: Pick | null +): Extract | undefined { + if (!source) { + return undefined + } + return source.setupRunPolicy === 'run-by-default' ? 'run' : 'skip' +} + +export function getVisibleAutomationSetupDecision(args: { + createTarget: 'orca' | 'hermes' + workspaceMode: AutomationWorkspaceMode + repoId: string + repos: readonly Repo[] + projectHostSetups: readonly ProjectHostSetup[] + yamlHooks?: OrcaHooks | null +}): Extract | undefined { + if (args.createTarget !== 'orca' || args.workspaceMode !== 'new_per_run') { + return undefined + } + return getAutomationSetupDefaultDecision( + getAutomationSetupSource(args.repoId, args.repos, args.projectHostSetups, args.yamlHooks) + ) +} + +export function resolveAutomationSetupDecisionForSave(args: { + createTarget: 'orca' | 'hermes' + workspaceMode: AutomationWorkspaceMode + repoId: string + repos: readonly Repo[] + projectHostSetups: readonly ProjectHostSetup[] + yamlHooks?: OrcaHooks | null + draftSetupDecision: Extract | undefined +}): Extract | undefined { + if (args.createTarget !== 'orca' || args.workspaceMode !== 'new_per_run') { + return undefined + } + + const visibleDefault = getVisibleAutomationSetupDecision(args) + if (visibleDefault) { + return args.draftSetupDecision ?? visibleDefault + } + + if (args.yamlHooks === undefined) { + // Why: automations cannot pause later for an orca.yaml trust prompt; when + // hook inspection is unavailable, fail closed instead of inheriting setup. + return 'skip' + } + + return undefined +} + +export function getAutomationSetupDecisionDraftValue(args: { + workspaceMode: AutomationWorkspaceMode + persistedSetupDecision: SetupDecision | undefined +}): Extract | undefined { + if (args.persistedSetupDecision === 'run' || args.persistedSetupDecision === 'skip') { + return args.persistedSetupDecision + } + // Why: legacy new-run automations had no saved choice and now dispatch as + // skip for compatibility; editing them must not silently opt into setup. + return args.workspaceMode === 'new_per_run' ? 'skip' : undefined +} diff --git a/src/renderer/src/components/automations/automation-target-availability.test.ts b/src/renderer/src/components/automations/automation-target-availability.test.ts index a640de7d7b5..cbc9b029c92 100644 --- a/src/renderer/src/components/automations/automation-target-availability.test.ts +++ b/src/renderer/src/components/automations/automation-target-availability.test.ts @@ -140,6 +140,66 @@ describe('automation target availability', () => { ).toBe('host-mismatch') }) + it('allows remote-listed SSH automations whose repo is projected through a runtime server', () => { + expect( + getAutomationTargetAvailability({ + automation: makeAutomation({ + executionTargetType: 'ssh', + executionTargetId: 'devbox', + runContext: { + kind: 'workspace-run', + projectId: 'project-1', + hostId: 'ssh:devbox', + projectHostSetupId: 'setup-1', + repoId: 'repo-1', + path: '/repo' + } + }), + repo: makeRepo({ + connectionId: 'devbox', + executionHostId: 'runtime:gpu' + }), + workspace: makeWorkspace(), + projectHostSetups: [ + makeProjectHostSetup({ + hostId: 'ssh:devbox', + connectionId: 'devbox', + executionHostId: 'ssh:devbox' + }) + ], + sshConnectionStates: new Map([['devbox', { status: 'connected' }]]), + automationHostTarget: { kind: 'environment', environmentId: 'gpu' } + }) + ).toEqual({ canRunNow: true, reason: 'available', message: null }) + }) + + it('allows remote-listed server-local automations whose setup is projected through a runtime server', () => { + expect( + getAutomationTargetAvailability({ + automation: makeAutomation({ + runContext: { + kind: 'workspace-run', + projectId: 'project-1', + hostId: 'local', + projectHostSetupId: 'setup-1', + repoId: 'repo-1', + path: '/repo' + } + }), + repo: makeRepo({ executionHostId: 'runtime:gpu' }), + workspace: makeWorkspace(), + projectHostSetups: [ + makeProjectHostSetup({ + hostId: 'runtime:gpu', + executionHostId: 'runtime:gpu' + }) + ], + sshConnectionStates: new Map(), + automationHostTarget: { kind: 'environment', environmentId: 'gpu' } + }) + ).toEqual({ canRunNow: true, reason: 'available', message: null }) + }) + it('blocks saved run contexts whose project host setup is missing or not ready', () => { const automation = makeAutomation({ runContext: { diff --git a/src/renderer/src/components/automations/automation-target-availability.ts b/src/renderer/src/components/automations/automation-target-availability.ts index f2e994758db..008b4c9eb48 100644 --- a/src/renderer/src/components/automations/automation-target-availability.ts +++ b/src/renderer/src/components/automations/automation-target-availability.ts @@ -8,6 +8,7 @@ import { MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, RUNTIME_PROTOCOL_VERSION } from '../../../../shared/protocol-version' +import type { AutomationHostTarget } from './automation-host-client' import type { SshConnectionState } from '../../../../shared/ssh-types' import type { TaskSourceContext } from '../../../../shared/task-source-context' import type { RuntimeStatus } from '../../../../shared/runtime-types' @@ -52,6 +53,7 @@ type AutomationTargetAvailabilityArgs = { string, { status: RuntimeStatus | null; checkedAt: number } > + automationHostTarget?: AutomationHostTarget | null sourceHostAvailability?: readonly TaskSourceHostAvailability[] } @@ -62,6 +64,7 @@ export function getAutomationTargetAvailability({ projectHostSetups, sshConnectionStates, runtimeStatusByEnvironmentId, + automationHostTarget, sourceHostAvailability }: AutomationTargetAvailabilityArgs): AutomationTargetAvailability { if (!repo) { @@ -93,15 +96,16 @@ export function getAutomationTargetAvailability({ `Project setup on the selected automation host is ${setup.setupState}.` ) } - if ( - setup.projectId !== automation.runContext.projectId || - setup.hostId !== automation.runContext.hostId || - setup.repoId !== automation.runContext.repoId || - setup.path !== automation.runContext.path || - automation.runContext.repoId !== repo.id || - automation.runContext.path !== repo.path || - automation.runContext.hostId !== getRepoExecutionHostId(repo) - ) { + const setupMatchesContext = + setup.projectId === automation.runContext.projectId && + setup.repoId === automation.runContext.repoId && + setup.path === automation.runContext.path && + setupHostMatchesRunContext(setup.hostId, automation.runContext.hostId, automationHostTarget) + const repoMatchesContext = + automation.runContext.repoId === repo.id && + automation.runContext.path === repo.path && + repoHostMatchesRunContext(repo, automation.runContext.hostId, automationHostTarget) + if (!setupMatchesContext || !repoMatchesContext) { return unavailable( 'host-mismatch', 'The saved run host no longer matches this project setup.' @@ -142,6 +146,40 @@ export function getAutomationTargetAvailability({ } } +function getRuntimeTargetHostId(target: AutomationHostTarget | null | undefined): string | null { + return target?.kind === 'environment' + ? `runtime:${encodeURIComponent(target.environmentId)}` + : null +} + +function setupHostMatchesRunContext( + setupHostId: string, + runHostId: string, + target: AutomationHostTarget | null | undefined +): boolean { + if (setupHostId === runHostId) { + return true + } + const targetHostId = getRuntimeTargetHostId(target) + // Why: remote-runtime project lists project the server-local host as runtime:, + // while CLI-created automations can preserve the server's durable local run host. + return targetHostId !== null && setupHostId === targetHostId && runHostId === 'local' +} + +function repoHostMatchesRunContext( + repo: Repo, + runHostId: string, + target: AutomationHostTarget | null | undefined +): boolean { + if (runHostId === getRepoExecutionHostId(repo)) { + return true + } + const targetHostId = getRuntimeTargetHostId(target) + // Why: repos fetched from a remote runtime are owned by runtime: in the + // renderer, but saved automations still target the host setup that runs there. + return targetHostId !== null && getRepoExecutionHostId(repo) === targetHostId +} + function getAutomationSourceAvailability( sourceContext: TaskSourceContext | null | undefined, sourceHostAvailability: readonly TaskSourceHostAvailability[] | undefined 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/mobile/mobile-platform-copy.ts b/src/renderer/src/components/mobile/mobile-platform-copy.ts index a959cda5ce1..7070b9b5b97 100644 --- a/src/renderer/src/components/mobile/mobile-platform-copy.ts +++ b/src/renderer/src/components/mobile/mobile-platform-copy.ts @@ -20,7 +20,7 @@ const IOS_CHANNEL_COPY: Record = { const ANDROID_COPY: InstallCopy = { ctaLabel: 'Download APK', - url: 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk' + url: 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.17/app-release.apk' } export function getInstallCopy(platform: Platform, iosChannel: IosChannel): InstallCopy { 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} +