Merge branch 'main' of https://github.com/stablyai/orca into investigate-scrollback

This commit is contained in:
Jinjing
2026-06-28 15:46:59 -07:00
259 changed files with 20402 additions and 784 deletions
+2 -2
View File
@@ -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)
</td>
<td width="50%">
@@ -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)
---
+217
View File
@@ -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 <rest>`.
- 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.
+2 -2
View File
@@ -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)
</td>
<td width="50%">
@@ -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)
---
+2 -2
View File
@@ -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)
</td>
<td width="50%">
@@ -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)
---
+2 -2
View File
@@ -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)
</td>
<td width="50%">
@@ -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)
---
+2 -2
View File
@@ -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)
</td>
<td width="50%">
@@ -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)
---
+1 -1
View File
@@ -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",
+1
View File
@@ -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
)
@@ -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')
})
})
@@ -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,
+41 -1
View File
@@ -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<string, string> | 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)
+259
View File
@@ -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<string, (_event: unknown, args?: unknown) => unknown>(),
listeners: new Map<string, (_event: unknown, args?: unknown) => 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<void> {
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<unknown> {
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
}
}
})
})
+168
View File
@@ -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<ReadTranscriptResult> {
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<number, Map<string, LiveSubscription>>()
const senderCleanupRegistered = new Set<number>()
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<void> {
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<string, LiveSubscription>()
// 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)
})
}
+93
View File
@@ -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<T>(command: string, commandPromise: Promise<T>): Promise<T> {
let timeout: ReturnType<typeof setTimeout> | null = null
try {
return await Promise.race([
commandPromise,
new Promise<never>((_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<PreflightCommandResult> {
const env = buildLocalPreflightEnv()
const commandPromise = execFileAsync(command, args, {
encoding: 'utf-8',
timeout: PREFLIGHT_COMMAND_TIMEOUT_MS,
...(env ? { env } : {})
}) as Promise<PreflightCommandResult>
return withPreflightTimeout(command, commandPromise)
}
export async function execCommandInWsl(
target: WslPreflightTarget,
command: string
): Promise<PreflightCommandResult> {
const commandPromise = runPreflightCommandInWsl(target, command, PREFLIGHT_COMMAND_TIMEOUT_MS)
return withPreflightTimeout('wsl.exe', commandPromise)
}
export async function isCommandAvailable(
command: string,
wslTarget?: WslPreflightTarget
): Promise<boolean> {
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<boolean> {
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
}
}
@@ -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<RemoteWindowsTerminalCapabilities> {
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
}
+29
View File
@@ -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,
+21 -93
View File
@@ -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<T>(command: string, commandPromise: Promise<T>): Promise<T> {
let timeout: ReturnType<typeof setTimeout> | null = null
try {
return await Promise.race([
commandPromise,
new Promise<never>((_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<PreflightCommandResult> {
const env = buildLocalPreflightEnv()
const commandPromise = execFileAsync(command, args, {
encoding: 'utf-8',
timeout: PREFLIGHT_COMMAND_TIMEOUT_MS,
...(env ? { env } : {})
}) as Promise<PreflightCommandResult>
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<boolean> {
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<boolean> {
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<RemoteWindowsTerminalCapabilities> => {
return detectRemoteWindowsTerminalCapabilities(args)
}
)
}
@@ -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()
+2
View File
@@ -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)
+8
View File
@@ -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()
+2 -1
View File
@@ -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() })
}
)
}
+8 -4
View File
@@ -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', () => {
+9 -1
View File
@@ -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' }
]
}
@@ -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<string> {
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 <sessionId>.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 <userData>/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)
})
})
@@ -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
// `<managed home>/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/<slug>/<id>.jsonl`), so we glob the projects subdirs for
* `<id>.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<string | null> {
// 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<string | null> {
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<string | null> {
// Codex rollout file names embed the session id (rollout-<ts>-<id>.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
}
@@ -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<string, unknown>,
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<string, unknown>,
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<string, unknown>): 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
}
@@ -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<typeof TranscriptReader>()
return {
...actual,
readNativeChatTranscript: (...args: Parameters<typeof actual.readNativeChatTranscript>) => {
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<string> {
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()
})
})
@@ -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<string, CachedTranscript>()
// 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<number> {
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<ReadTranscriptResult> {
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()
}
@@ -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<string> {
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)
})
})
+66
View File
@@ -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<ReadTranscriptResult> {
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<NativeChatMessage[]> {
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
}
@@ -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<string, unknown>): 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<string, unknown>): NativeChatToolResultBlock {
return {
type: 'tool-result',
output: toolResultOutput(record.content),
...(record.is_error === true ? { isError: true } : {})
}
}
function imageRefBlock(record: Record<string, unknown>): 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 } : {})
}
}
@@ -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<string> {
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<void> {
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()
})
})
+254
View File
@@ -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<number> {
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<string> {
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<NativeChatTranscriptSubscription> {
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<typeof setTimeout> | null = null
async function drain(): Promise<void> {
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--
}
}
}
+127
View File
@@ -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()
})
})
+24
View File
@@ -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)
@@ -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' })
+4
View File
@@ -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 }
@@ -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,
+161 -10
View File
@@ -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<string, string> }).env ?? {}
const setupEnv = (spawn.mock.calls[1]![0] as { env?: Record<string, string> }).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<string, string> }).env ?? {}
const setupEnv = (spawn.mock.calls[1]![0] as { env?: Record<string, string> }).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<string, WorktreeMeta> = {}
const runtimeStore = {
...store,
getAllWorktreeMeta: () => metaById,
getWorktreeMeta: (worktreeId: string) => metaById[worktreeId],
setWorktreeMeta: (worktreeId: string, meta: Partial<WorktreeMeta>) => {
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<string, WorktreeMeta> = {}
@@ -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({
+306 -154
View File
@@ -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<string[]> {
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<GlobalSettings, 'setupScriptLaunchMode'>
>
).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<Pick<GlobalSettings, 'setupScriptLaunchMode'>>)
.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<Pick<GlobalSettings, 'setupScriptLaunchMode'>>)
.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<Pick<GlobalSettings, 'setupScriptLaunchMode'>>)
.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<Pick<GlobalSettings, 'setupScriptLaunchMode'>>)
.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<string, AgentStatusOrchestrationContext>
| 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) {
+5
View File
@@ -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.
+3
View File
@@ -93,6 +93,7 @@ export class RpcDispatcher {
connectionId?: string
signal?: AbortSignal
clientId?: string
clientKind?: 'mobile' | 'runtime'
sendBinary?: (bytes: Uint8Array<ArrayBufferLike>) => 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
},
@@ -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')
@@ -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(),
+2
View File
@@ -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,
@@ -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<unknown> {
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<unknown>
}
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')
})
})
+180
View File
@@ -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 }
}
})
]
@@ -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'
}
})
})
})
@@ -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,
@@ -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({
+2 -1
View File
@@ -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({
+12 -2
View File
@@ -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() })
}
})
]
+6
View File
@@ -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) =>
+27
View File
@@ -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')
+20
View File
@@ -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',
+49
View File
@@ -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<string[]>
refreshAgents: (args?: PreflightRuntimeContext) => Promise<RefreshAgentsResult>
detectRemoteAgents: (args: { connectionId: string }) => Promise<string[]>
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<AiVaultListResult>
}
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<NativeChatReadSessionResult>
/** 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<AppIdentity>
@@ -2007,6 +2055,7 @@ export type PreloadApi = {
codexUsage: CodexUsageApi
openCodeUsage: OpenCodeUsageApi
aiVault: AiVaultApi
nativeChat: NativeChatApi
fs: {
readDir: (args: { dirPath: string; connectionId?: string }) => Promise<DirEntry[]>
readFile: (args: {
+50 -1
View File
@@ -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<RefreshAgentsResult> =>
ipcRenderer.invoke('preflight:refreshAgents', args),
detectRemoteAgents: (args: { connectionId: string }): Promise<string[]> =>
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<NativeChatReadSessionResult> =>
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<RuntimeSyncWindowGraphResult> =>
ipcRenderer.invoke('runtime:syncWindowGraph', graph),
+63 -1
View File
@@ -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<string, (params: Record<string, unknown>) => Promise<unknown>>()
const dispatcher = {
onRequest: vi.fn(
(method: string, handler: (params: Record<string, unknown>) => Promise<unknown>) => {
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
})
}
})
})
+28
View File
@@ -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.
+151
View File
@@ -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' })
+35 -1
View File
@@ -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<string, string> | 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
})
+16 -3
View File
@@ -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<string, string>,
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') {
@@ -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<SetupDecision, 'run' | 'skip'>
reuseSession: boolean
precheckCommand: string
precheckTimeoutSeconds: string
@@ -53,6 +62,9 @@ type AutomationEditorDialogProps = {
canSave: boolean
createTarget: AutomationCreateTarget
repos: Repo[]
projectHostSetups: ProjectHostSetup[]
automationYamlHooksByRepoKey: Record<string, OrcaHooks | null>
getAutomationHooksCacheKey: (repoId: string) => string
repoMap: Map<string, Repo>
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}
/>
@@ -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<string, OrcaHooks | null>
getAutomationHooksCacheKey: (repoId: string) => string
repoMap: Map<string, Repo>
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}
/>
</div>
{/* 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. */}
<AutomationSetupDecisionField
createTarget={isHermesTarget ? 'hermes' : 'orca'}
draft={draft}
repos={repos}
projectHostSetups={projectHostSetups}
yamlHooks={automationYamlHooksByRepoKey[getAutomationHooksCacheKey(draft.projectId)]}
onDraftChange={onDraftChange}
onSetupDecisionTouched={onSetupDecisionTouched}
/>
</div>
</div>
<div className="mt-4 flex justify-end gap-2">
@@ -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.
<div className="mt-1">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setAdvancedOpen((open) => !open)}
className="-ml-2 text-xs"
>
{translate(
'auto.components.automations.AutomationSetupDecisionField.18f000ad4e',
'Advanced'
)}
<ChevronDown className={cn('size-4 transition-transform', advancedOpen && 'rotate-180')} />
</Button>
<div
className={cn(
'grid overflow-hidden transition-[grid-template-rows] duration-200 ease-out',
advancedOpen ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'
)}
aria-hidden={!advancedOpen}
>
<div className="min-h-0">
<div
className={cn(
'space-y-1 px-1 pt-2 transition-[opacity,transform] duration-150 ease-out',
advancedOpen
? 'translate-y-0 opacity-100 delay-200'
: '-translate-y-1 opacity-0 delay-0'
)}
>
<label className="group flex items-center gap-2 text-xs text-foreground">
<span
className={cn(
'flex size-4 items-center justify-center rounded-[3px] border shadow-sm transition',
checked
? 'border-emerald-500/60 bg-emerald-500 text-white'
: 'border-foreground/20 bg-background dark:border-white/20 dark:bg-muted/10'
)}
>
<Check
className={cn('size-3 transition-opacity', checked ? 'opacity-100' : 'opacity-0')}
/>
</span>
<input
type="checkbox"
checked={checked}
onChange={(event) => {
onSetupDecisionTouched()
onDraftChange((current) => ({
...current,
setupDecision: event.target.checked ? 'run' : 'skip'
}))
}}
className="sr-only"
/>
<span>{label}</span>
</label>
<p className="pl-6 text-xs text-muted-foreground">
{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."
)}
</p>
</div>
</div>
</div>
</div>
)
}
@@ -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<Set<string>>(new Set())
const rerunRunIdsInFlightRef = useRef<Set<string>>(new Set())
const workspaceNameCacheRef = useRef<Map<string, string>>(new Map())
const setupDecisionPolicyDefaultRef = useRef<AutomationDraft['setupDecision']>(undefined)
const setupDecisionDefaultSignatureRef = useRef<string | null>(null)
const setupDecisionTouchedRef = useRef(false)
const automationHookCheckPromisesRef = useRef<
Map<string, Promise<{ hooks: OrcaHooks | null; ok: boolean }>>
>(new Map())
const [automationYamlHooksByRepoKey, setAutomationYamlHooksByRepoKey] = useState<
Record<string, OrcaHooks | null>
>({})
const [draft, setDraft] = useState<AutomationDraft>({
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, 'projectId' | 'workspaceMode'>
): 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<OrcaHooks | null> => {
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<AutomationDraft, 'projectId' | 'workspaceMode'>): 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<void> => {
await updateAutomationForTarget(automation, { enabled: !automation.enabled })
await updateAutomationForTarget(
automation,
{ enabled: !automation.enabled },
automationHostTarget
)
await refresh()
}
const deleteAutomation = async (automation: Automation): Promise<void> => {
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 =
@@ -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 }
)
})
})
@@ -43,8 +43,12 @@ export function getAutomationListTarget(
}
export function getAutomationOwnerTarget(
automation: Pick<Automation, 'runContext'>
automation: Pick<Automation, 'runContext'>,
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<Automation> {
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<void> {
const target = getAutomationOwnerTarget(automation)
export async function deleteAutomationForTarget(
automation: Automation,
sourceTarget?: AutomationHostTarget | null
): Promise<void> {
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<AutomationRun> {
const target = getAutomationOwnerTarget(automation)
export async function runAutomationNowForTarget(
automation: Automation,
sourceTarget?: AutomationHostTarget | null
): Promise<AutomationRun> {
const target = getAutomationOwnerTarget(automation, sourceTarget)
if (target.kind === 'local') {
return await window.api.automations.runNow({ id: automation.id })
}
@@ -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> = {}): 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()
})
})
@@ -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<AutomationSetupSource, 'setupRunPolicy'> | null
): Extract<SetupDecision, 'run' | 'skip'> | 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<SetupDecision, 'run' | 'skip'> | 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<SetupDecision, 'run' | 'skip'> | undefined
}): Extract<SetupDecision, 'run' | 'skip'> | 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<SetupDecision, 'run' | 'skip'> | 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
}
@@ -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: {
@@ -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:<env>,
// 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:<env> 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
@@ -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<DictationControlAction>).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,
@@ -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<DictationControlAction>(DICTATION_CONTROL_EVENT, { detail: action })
)
}
@@ -20,7 +20,7 @@ const IOS_CHANNEL_COPY: Record<IosChannel, InstallCopy> = {
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 {
@@ -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 (
<div className="shrink-0 border-t border-border bg-muted/30">
<div className="mx-auto flex w-full max-w-3xl flex-col gap-2 px-3 py-3 sm:px-4">
<div className="flex items-start gap-2">
<ShieldQuestion className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0">
<p className="text-sm font-semibold text-foreground">{approval.title}</p>
{approval.detail ? (
<p className="mt-0.5 break-words font-mono text-xs text-muted-foreground">
{approval.detail}
</p>
) : null}
</div>
</div>
<div className="flex flex-wrap gap-2">
{approval.options.map((opt, i) => (
<button
key={`${opt.label}-${i}`}
type="button"
onClick={() => onChoose(opt.send)}
className={cn(
'rounded-md px-4 py-1.5 text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
i === 0
? 'bg-primary text-primary-foreground hover:bg-primary/90'
: 'border border-border bg-background text-foreground hover:bg-accent'
)}
>
{opt.label}
</button>
))}
</div>
</div>
</div>
)
}
@@ -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 (
<div className="absolute bottom-full left-3 right-3 mb-1 overflow-hidden rounded-md border border-border bg-popover shadow-md sm:left-4 sm:right-4">
{suggestions.map((command, index) => (
<button
key={command.name}
type="button"
onClick={() => onChoose(command)}
className={cn(
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm',
index === activeIndex ? 'bg-accent text-accent-foreground' : 'text-foreground'
)}
>
<span className="font-medium">/{command.name}</span>
{command.description ? (
<span className="truncate text-xs text-muted-foreground">{command.description}</span>
) : null}
</button>
))}
</div>
)
}
export function NativeChatMentionHint({
query,
onAccept
}: {
query: string
onAccept: () => void
}): React.JSX.Element {
return (
<button
type="button"
onClick={onAccept}
className="absolute bottom-full left-3 right-3 mb-1 flex w-auto items-center gap-2 rounded-md border border-border bg-popover px-3 py-1.5 text-left text-xs text-muted-foreground shadow-md sm:left-4 sm:right-4"
>
{translate('components.native-chat.composer.mentionHint', 'Referencing file:')}{' '}
<span className="font-medium text-foreground">@{query || '…'}</span>
</button>
)
}
export function NativeChatSkillMenu({
suggestions,
activeIndex,
onChoose
}: {
suggestions: DiscoveredSkill[]
activeIndex: number
onChoose: (skill: DiscoveredSkill) => void
}): React.JSX.Element {
const activeItemRef = useRef<HTMLButtonElement | null>(null)
useEffect(() => {
activeItemRef.current?.scrollIntoView({ block: 'nearest' })
}, [activeIndex, suggestions])
return (
<div className="scrollbar-sleek absolute bottom-full left-0 right-0 mb-1 max-h-64 overflow-y-auto rounded-md border border-border bg-popover p-1 shadow-md">
{suggestions.length === 0 ? (
<div className="px-2 py-1.5 text-xs text-muted-foreground">
{translate('components.native-chat.composer.noSkills', 'No matching skills')}
</div>
) : null}
{suggestions.map((skill, index) => (
<button
key={skill.id}
ref={index === activeIndex ? activeItemRef : null}
type="button"
onClick={() => onChoose(skill)}
className={cn(
'flex w-full items-start gap-2 rounded-sm px-2 py-1.5 text-left text-sm',
index === activeIndex ? 'bg-accent text-accent-foreground' : 'text-foreground'
)}
>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">${skill.name}</span>
{skill.description ? (
<span className="block truncate text-xs text-muted-foreground">
{skill.description}
</span>
) : null}
</span>
<span className="shrink-0 text-[11px] text-muted-foreground">{skill.sourceLabel}</span>
</button>
))}
</div>
)
}
@@ -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<NativeChatComposerHandle, NativeChatComposerProps>(
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<HistoryState>(EMPTY_HISTORY)
const [activeSuggestion, setActiveSuggestion] = useState(0)
const [notice, setNotice] = useState<string | null>(null)
const [dictationPressed, setDictationPressed] = useState(false)
const skills = useNativeChatSkills(agent, terminalTabId)
const textareaRef = useRef<HTMLTextAreaElement>(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<HTMLTextAreaElement>) => {
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 (
<NativeChatComposerField
textareaRef={textareaRef}
draft={draft}
disabled={disabled}
hasPty={hasPty}
canSend={canSend}
autocomplete={autocomplete}
activeSuggestion={activeSuggestion}
notice={notice}
imageAttachments={imageAttachments}
sendButtonDisabled={sendButtonDisabled}
isWorking={isWorking}
attachDisabled={disabled}
dictationDisabled={dictationDisabled}
isDictating={isDictating}
isDictationHoldMode={isDictationHoldMode}
onDraftChange={(value, element) => {
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}
/>
)
}
)
@@ -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 (
<div className="flex w-full items-center justify-between gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={translate('components.native-chat.composer.attach', 'Attach file')}
disabled={attachDisabled}
onClick={onAttach}
className="pointer-coarse:size-11"
>
<Plus className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{translate('components.native-chat.composer.attach', 'Attach file')}
</TooltipContent>
</Tooltip>
<div className="ml-auto flex items-center gap-1.5">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant={isDictating ? 'secondary' : 'ghost'}
size="icon-sm"
aria-label={dictationLabel}
disabled={dictationDisabled}
onClick={isDictationHoldMode ? undefined : onDictationToggle}
onPointerDown={(event) => {
if (!isDictationHoldMode || dictationDisabled) {
return
}
event.preventDefault()
onDictationHoldStart()
}}
onPointerUp={() => {
if (isDictationHoldMode && !dictationDisabled) {
onDictationHoldEnd()
}
}}
onPointerCancel={() => {
if (isDictationHoldMode && !dictationDisabled) {
onDictationHoldEnd()
}
}}
onPointerLeave={(event) => {
if (isDictationHoldMode && event.buttons === 1 && !dictationDisabled) {
onDictationHoldEnd()
}
}}
className="pointer-coarse:size-11"
>
{isDictating ? (
<Square className="size-3.5 fill-current" />
) : (
<Mic className="size-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{dictationLabel}
</TooltipContent>
</Tooltip>
<Button
type="button"
aria-label={
isWorking
? translate('components.native-chat.stop', 'Stop the agent')
: translate('components.native-chat.composer.send', 'Send')
}
disabled={sendDisabled}
onClick={isWorking ? onStop : onSend}
variant={isWorking ? 'secondary' : 'default'}
size="icon"
className="size-8 rounded-full pointer-coarse:size-10"
>
{isWorking ? (
<Square className="size-3.5 fill-current" />
) : (
<ArrowUp className="size-4" />
)}
</Button>
</div>
</div>
)
}
@@ -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<HTMLTextAreaElement | null>
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<HTMLTextAreaElement>
onPaste: ClipboardEventHandler<HTMLTextAreaElement>
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 (
<div className="shrink-0 bg-background">
<div className="px-3 py-2 sm:px-4">
<div className="relative mx-auto w-full max-w-3xl">
{autocomplete.mode === 'slash' && autocomplete.suggestions.length > 0 ? (
<NativeChatSlashMenu
suggestions={autocomplete.suggestions}
activeIndex={activeSuggestion}
onChoose={onChooseSlash}
/>
) : null}
{autocomplete.mode === 'mention' ? (
<NativeChatMentionHint query={autocomplete.query} onAccept={onAcceptMention} />
) : null}
{autocomplete.mode === 'skill' ? (
<NativeChatSkillMenu
suggestions={autocomplete.suggestions}
activeIndex={activeSuggestion}
onChoose={onChooseSkill}
/>
) : null}
{notice ? (
<div className="mb-1.5 flex items-center gap-1.5 text-xs text-muted-foreground">
<ImageOff className="size-3.5 shrink-0" />
<span>{notice}</span>
</div>
) : null}
<div
data-native-file-drop-target={NATIVE_FILE_DROP_TARGET.composer}
className={cn(
'rounded-xl border border-input bg-card p-1.5 shadow-xs transition-colors',
'focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50 dark:bg-input/30'
)}
>
{imageAttachments.length > 0 ? (
<div className="mb-2 flex flex-wrap gap-1.5 px-1">
{imageAttachments.map((attachment) => (
<div
key={attachment.id}
className="flex max-w-full items-center gap-1.5 rounded-md border border-border bg-background px-2 py-1 text-xs text-muted-foreground"
title={attachment.path}
>
<ImageIcon className="size-3.5 shrink-0" />
<span className="max-w-56 truncate">{basename(attachment.path)}</span>
<button
type="button"
onClick={() => onRemoveImageAttachment(attachment.id)}
aria-label={translate(
'components.native-chat.composer.removeAttachment',
'Remove attachment'
)}
className="flex size-4 shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X className="size-3" />
</button>
</div>
))}
</div>
) : null}
<textarea
ref={textareaRef}
value={draft}
disabled={disabled}
rows={2}
onChange={(e) => onDraftChange(e.target.value, e.currentTarget)}
onKeyDown={onKeyDown}
onPaste={onPaste}
onSelect={(e) => onTextareaSelect(e.currentTarget)}
placeholder={nativeChatComposerPlaceholder(hasPty, canSend)}
// Why: coarse-pointer min-height follows the app's touch target convention.
className={cn(
'min-h-12 max-h-28 w-full resize-none bg-transparent px-2 py-1 text-sm outline-none pointer-coarse:min-h-14',
'placeholder:text-muted-foreground/60 disabled:cursor-not-allowed disabled:opacity-50'
)}
/>
<div className="flex flex-wrap items-center gap-2 pt-0.5">
<NativeChatComposerActions
attachDisabled={attachDisabled}
dictationDisabled={dictationDisabled}
sendDisabled={sendButtonDisabled}
isWorking={isWorking}
isDictating={isDictating}
isDictationHoldMode={isDictationHoldMode}
onAttach={onAttach}
onDictationToggle={onDictationToggle}
onDictationHoldStart={onDictationHoldStart}
onDictationHoldEnd={onDictationHoldEnd}
onSend={onSend}
onStop={onStop}
/>
</div>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,66 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Check, Copy } from 'lucide-react'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
/**
* Per-message copy affordance for the native chat. Copies the message's text to
* the clipboard and briefly swaps the icon to a check tint as success feedback —
* matching the app's other inline copy buttons (icon swap, no toast). Uses
* Electron's clipboard IPC, which wraps navigator.clipboard.writeText and avoids
* the silent failures navigator.clipboard hits inside some renderer contexts.
*/
export function NativeChatCopyButton({
text,
className
}: {
text: string
className?: string
}): React.JSX.Element {
const [copied, setCopied] = useState(false)
const resetTimerRef = useRef<number | null>(null)
useEffect(() => {
return () => {
if (resetTimerRef.current !== null) {
window.clearTimeout(resetTimerRef.current)
}
}
}, [])
const handleCopy = useCallback(async () => {
try {
await window.api.ui.writeClipboardText(text)
setCopied(true)
if (resetTimerRef.current !== null) {
window.clearTimeout(resetTimerRef.current)
}
resetTimerRef.current = window.setTimeout(() => {
resetTimerRef.current = null
setCopied(false)
}, 1500)
} catch {
/* best-effort: clipboard can reject when unfocused */
}
}, [text])
const label = copied
? translate('components.native-chat.copyMessage.copied', 'Copied')
: translate('components.native-chat.copyMessage.copy', 'Copy message')
return (
<button
type="button"
onClick={handleCopy}
aria-label={label}
title={label}
className={cn(
'flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
copied && 'text-status-success',
className
)}
>
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
</button>
)
}
@@ -0,0 +1,27 @@
import { cn } from '@/lib/utils'
import type { DiffLine } from './native-chat-diff'
/** Inline coloured diff, used for Edit/Write tool calls and diff-style tool
* results. Adds/dels use the git-decoration tokens with a faint tinted ground,
* matching the terminal's diff palette (no invented colors). */
export function NativeChatDiffView({ lines }: { lines: DiffLine[] }): React.JSX.Element {
return (
<div className="overflow-hidden rounded bg-accent py-1 font-mono text-[11px] leading-relaxed">
{lines.map((line, i) => (
<div
key={i}
className={cn(
'whitespace-pre-wrap break-words px-2',
line.kind === 'add' && 'bg-emerald-500/10 text-[var(--git-decoration-added)]',
line.kind === 'del' && 'bg-rose-500/10 text-[var(--git-decoration-deleted)]',
line.kind === 'meta' && 'text-muted-foreground',
line.kind === 'context' && 'text-foreground/70'
)}
>
{line.kind === 'add' ? '+' : line.kind === 'del' ? '-' : ' '}
{line.text}
</div>
))}
</div>
)
}
@@ -0,0 +1,87 @@
import { MessageSquare, TriangleAlert } from 'lucide-react'
import { translate } from '@/i18n/i18n'
import { formatAgentTypeLabel } from '@/lib/agent-status'
import type { NativeChatSession } from '../../../../shared/native-chat-types'
export function NativeChatEmptyState({
kind,
message,
agent
}: {
kind: 'loading' | 'empty' | 'error' | 'not-agent'
message?: string
agent?: NativeChatSession['agent']
}): React.JSX.Element {
const copy = emptyStateCopy(kind, message, agent)
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-3 p-6 text-center">
<div
className={
kind === 'error'
? 'flex size-12 items-center justify-center rounded-full bg-destructive/10 text-destructive'
: 'flex size-12 items-center justify-center rounded-full bg-accent text-accent-foreground'
}
>
{kind === 'error' ? (
<TriangleAlert className="size-6" />
) : (
<MessageSquare className="size-6" />
)}
</div>
<p className="text-sm font-medium text-foreground">{copy.title}</p>
{copy.subtitle ? (
<p className="max-w-sm text-balance text-xs text-muted-foreground">{copy.subtitle}</p>
) : null}
</div>
)
}
function emptyStateCopy(
kind: 'loading' | 'empty' | 'error' | 'not-agent',
message?: string,
agent?: NativeChatSession['agent']
): { title: string; subtitle: string | null } {
switch (kind) {
case 'loading':
return {
title: translate('components.native-chat.state.loading.title', 'Loading conversation…'),
subtitle: translate(
'components.native-chat.state.loading.subtitle',
'Reading the agent transcript.'
)
}
case 'error':
return {
title: translate('components.native-chat.state.error.title', 'Could not load conversation'),
subtitle:
message ??
translate(
'components.native-chat.state.error.subtitle',
'The transcript could not be read. Toggle back to the terminal to keep working.'
)
}
case 'not-agent':
return {
title: translate('components.native-chat.state.notAgent.title', 'No conversation here'),
subtitle: translate(
'components.native-chat.state.notAgent.subtitle',
'This terminal is not running a recognized coding agent.'
)
}
case 'empty': {
const agentName = agent ? formatAgentTypeLabel(agent) : 'the agent'
return {
title: translate(
'components.native-chat.state.empty.title',
'Start a chat with {{value0}}',
{ value0: agentName }
),
subtitle: translate(
'components.native-chat.state.empty.subtitle',
'Ask {{value0}} to inspect code, explain output, or make a change.',
{ value0: agentName }
)
}
}
}
}
@@ -0,0 +1,85 @@
import { useEffect, useMemo, useState } from 'react'
import { useAppStore } from '../../store'
import { parseInteractivePrompt } from './native-chat-interactive-prompt'
import { nativeChatCardDismissKey } from './native-chat-dismiss-key'
import { NativeChatQuestionCard } from './NativeChatQuestionCard'
import { NativeChatApprovalCard } from './NativeChatApprovalCard'
import type { NativeChatInteractiveSend } from './use-native-chat-interactive-send'
/**
* Render the live interactive card for the pane while the agent's
* `interactivePrompt` is present: a question wizard (precedence) or a tool
* approval. Cleared by the host once the agent moves on, so it disappears
* automatically. Sends through the composer's verified runtime path (R8/R6):
* answers as bracketed-paste + Enter; cancel/deny as ESC. Guarded by `canSend`
* so a mobile presence-lock blocks desktop sends the same way it guards xterm.
*
* Dismiss-on-answer (mobile parity): the live status lingers after answering —
* the agent emits a post-tool event carrying the same prompt — so we track the
* answered prompt by content key and hide the card until a genuinely different
* prompt arrives. The dismissal resets once the prompt clears, so a later
* (even identical) prompt shows again instead of staying hidden.
*/
export function NativeChatInteractiveCard({
paneKey,
send,
canSend
}: {
paneKey: string
send: NativeChatInteractiveSend
canSend: boolean
}): React.JSX.Element | null {
const interactivePrompt = useAppStore(
(s) => s.agentStatusByPaneKey[paneKey]?.interactivePrompt ?? null
)
// Thread the sibling `toolName` from the same status entry so the question
// parser can dispatch through the tool's registered parser (mobile parity).
const interactiveToolName = useAppStore(
(s) => s.agentStatusByPaneKey[paneKey]?.toolName ?? null
)
const { sendAnswer, sendRaw, cancel } = send
const card = useMemo(
() => parseInteractivePrompt(interactivePrompt, interactiveToolName ?? undefined),
[interactivePrompt, interactiveToolName]
)
const cardKey = useMemo(() => nativeChatCardDismissKey(card), [card])
const [dismissedKey, setDismissedKey] = useState<string | null>(null)
// Forget the dismissal once the prompt clears so a fresh prompt can show.
const present = card != null
useEffect(() => {
if (!present) {
setDismissedKey(null)
}
}, [present])
if (!card || !canSend || cardKey === dismissedKey) {
return null
}
if (card.kind === 'question') {
return (
<NativeChatQuestionCard
key={cardKey ?? 'question'}
prompt={card.prompt}
onAnswer={(text) => {
setDismissedKey(cardKey)
sendAnswer(text)
}}
onCancel={() => {
setDismissedKey(cardKey)
cancel()
}}
/>
)
}
return (
<NativeChatApprovalCard
approval={card.approval}
onChoose={(raw) => {
setDismissedKey(cardKey)
sendRaw(raw)
}}
/>
)
}
@@ -0,0 +1,361 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { ArrowDown, ArrowUp, Image as ImageIcon } from 'lucide-react'
import CommentMarkdown from '@/components/sidebar/CommentMarkdown'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import { basename } from '@/lib/path'
import {
isTextBlock,
type NativeChatBlock,
type NativeChatMessage
} from '../../../../shared/native-chat-types'
import type { NativeChatLiveSession } from './use-native-chat-live-session'
import { orderNativeChatMessages } from './native-chat-message-grouping'
import { stripNoiseMessages } from './native-chat-noise'
import { foldToolMessages, splitNativeChatBlocks } from './native-chat-tool-fold'
import { isNearBottom, shouldShowJumpToLatest, type ScrollGeometry } from './native-chat-autoscroll'
import { NativeChatToolRun } from './NativeChatToolRun'
import { NativeChatCopyButton } from './NativeChatCopyButton'
import { NATIVE_CHAT_STREAMING_ID } from '../../../../shared/native-chat-streaming'
function geometryOf(el: HTMLElement): ScrollGeometry {
return { scrollTop: el.scrollTop, scrollHeight: el.scrollHeight, clientHeight: el.clientHeight }
}
function proseToMarkdown(blocks: NativeChatBlock[]): string {
return blocks
.map((block) => {
if (isTextBlock(block)) {
return block.text
}
return ''
})
.filter((part) => part.length > 0)
.join('\n\n')
}
function ImageAttachmentRefs({ blocks }: { blocks: NativeChatBlock[] }): React.JSX.Element | null {
const images = blocks.filter((block) => block.type === 'image-ref')
if (images.length === 0) {
return null
}
return (
<div className="mb-2 flex flex-wrap gap-1.5">
{images.map((image, index) => {
const label = image.alt ?? image.path ?? image.url ?? 'Image'
const name = image.path ? basename(image.path) : label
return (
<div
key={`${label}-${index}`}
className="flex max-w-full items-center gap-1.5 rounded-md border border-border bg-background px-2 py-1 text-xs text-muted-foreground"
title={label}
>
<ImageIcon className="size-3.5 shrink-0" />
<span className="truncate">{name}</span>
</div>
)
})}
</div>
)
}
/** Inline controls for an agent message (mobile AgentControls parity): copy the
* message's prose, and scroll so this message's top aligns to the viewport top.
* Reveals on hover / keyboard focus like the prior copy affordance. */
function AgentControls({
markdown,
onScrollToTop,
className
}: {
markdown: string
onScrollToTop: () => void
className?: string
}): React.JSX.Element {
return (
<div className={cn('flex items-center gap-1', className)}>
<NativeChatCopyButton text={markdown} />
<button
type="button"
onClick={onScrollToTop}
aria-label={translate(
'components.native-chat.scrollMessageToTop',
'Scroll this message to top'
)}
title={translate('components.native-chat.scrollMessageToTop', 'Scroll this message to top')}
className="flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<ArrowUp className="size-3.5" />
</button>
</div>
)
}
function TypingIndicatorRow(): React.JSX.Element {
return (
<div
className="flex items-center justify-start"
aria-label={translate('components.native-chat.status.responding', 'Agent is responding')}
aria-live="polite"
>
<div className="flex h-8 items-center gap-1.5 text-muted-foreground">
{[0, 1, 2].map((i) => (
<span
key={i}
className="size-1.5 animate-bounce rounded-full bg-muted-foreground/70"
// Stagger the three dots so they ripple rather than pulse in unison.
style={{ animationDelay: `${i * 160}ms` }}
/>
))}
</div>
</div>
)
}
/** One message: its prose first, then a collapsible run folding all of the
* turn's tool activity. Monochrome per STYLEGUIDE: user prompts read as a
* lifted card, assistant prose as body copy, reasoning de-emphasized. */
function MessageRow({
message,
expandSignal,
onScrollMessageToTop
}: {
message: NativeChatMessage
expandSignal: boolean
/** Align this message's top to the top of the scroll viewport. */
onScrollMessageToTop: (el: HTMLElement) => void
}): React.JSX.Element | null {
const rowRef = useRef<HTMLDivElement | null>(null)
const { prose, tools } = useMemo(() => splitNativeChatBlocks(message.blocks), [message.blocks])
const markdown = proseToMarkdown(prose)
const hasImages = prose.some((block) => block.type === 'image-ref')
const isUser = message.role === 'user'
const isReasoning = message.role === 'reasoning'
const isSystem = message.role === 'system'
const scrollToTop = useCallback(() => {
if (rowRef.current) {
onScrollMessageToTop(rowRef.current)
}
}, [onScrollMessageToTop])
// Skip rows with nothing renderable so the transcript shows no empty/ghost
// bubble.
// After all hooks, so hook order stays unconditional.
if (markdown.length === 0 && !hasImages && tools.length === 0) {
return null
}
if (isUser) {
// Why: an optimistic echo is rendered identically to a real user turn (no
// muting, no "Queued" label) so that when the real transcript turn lands and
// replaces it, there is no visible state change — the send just appears and
// stays. (A distinct "queued" treatment flickered normal→queued→normal as the
// transcript caught up.)
return (
<div ref={rowRef} className="flex flex-col items-end gap-0.5">
<div className="max-w-[85%] rounded-xl rounded-tr-sm border border-border bg-card px-3 py-2 text-sm text-card-foreground">
{markdown ? (
<>
<ImageAttachmentRefs blocks={prose} />
<CommentMarkdown content={markdown} variant="document" className="text-sm" />
</>
) : (
<ImageAttachmentRefs blocks={prose} />
)}
</div>
</div>
)
}
// Plain assistant prose is the copyable unit; reasoning/system asides stay
// chrome-free. The controls reveal on hover (and on keyboard focus-within).
const showControls = !isReasoning && !isSystem && markdown.length > 0
return (
<div
ref={rowRef}
className={cn(
'group relative max-w-full text-sm leading-relaxed text-foreground',
// Reasoning is the agent thinking aloud — quieter, italic, like an aside.
isReasoning && 'border-l-2 border-border/60 pl-3 italic text-muted-foreground',
isSystem && 'text-xs text-muted-foreground'
)}
>
{showControls ? (
<AgentControls
markdown={markdown}
onScrollToTop={scrollToTop}
className="absolute -top-1 right-0 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100"
/>
) : null}
<ImageAttachmentRefs blocks={prose} />
{markdown ? (
<CommentMarkdown content={markdown} variant="document" className="text-sm" />
) : null}
{tools.length > 0 ? <NativeChatToolRun blocks={tools} expandSignal={expandSignal} /> : null}
</div>
)
}
export function NativeChatMessageList({
session,
isWorking,
expandSignal,
fontScale
}: {
session: NativeChatLiveSession
isWorking: boolean
/** Toolbar-driven desired open state for every tool run; each flip re-syncs. */
expandSignal: boolean
/** Chat-only text multiplier (1 = default), driven by the zoom shortcuts. */
fontScale: number
}): React.JSX.Element {
const scrollRef = useRef<HTMLDivElement | null>(null)
const [stuckToBottom, setStuckToBottom] = useState(true)
const [showJump, setShowJump] = useState(false)
// Why: mirror stuck state into a ref so the auto-scroll layout effect can read
// it without depending on it — depending on stuckToBottom (which scrollToBottom
// sets) would re-fire the effect in a self-loop.
const stuckToBottomRef = useRef(stuckToBottom)
stuckToBottomRef.current = stuckToBottom
const { hasMore, loadingEarlier, loadEarlier } = session
// Strip harness noise (task-notifications, system reminders, slash-command
// envelopes) before folding so they don't render as the user's own bubbles —
// matching the mobile chat. Then fold each turn's tool activity into the
// assistant message it belongs to, ordered stably, so a turn's tools collapse
// under one run.
const messages = useMemo(
() => foldToolMessages(orderNativeChatMessages(stripNoiseMessages(session.messages))),
[session.messages]
)
const showTypingIndicator =
isWorking && !messages.some((message) => message.id === NATIVE_CHAT_STREAMING_ID)
// When an older page prepends, the scroll content grows above the viewport.
// Capture the pre-render scroll height so the layout effect can restore the
// user's position (no jump) instead of letting the browser keep scrollTop.
const prependAnchorRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null)
const handleScroll = useCallback(() => {
const el = scrollRef.current
if (!el) {
return
}
const geometry = geometryOf(el)
const stick = isNearBottom(geometry)
setStuckToBottom(stick)
setShowJump(shouldShowJumpToLatest(stick, geometry))
// Near the top — page in older history, anchoring the current position so the
// prepend doesn't yank the view.
if (geometry.scrollTop < 80 && hasMore && !loadingEarlier) {
prependAnchorRef.current = { scrollHeight: el.scrollHeight, scrollTop: el.scrollTop }
loadEarlier()
}
}, [hasMore, loadingEarlier, loadEarlier])
const scrollToBottom = useCallback(() => {
const el = scrollRef.current
if (!el) {
return
}
el.scrollTop = el.scrollHeight
setStuckToBottom(true)
setShowJump(false)
}, [])
// Align a single message's top to the top of the scroll viewport.
const scrollMessageToTop = useCallback((el: HTMLElement) => {
const container = scrollRef.current
if (!container) {
return
}
const delta = el.getBoundingClientRect().top - container.getBoundingClientRect().top
container.scrollTo({ top: container.scrollTop + delta, behavior: 'smooth' })
}, [])
// Re-pin to the bottom when new content arrives, but only if the user hasn't
// scrolled up. Layout effect so the jump happens before paint (no flicker).
// When an older page just prepended, restore the prior position instead.
useLayoutEffect(() => {
const el = scrollRef.current
if (el && prependAnchorRef.current) {
// Preserve the viewport: shift scrollTop by however much taller the content
// got, so the message the user was reading stays put.
const grew = el.scrollHeight - prependAnchorRef.current.scrollHeight
el.scrollTop = prependAnchorRef.current.scrollTop + grew
prependAnchorRef.current = null
return
}
if (stuckToBottomRef.current) {
scrollToBottom()
}
}, [messages.length, isWorking, showTypingIndicator, scrollToBottom])
// Keep the affordances in sync if the container resizes (e.g. composer mounts,
// viewport reflow) without a scroll event.
useEffect(() => {
const el = scrollRef.current
if (!el || typeof ResizeObserver === 'undefined') {
return
}
const observer = new ResizeObserver(handleScroll)
observer.observe(el)
return () => observer.disconnect()
}, [handleScroll])
return (
<div className="relative min-h-0 flex-1">
<div
ref={scrollRef}
onScroll={handleScroll}
className="scrollbar-sleek h-full overflow-y-auto px-3 pt-10 pb-4 sm:px-4"
>
<div
className="mx-auto flex w-full max-w-3xl flex-col gap-3"
// Why: `zoom` scales the chat transcript's text and layout together,
// scoped to this container so the rest of the app is untouched. It's
// the desktop analog of the mobile pinch-zoom (Chromium/Electron only).
style={{ zoom: fontScale }}
>
{hasMore ? (
<div className="flex justify-center py-1">
<button
type="button"
onClick={loadEarlier}
disabled={loadingEarlier}
className="rounded-md px-3 py-1 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
>
{loadingEarlier
? translate('components.native-chat.loadingEarlier', 'Loading…')
: translate('components.native-chat.loadEarlier', 'Load earlier messages')}
</button>
</div>
) : null}
{messages.map((message) => (
<MessageRow
key={message.id}
message={message}
expandSignal={expandSignal}
onScrollMessageToTop={scrollMessageToTop}
/>
))}
{showTypingIndicator ? <TypingIndicatorRow /> : null}
</div>
</div>
{showJump ? (
<button
type="button"
onClick={scrollToBottom}
aria-label={translate('components.native-chat.jumpToLatest', 'Jump to latest')}
className="absolute bottom-3 left-1/2 flex -translate-x-1/2 items-center gap-1.5 rounded-full border border-border bg-card/90 px-3 py-1.5 text-xs text-muted-foreground shadow-sm backdrop-blur hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<ArrowDown className="size-3.5" />
<span>{translate('components.native-chat.jumpToLatest', 'Jump to latest')}</span>
</button>
) : null}
</div>
)
}
@@ -0,0 +1,229 @@
import { useMemo, useState } from 'react'
import { Check } from 'lucide-react'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import { formatAskAnswer, type AskPrompt } from './native-chat-interactive-prompt'
export type NativeChatQuestionCardProps = {
prompt: AskPrompt
/** Send the formatted answer text to the agent. */
onAnswer: (text: string) => void
/** Dismiss the prompt (sends Escape to the agent). */
onCancel: () => void
}
// Synthetic option value for the "Other…" free-text row, kept out of the
// answer text and replaced by the typed value when selected.
const OTHER = '__other__'
/**
* Native renderer for an agent's AskUserQuestion prompt as a wizard: one
* question per step with tabs across the top (tap to jump, a check once
* answered), single- or multi-select option rows, an "Other…" row that reveals a
* free-text input, and a Next button that advances and becomes "Send answer" on
* the last step. Matches the desktop chat's neutral shadcn styling.
*/
export function NativeChatQuestionCard({
prompt,
onAnswer,
onCancel
}: NativeChatQuestionCardProps): React.JSX.Element {
const [index, setIndex] = useState(0)
const [selections, setSelections] = useState<string[][]>(() => prompt.questions.map(() => []))
const [otherText, setOtherText] = useState<string[]>(() => prompt.questions.map(() => ''))
const toggle = (qi: number, label: string, multi: boolean): void => {
setSelections((prev) => {
const next = prev.map((s) => [...s])
const cur = next[qi] ?? []
if (multi) {
next[qi] = cur.includes(label) ? cur.filter((l) => l !== label) : [...cur, label]
} else {
next[qi] = cur.includes(label) ? [] : [label]
}
return next
})
}
const setOther = (qi: number, value: string): void => {
setOtherText((prev) => {
const next = [...prev]
next[qi] = value
return next
})
}
// The resolved answer for a question: picked labels plus the typed "Other"
// value (which replaces the synthetic OTHER marker).
const answerFor = (qi: number): string => {
const picked = (selections[qi] ?? []).filter((l) => l !== OTHER)
const other = (selections[qi] ?? []).includes(OTHER) ? (otherText[qi] ?? '').trim() : ''
return [...picked, other].filter((p) => p.length > 0).join(', ')
}
const total = prompt.questions.length
const isLast = index === total - 1
const currentAnswered = useMemo(
() => answerFor(index).length > 0,
// eslint-disable-next-line react-hooks/exhaustive-deps
[selections, otherText, index]
)
const submit = (): void => {
// Build per-question label lists, substituting the typed Other value, then
// format to one line per answered question.
const resolved = prompt.questions.map((_, i) => {
const picked = (selections[i] ?? []).filter((l) => l !== OTHER)
const other = (selections[i] ?? []).includes(OTHER) ? (otherText[i] ?? '').trim() : ''
return [...picked, ...(other ? [other] : [])]
})
const text = formatAskAnswer(prompt, resolved)
if (text.length > 0) {
onAnswer(text)
}
}
const advance = (): void => {
if (isLast) {
submit()
} else {
setIndex((i) => Math.min(i + 1, total - 1))
}
}
const q = prompt.questions[index]!
const otherSelected = (selections[index] ?? []).includes(OTHER)
return (
<div className="shrink-0 border-t border-border bg-muted/30">
<div className="mx-auto flex max-h-[22rem] w-full max-w-3xl flex-col px-3 py-2 sm:px-4">
{total > 1 ? (
<div className="flex gap-1 overflow-x-auto border-b border-border pb-2 scrollbar-sleek">
{prompt.questions.map((qq, i) => (
<button
key={i}
type="button"
onClick={() => setIndex(i)}
className={cn(
'flex shrink-0 items-center gap-1 rounded-md px-2 py-1 text-xs font-medium',
i === index
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:text-foreground'
)}
>
<span className="max-w-[10rem] truncate">
{qq.header ||
translate('components.native-chat.question.step', 'Step {{value0}}', {
value0: i + 1
})}
</span>
{answerFor(i).length > 0 ? (
<Check className="size-3 text-primary" strokeWidth={3} />
) : null}
</button>
))}
</div>
) : null}
<div className="min-h-0 flex-1 overflow-y-auto py-2 scrollbar-sleek">
<p className="mb-2 text-sm font-semibold text-foreground">{q.question}</p>
<div className="flex flex-col gap-1.5">
{q.options.map((opt) => (
<OptionRow
key={opt.label}
label={opt.label}
description={opt.description}
selected={(selections[index] ?? []).includes(opt.label)}
onSelect={() => toggle(index, opt.label, q.multiSelect)}
/>
))}
<OptionRow
label={translate('components.native-chat.question.other', 'Other…')}
selected={otherSelected}
onSelect={() => toggle(index, OTHER, q.multiSelect)}
/>
{otherSelected ? (
<textarea
autoFocus
value={otherText[index]}
onChange={(e) => setOther(index, e.target.value)}
placeholder={translate(
'components.native-chat.question.otherPlaceholder',
'Type your answer'
)}
rows={2}
className="w-full resize-none rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs outline-none placeholder:text-muted-foreground/60 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:bg-input/30"
/>
) : null}
</div>
</div>
<div className="flex items-center justify-between gap-2 border-t border-border pt-2">
<button
type="button"
onClick={onCancel}
className="rounded-md px-2 py-1 text-sm font-medium text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{translate('components.native-chat.question.cancel', 'Cancel')}
</button>
{total > 1 ? (
<span className="text-xs text-muted-foreground">
{index + 1}/{total}
</span>
) : null}
<button
type="button"
onClick={advance}
disabled={!currentAnswered}
className={cn(
'rounded-md bg-primary px-4 py-1.5 text-sm font-semibold text-primary-foreground transition-colors',
'hover:bg-primary/90 disabled:pointer-events-none disabled:opacity-50'
)}
>
{isLast
? translate('components.native-chat.question.send', 'Send answer')
: translate('components.native-chat.question.next', 'Next')}
</button>
</div>
</div>
</div>
)
}
function OptionRow({
label,
description,
selected,
onSelect
}: {
label: string
description?: string
selected: boolean
onSelect: () => void
}): React.JSX.Element {
return (
<button
type="button"
onClick={onSelect}
className={cn(
'flex w-full items-start gap-2.5 rounded-md border bg-background px-3 py-2 text-left transition-colors',
selected ? 'border-primary' : 'border-border hover:bg-accent/50'
)}
>
<span
className={cn(
'mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full border',
selected ? 'border-primary bg-primary text-primary-foreground' : 'border-muted-foreground'
)}
>
{selected ? <Check className="size-3" strokeWidth={3} /> : null}
</span>
<span className="min-w-0">
<span className="block text-sm font-medium text-foreground">{label}</span>
{description ? (
<span className="block text-xs text-muted-foreground">{description}</span>
) : null}
</span>
</button>
)
}
@@ -0,0 +1,144 @@
import { useEffect, useState } from 'react'
import { ChevronDown, SquareChevronRight } from 'lucide-react'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import {
isToolCallBlock,
isToolResultBlock,
type NativeChatBlock
} from '../../../../shared/native-chat-types'
import { diffFromText, diffFromToolCall, type DiffLine } from './native-chat-diff'
import { countToolCalls, summarizeToolInput, summarizeToolRun } from './native-chat-tool-summary'
import { NativeChatDiffView } from './NativeChatDiffView'
const MAX_TOOL_RESULT_CHARS = 4000
/** A single inline tool line — `▸ ToolName preview` — that expands in place to
* show the call's diff/input or the result's body. Tool calls read as flat
* lines in the conversation rather than boxed blocks (mobile parity). */
function ToolLine({ block }: { block: NativeChatBlock }): React.JSX.Element | null {
const [expanded, setExpanded] = useState(false)
let name: string
let preview: string
let diff: DiffLine[] | null = null
let body: { output: string; isError?: boolean } | null = null
if (isToolCallBlock(block)) {
name = block.name
preview = summarizeToolInput(block.input)
diff = diffFromToolCall(block.name, block.input)
} else if (isToolResultBlock(block)) {
name = translate('components.native-chat.tool.result', 'Result')
preview = block.output.split('\n')[0]?.slice(0, 80) ?? ''
diff = diffFromText(block.output)
body = { output: block.output, isError: block.isError }
} else {
return null
}
const hasDetail = diff !== null || body !== null || preview.length > 40
return (
<div>
<button
type="button"
onClick={() => hasDetail && setExpanded((v) => !v)}
className={cn(
'flex w-full items-center gap-1.5 py-0.5 text-left',
hasDetail ? 'cursor-pointer' : 'cursor-default'
)}
>
{expanded ? (
<ChevronDown className="size-3.5 shrink-0 text-muted-foreground" />
) : (
<SquareChevronRight className="size-3.5 shrink-0 text-muted-foreground" />
)}
<code className="shrink-0 font-mono text-xs font-semibold text-foreground/90">{name}</code>
{preview ? (
<span
className="min-w-0 truncate font-mono text-[11px] text-muted-foreground"
title={preview}
>
{preview}
</span>
) : null}
</button>
{expanded ? (
<div className="space-y-1.5 py-1 pl-5">
{diff ? <NativeChatDiffView lines={diff} /> : null}
{!diff && body ? (
<pre
className={cn(
'max-h-64 overflow-auto whitespace-pre-wrap break-words rounded bg-accent p-2 font-mono text-[11px] scrollbar-sleek',
body.isError ? 'text-destructive' : 'text-foreground/80'
)}
>
{body.output.length > MAX_TOOL_RESULT_CHARS
? `${body.output.slice(0, MAX_TOOL_RESULT_CHARS)}…`
: body.output}
</pre>
) : null}
{!diff && !body && preview ? (
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-words rounded bg-accent p-2 font-mono text-[11px] text-foreground/80 scrollbar-sleek">
{preview}
</pre>
) : null}
</div>
) : null}
</div>
)
}
/** A run of a message's tool calls/results, collapsed to a one-line summary that
* expands to the individual inline tool lines. `expandSignal` lets the global
* toolbar toggle drive every run at once while still allowing per-run override. */
export function NativeChatToolRun({
blocks,
expandSignal
}: {
blocks: NativeChatBlock[]
/** Toolbar-driven desired open state. Each change re-syncs this run's state. */
expandSignal: boolean
}): React.JSX.Element {
const [open, setOpen] = useState(expandSignal)
// Re-sync when the global toolbar toggle flips.
useEffect(() => setOpen(expandSignal), [expandSignal])
const callCount = countToolCalls(blocks) || blocks.length
const summary = summarizeToolRun(blocks)
const fallbackLabel = translate(
callCount === 1 ? 'components.native-chat.tool.countOne' : 'components.native-chat.tool.countN',
callCount === 1 ? '1 tool call' : `${callCount} tool calls`,
{ count: callCount }
)
return (
<div className="mt-1">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center gap-1.5 py-0.5 text-left"
>
{open ? (
<ChevronDown className="size-3.5 shrink-0 text-muted-foreground" />
) : (
<SquareChevronRight className="size-3.5 shrink-0 text-muted-foreground" />
)}
<span className="shrink-0 font-mono text-[11px] font-bold text-muted-foreground">
{callCount}×
</span>
<span className="min-w-0 truncate font-mono text-[11px] text-muted-foreground">
{summary || fallbackLabel}
</span>
</button>
{open ? (
<div className="mt-1 border-l-2 border-border/60 pl-2.5">
{blocks.map((block, i) => (
<ToolLine key={i} block={block} />
))}
</div>
) : null}
</div>
)
}
@@ -0,0 +1,430 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { useAppStore } from '../../store'
import { APP_MENU_PASTE_EVENT } from '@/lib/app-menu-paste'
import type { TuiAgent } from '../../../../shared/types'
import type { NativeChatSession } from '../../../../shared/native-chat-types'
import { resolveNativeChatSession } from './native-chat-pane-resolution'
import { useNativeChatLiveSession } from './use-native-chat-live-session'
import { selectNativeChatViewState } from './native-chat-view-state'
import { NativeChatMessageList } from './NativeChatMessageList'
import { NativeChatComposer, type NativeChatComposerHandle } from './NativeChatComposer'
import { useNativeChatFontScale } from './use-native-chat-font-scale'
import { useNativeChatCanSend } from './use-native-chat-can-send'
import { NativeChatInteractiveCard } from './NativeChatInteractiveCard'
import { NativeChatEmptyState } from './NativeChatEmptyState'
import { useNativeChatInteractiveSend } from './use-native-chat-interactive-send'
import { findTabAgentEntry } from './native-chat-tab-agent-entry'
import {
shouldClearNativeChatWorkingSuppression,
shouldShowNativeChatWorking
} from './native-chat-working-suppression'
import {
applyCommandMarkerBoundaries,
appendPendingSendCache,
commandMarkersAsMessages,
appendCommandMarkerCache,
pendingSendsAsMessages,
prunePendingSends,
readCommandMarkerCache,
readPendingSendCache,
writePendingSendCache,
type NativeChatCommandMarker,
type NativeChatPendingSend
} from './native-chat-pending'
import {
deriveNativeChatStreamingText,
nativeChatStreamingMessage
} from '../../../../shared/native-chat-streaming'
import {
shouldFocusNativeChatPaneFromPointerTarget,
shouldRedirectNativeChatTyping
} from './native-chat-typing-redirect'
import { useNativeChatContextMenu } from './use-native-chat-context-menu'
import type { NativeChatContextMenuActions } from './use-native-chat-context-menu'
import { isMacPlatform } from './native-chat-shortcut'
const NATIVE_CHAT_CONTEXT_PASTE_MAX_BYTES = 16 * 1024 * 1024
const emptyNativeChatContextMenuActions: Omit<NativeChatContextMenuActions, 'onPaste'> = {
onSplitRight: () => {},
onSplitDown: () => {},
canEqualizePaneSizes: false,
onEqualizePaneSizes: () => {},
canExpandPane: false,
isPaneExpanded: false,
onToggleExpand: () => {},
onForkAgentSession: () => {},
onSetTitle: () => {},
onCopyTerminalId: () => {},
onCopyPaneId: () => {},
canClosePane: false,
onClosePane: () => {}
}
export type NativeChatViewProps = {
/** The terminal tab hosting the agent. paneKey is `${tabId}:${leafId}`. */
terminalTabId: string
/** Specific split leaf this chat surface replaces. */
paneKey?: string
/** PTY bound to `paneKey`, used for composer and interactive-card sends. */
targetPtyId?: string | null
/** Launch-time agent hint from the TerminalTab, when Orca started one. */
launchAgent?: TuiAgent | null
/** Return this pane to the hosted terminal surface. */
onSwitchToTerminal?: () => void
contextMenuActions?: Omit<NativeChatContextMenuActions, 'onPaste'>
}
/**
* Native chat surface for an agent terminal. Resolves the pane to its agent +
* session id, streams the assembled conversation via the U4 live-session hook,
* and renders the message list, live status, and all empty/loading/error
* states. When no session id is known yet the hook surfaces live hook state on
* an empty transcript; a true scrollback-scrape fallback (U6) is wired but only
* runs when scrollback is obtainable — it degrades to the empty state otherwise.
*/
export default function NativeChatView({
terminalTabId,
paneKey: preferredPaneKey,
targetPtyId = null,
launchAgent,
onSwitchToTerminal,
contextMenuActions
}: NativeChatViewProps): React.JSX.Element {
// Select only this tab's status entry (shallow-compared) so an unrelated
// pane's status tick doesn't re-render this view or re-run the resolution.
const agentStatusEntry = useAppStore(
useShallow((s) =>
preferredPaneKey
? s.agentStatusByPaneKey[preferredPaneKey]
: findTabAgentEntry(s.agentStatusByPaneKey, terminalTabId)
)
)
const resolution = useMemo(() => {
// paneKey: prefer the live entry's key; fall back to the tab id so the hook
// still has a stable key to select live status by before any pane reports.
const paneKey = preferredPaneKey ?? agentStatusEntry?.paneKey ?? `${terminalTabId}:`
return resolveNativeChatSession({
paneKey,
launchAgent,
...(agentStatusEntry ? { agentStatusEntry } : {}),
ptyId: targetPtyId
})
}, [agentStatusEntry, terminalTabId, preferredPaneKey, targetPtyId, launchAgent])
if (!resolution) {
return <NativeChatEmptyState kind="not-agent" />
}
return (
<NativeChatResolvedView
paneKey={resolution.paneKey}
agent={resolution.agent}
sessionId={resolution.sessionId}
transcriptPath={resolution.transcriptPath}
targetPtyId={targetPtyId}
terminalTabId={terminalTabId}
onSwitchToTerminal={onSwitchToTerminal}
contextMenuActions={contextMenuActions}
/>
)
}
function NativeChatResolvedView({
paneKey,
agent,
sessionId,
transcriptPath,
targetPtyId,
terminalTabId,
onSwitchToTerminal,
contextMenuActions
}: {
paneKey: string
agent: NativeChatSession['agent']
sessionId: string | null
transcriptPath: string | null
targetPtyId: string | null
terminalTabId: string
onSwitchToTerminal?: () => void
contextMenuActions?: Omit<NativeChatContextMenuActions, 'onPaste'>
}): React.JSX.Element {
const session = useNativeChatLiveSession({ paneKey, agent, sessionId, transcriptPath })
// Live hook state for this pane, selected directly so the working indicator
// flips the instant the agent reports 'working' — even when switching to chat
// mid-turn before the transcript merge has caught up.
const hookWorking = useAppStore((s) => s.agentStatusByPaneKey[paneKey]?.state === 'working')
// The agent's in-progress reply preview (hook), shown as a live streaming
// bubble while it works — before the completed turn flushes to the transcript.
const hookPreview = useAppStore((s) => s.agentStatusByPaneKey[paneKey]?.lastAssistantMessage)
const canSend = useNativeChatCanSend(targetPtyId)
// Reuse the verified composer send path for interactive cards and composer
// stop (Stop sends ESC, the agent-TUI interrupt key).
const interactiveSend = useNativeChatInteractiveSend(terminalTabId, targetPtyId, agent)
const [workingInterrupted, setWorkingInterrupted] = useState(false)
const rootRef = useRef<HTMLDivElement>(null)
const composerRef = useRef<NativeChatComposerHandle>(null)
const isMac = useMemo(() => isMacPlatform(), [])
const pasteClipboardIntoComposer = useCallback(() => {
void window.api.ui
.readClipboardText({ maxBytes: NATIVE_CHAT_CONTEXT_PASTE_MAX_BYTES })
.then((text) => {
if (text.length > 0) {
composerRef.current?.insertTypedText(text)
}
})
.catch(() => {})
}, [])
const contextMenu = useNativeChatContextMenu({
rootRef,
onSwitchToTerminal,
actions: {
onPaste: pasteClipboardIntoComposer,
...(contextMenuActions ?? emptyNativeChatContextMenuActions)
}
})
useEffect(() => {
const root = rootRef.current
if (!root) {
return
}
const onKeyPaste = (event: KeyboardEvent): void => {
if (
!matchesNativeChatPasteShortcut(event, isMac) ||
!shouldFocusNativeChatPaneFromPointerTarget(event.target)
) {
return
}
event.preventDefault()
event.stopPropagation()
pasteClipboardIntoComposer()
}
root.addEventListener('keydown', onKeyPaste, { capture: true })
return () => {
root.removeEventListener('keydown', onKeyPaste, { capture: true })
}
}, [isMac, pasteClipboardIntoComposer])
useEffect(() => {
const onAppMenuPaste = (event: Event): void => {
const root = rootRef.current
const activeElement = document.activeElement
if (
!root ||
!(activeElement instanceof Element) ||
!root.contains(activeElement) ||
!shouldFocusNativeChatPaneFromPointerTarget(activeElement)
) {
return
}
event.preventDefault()
event.stopPropagation()
pasteClipboardIntoComposer()
}
window.addEventListener(APP_MENU_PASTE_EVENT, onAppMenuPaste)
return () => {
window.removeEventListener(APP_MENU_PASTE_EVENT, onAppMenuPaste)
}
}, [pasteClipboardIntoComposer])
// Optimistic "queued" sends (mobile parity): a composer send is echoed
// immediately and pruned once its real user turn lands in the transcript, so
// the message never vanishes between send and transcript catch-up.
const commandMarkerScope = useMemo(
() => ({ paneKey, agent, sessionId }),
[paneKey, agent, sessionId]
)
const pendingScope = useMemo(() => ({ paneKey, agent }), [paneKey, agent])
const [pending, setPending] = useState<NativeChatPendingSend[]>(() =>
readPendingSendCache(pendingScope)
)
const pendingCounter = useRef(0)
// Slash commands aren't chat turns, so they get a small local "Ran /clear"
// system line instead of a user bubble. Capped + cached per conversation.
const [commandMarkers, setCommandMarkers] = useState<NativeChatCommandMarker[]>(() =>
readCommandMarkerCache(commandMarkerScope)
)
// Reset the optimistic queue only when the pane/agent changes. A fresh launch
// often learns its provider session id after the first send; clearing pending
// on that transition briefly flashes the empty state before the transcript
// user turn lands.
useEffect(() => {
setPending(readPendingSendCache(pendingScope))
setWorkingInterrupted(false)
}, [pendingScope])
// Command markers are session-scoped because slash commands like /clear are
// local feedback for a specific transcript boundary.
useEffect(() => {
setCommandMarkers(readCommandMarkerCache(commandMarkerScope))
setWorkingInterrupted(false)
}, [commandMarkerScope])
// Prune echoes whose real user turn is now in the transcript.
useEffect(() => {
setPending((prev) =>
writePendingSendCache(pendingScope, prunePendingSends(prev, session.messages))
)
}, [session.messages, pendingScope])
const onOptimisticSend = useCallback(
(text: string, imagePaths?: string[]) => {
setWorkingInterrupted(false)
pendingCounter.current += 1
const entry: NativeChatPendingSend = {
id: `${pendingCounter.current}`,
text,
sentAt: Date.now(),
...(imagePaths ? { imagePaths } : {})
}
setPending(appendPendingSendCache(pendingScope, entry))
},
[pendingScope]
)
const onSlashCommand = useCallback(
(command: string) => {
setCommandMarkers(appendCommandMarkerCache(commandMarkerScope, command))
},
[commandMarkerScope]
)
const sessionAfterCommandBoundaries = useMemo<typeof session>(() => {
const messages = applyCommandMarkerBoundaries(session.messages, commandMarkers)
return messages === session.messages ? session : { ...session, messages }
}, [session, commandMarkers])
// The streaming preview bubble (if any) sits after the transcript but before
// the optimistic user echoes — same order mobile uses.
const streamingText = useMemo(
() =>
deriveNativeChatStreamingText({
messages: sessionAfterCommandBoundaries.messages,
previewText: hookPreview,
working: hookWorking
}),
[sessionAfterCommandBoundaries.messages, hookPreview, hookWorking]
)
const sessionWithPending = useMemo<typeof session>(() => {
if (pending.length === 0 && commandMarkers.length === 0 && !streamingText) {
return sessionAfterCommandBoundaries
}
return {
...sessionAfterCommandBoundaries,
messages: [
...sessionAfterCommandBoundaries.messages,
...commandMarkersAsMessages(commandMarkers),
...(streamingText ? [nativeChatStreamingMessage(streamingText)] : []),
...pendingSendsAsMessages(pending, sessionAfterCommandBoundaries.messages)
]
}
}, [sessionAfterCommandBoundaries, pending, commandMarkers, streamingText])
// Derive the view state from the pending-augmented session so a send into an
// otherwise-empty conversation flips to the list (showing the queued bubble)
// instead of staying on the empty state.
const viewState = selectNativeChatViewState(sessionWithPending)
const isConversation = viewState.kind === 'ready'
// Drive "working" from the live hook state too: when toggling to chat while the
// agent is mid-turn, the merged transcript may not yet reflect the in-flight
// turn, but the hook already says 'working' — show the indicator immediately.
const viewWorking = viewState.kind === 'ready' && viewState.isWorking
useEffect(() => {
if (shouldClearNativeChatWorkingSuppression({ viewWorking, hookWorking })) {
setWorkingInterrupted(false)
}
}, [viewWorking, hookWorking])
const isWorking = shouldShowNativeChatWorking({
isConversation,
viewWorking,
hookWorking,
interrupted: workingInterrupted
})
const stopAgent = useCallback(() => {
setWorkingInterrupted(true)
interactiveSend.cancel()
}, [interactiveSend])
// Chat-only font zoom via Cmd/Ctrl +/-/0, gated to the live conversation so
// the chord is inert on the loading/empty/error states and elsewhere.
const fontScale = useNativeChatFontScale(isConversation)
return (
<div
ref={rootRef}
data-native-chat-root="true"
tabIndex={-1}
onPointerDownCapture={(event) => {
if (event.button === 2) {
contextMenu.onSelectionCapture()
event.preventDefault()
event.stopPropagation()
return
}
if (event.button === 0 && shouldFocusNativeChatPaneFromPointerTarget(event.target)) {
rootRef.current?.focus({ preventScroll: true })
}
}}
onKeyDownCapture={(event) => {
if (!shouldRedirectNativeChatTyping(event)) {
return
}
if (!composerRef.current?.insertTypedText(event.key)) {
return
}
event.preventDefault()
event.stopPropagation()
}}
onMouseUpCapture={contextMenu.onSelectionCapture}
onKeyUpCapture={contextMenu.onSelectionCapture}
onContextMenuCapture={contextMenu.onContextMenuCapture}
className="flex h-full min-h-0 w-full flex-col bg-background focus:outline-none"
>
<div className="flex min-h-0 flex-1 flex-col">
{viewState.kind === 'loading' ? (
<NativeChatEmptyState kind="loading" />
) : viewState.kind === 'error' ? (
<NativeChatEmptyState kind="error" message={viewState.message} />
) : viewState.kind === 'empty' ? (
<NativeChatEmptyState kind="empty" agent={agent} />
) : (
<NativeChatMessageList
session={sessionWithPending}
isWorking={isWorking}
expandSignal={false}
fontScale={fontScale.scale}
/>
)}
</div>
{/* Live interactive cards (question / approval) render just above the
composer while the agent's interactivePrompt is present (mobile parity). */}
<NativeChatInteractiveCard paneKey={paneKey} send={interactiveSend} canSend={canSend} />
{/* canSend reflects the mobile presence-lock: when a mobile client holds
the pty, the composer shows its guarded state instead of racing the
mobile driver (R8). */}
<NativeChatComposer
ref={composerRef}
terminalTabId={terminalTabId}
targetPtyId={targetPtyId}
agent={agent}
canSend={canSend}
isWorking={isWorking}
onStop={stopAgent}
onOptimisticSend={onOptimisticSend}
onSlashCommand={onSlashCommand}
/>
{contextMenu.menu}
</div>
)
}
function matchesNativeChatPasteShortcut(
event: Pick<KeyboardEvent, 'key' | 'metaKey' | 'ctrlKey' | 'altKey' | 'shiftKey'>,
isMac: boolean
): boolean {
if (event.altKey || event.shiftKey || event.key.toLowerCase() !== 'v') {
return false
}
return isMac ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey
}
@@ -0,0 +1,8 @@
// The per-agent slash-command catalog now lives in src/shared so the desktop
// renderer and the mobile app share one source of truth (no drift). This file
// re-exports it for existing desktop import sites.
export {
getAgentSlashCommands,
type SlashCommandSuggestion
} from '../../../../shared/native-chat-slash-commands'
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import { NATIVE_CHAT_SOURCE_PRIORITY, type NativeChatMessage } from '../../../../shared/native-chat-types'
import { mergeNativeChatMessagesWith } from '../../../../shared/native-chat-merge'
import { assembleNativeChatSession } from './native-chat-session-assembler'
// On single-source (pure transcript) data the desktop assembler's cross-source
// turnKey pass is a no-op, so it must agree with mobile's id-only merge on the
// final id set and order. This locks the "mobile is single-source, so id-only ≡
// assembler-with-same-source-gated-fallback" invariant from the design (#10).
function msg(
overrides: Partial<NativeChatMessage> & Pick<NativeChatMessage, 'id'>
): NativeChatMessage {
return {
role: 'assistant',
blocks: [{ type: 'text', text: overrides.id }],
timestamp: 0,
source: 'transcript',
...overrides
}
}
describe('assembler ↔ id-merge parity on single-source data', () => {
it('produces the same ids in the same order as the id-only merge', () => {
// Includes two identical-text same-source prompts (distinct ids): both must
// survive in both implementations.
const transcript: NativeChatMessage[] = [
msg({ id: 'u1', role: 'user', timestamp: 10, blocks: [{ type: 'text', text: 'run tests' }] }),
msg({ id: 'a1', timestamp: 20, blocks: [{ type: 'text', text: 'ok' }] }),
msg({ id: 'u2', role: 'user', timestamp: 30, blocks: [{ type: 'text', text: 'run tests' }] }),
msg({ id: 'a2', timestamp: 40, blocks: [{ type: 'text', text: 'done' }] })
]
const assembled = assembleNativeChatSession({
sources: { transcript },
sessionId: 's1',
agent: 'claude'
}).messages
const merged = mergeNativeChatMessagesWith([], transcript, NATIVE_CHAT_SOURCE_PRIORITY)
// The assembler sorts by (timestamp, id); the merge preserves arrival order.
// The fixture's arrival order already matches timestamp order, so ids align.
expect(assembled.map((m) => m.id)).toEqual(merged.map((m) => m.id))
expect(assembled).toHaveLength(transcript.length)
})
})
@@ -0,0 +1,44 @@
import { describe, it, expect } from 'vitest'
import {
distanceFromBottom,
isNearBottom,
shouldShowJumpToLatest,
NATIVE_CHAT_BOTTOM_THRESHOLD_PX
} from './native-chat-autoscroll'
const atBottom = { scrollTop: 952, scrollHeight: 1000, clientHeight: 48 }
const scrolledUp = { scrollTop: 0, scrollHeight: 1000, clientHeight: 48 }
const noOverflow = { scrollTop: 0, scrollHeight: 48, clientHeight: 48 }
describe('distanceFromBottom', () => {
it('is zero at the exact bottom and never negative', () => {
expect(distanceFromBottom(atBottom)).toBe(0)
expect(distanceFromBottom({ scrollTop: 5000, scrollHeight: 1000, clientHeight: 48 })).toBe(0)
})
})
describe('isNearBottom', () => {
it('sticks within the threshold and detaches beyond it', () => {
expect(isNearBottom(atBottom)).toBe(true)
expect(
isNearBottom({
scrollTop: 952 - NATIVE_CHAT_BOTTOM_THRESHOLD_PX,
scrollHeight: 1000,
clientHeight: 48
})
).toBe(true)
expect(isNearBottom(scrolledUp)).toBe(false)
})
})
describe('shouldShowJumpToLatest', () => {
it('shows only when detached with content below', () => {
expect(shouldShowJumpToLatest(false, scrolledUp)).toBe(true)
})
it('hides while stuck to bottom', () => {
expect(shouldShowJumpToLatest(true, scrolledUp)).toBe(false)
})
it('hides when there is nothing to scroll', () => {
expect(shouldShowJumpToLatest(false, noOverflow)).toBe(false)
})
})
@@ -0,0 +1,44 @@
// Pure auto-scroll logic for the native chat message list. The component owns
// the DOM ref and the imperative scroll; this module owns only the decisions —
// "are we near the bottom?", "should we stick on new content?", "show the jump
// affordance?" — so they can be unit-tested without a scroll container.
/** A scroll container's geometry. Mirrors the three DOM props we read so tests
* can pass plain numbers instead of a fake element. */
export type ScrollGeometry = {
scrollTop: number
scrollHeight: number
clientHeight: number
}
/** Pixels from the bottom within which we treat the view as "at the bottom" and
* keep it pinned as content arrives. A small slack absorbs sub-pixel rounding
* and the height jitter of a streaming last message. */
export const NATIVE_CHAT_BOTTOM_THRESHOLD_PX = 48
/** Distance in px from the bottom edge of the scroll range. */
export function distanceFromBottom(geometry: ScrollGeometry): number {
return Math.max(0, geometry.scrollHeight - geometry.clientHeight - geometry.scrollTop)
}
/** True when the viewport is close enough to the bottom that new content should
* keep it pinned (auto-scroll "attached"). */
export function isNearBottom(
geometry: ScrollGeometry,
threshold: number = NATIVE_CHAT_BOTTOM_THRESHOLD_PX
): boolean {
return distanceFromBottom(geometry) <= threshold
}
/** Whether the "jump to latest" affordance should show: only when the user has
* detached (scrolled up) and there is actually scrollable content below. */
export function shouldShowJumpToLatest(
isStuckToBottom: boolean,
geometry: ScrollGeometry,
threshold: number = NATIVE_CHAT_BOTTOM_THRESHOLD_PX
): boolean {
if (isStuckToBottom) {
return false
}
return distanceFromBottom(geometry) > threshold
}
@@ -0,0 +1,95 @@
import { describe, it, expect } from 'vitest'
import { canToggleNativeChat } from './native-chat-availability'
describe('canToggleNativeChat', () => {
it('allows a terminal launched with a coding agent', () => {
expect(
canToggleNativeChat({
experimentalNativeChatEnabled: true,
contentType: 'terminal',
launchAgent: 'claude'
})
).toBe(true)
})
it('allows a terminal with a live detected agent but no launchAgent', () => {
expect(
canToggleNativeChat({
experimentalNativeChatEnabled: true,
contentType: 'terminal',
launchAgent: null,
hasDetectedAgent: true
})
).toBe(true)
})
it('allows a terminal with a resolved title/foreground agent before hooks arrive', () => {
expect(
canToggleNativeChat({
experimentalNativeChatEnabled: true,
contentType: 'terminal',
launchAgent: null,
hasResolvedAgent: true
})
).toBe(true)
})
it('allows an existing chat view to toggle back after live signals are gone', () => {
expect(
canToggleNativeChat({
experimentalNativeChatEnabled: true,
contentType: 'terminal',
launchAgent: null,
isChatViewMode: true
})
).toBe(true)
})
it('rejects otherwise eligible terminals while the experimental flag is off', () => {
expect(
canToggleNativeChat({
experimentalNativeChatEnabled: false,
contentType: 'terminal',
launchAgent: 'claude'
})
).toBe(false)
})
it('rejects a plain shell terminal with no agent', () => {
expect(
canToggleNativeChat({
experimentalNativeChatEnabled: true,
contentType: 'terminal',
launchAgent: null,
hasDetectedAgent: false
})
).toBe(false)
})
it('rejects a plain shell terminal with everything omitted', () => {
expect(
canToggleNativeChat({ experimentalNativeChatEnabled: true, contentType: 'terminal' })
).toBe(false)
})
it('rejects an editor tab even if an agent hint were somehow present', () => {
expect(
canToggleNativeChat({
experimentalNativeChatEnabled: true,
contentType: 'editor',
launchAgent: 'codex',
hasDetectedAgent: true
})
).toBe(false)
})
it('rejects a browser tab', () => {
expect(
canToggleNativeChat({
experimentalNativeChatEnabled: true,
contentType: 'browser',
hasDetectedAgent: true
})
).toBe(false)
})
})
@@ -0,0 +1,43 @@
import type { Tab, TuiAgent } from '../../../../shared/types'
/** Inputs that decide whether a tab may toggle into the native chat view.
* Kept as a plain shape (not the live store) so the decision stays pure and
* unit-testable; call sites resolve `launchAgent`/`hasDetectedAgent` from the
* terminal tab + agent-status before calling. */
export type NativeChatAvailabilityInput = {
/** Feature flag: hidden unless enabled from Settings > Experimental. */
experimentalNativeChatEnabled?: boolean
contentType: Tab['contentType']
/** The coding-agent Orca launched in this terminal, if any (from TerminalTab). */
launchAgent?: TuiAgent | null
/** True when a live agent-status entry exists for any pane of this tab — i.e.
* an agent was detected at runtime even though `launchAgent` was not set
* (manually-started agents, resumed sessions). */
hasDetectedAgent?: boolean
/** True when another trusted tab signal (for example the terminal title
* resolver) identifies the foreground as an agent before hooks arrive. */
hasResolvedAgent?: boolean
/** Already-chat tabs must always be allowed to toggle back to terminal, even
* if live hook state was lost during a dev/app restart. */
isChatViewMode?: boolean
}
/** Native chat is a rendering of a coding-agent conversation, so the toggle is
* only meaningful on terminals that actually run an agent. Plain shells and
* non-terminal surfaces (editor, browser, …) never qualify. Eligibility is the
* union of the launch-time hint and live detection so the control appears both
* for Orca-launched agents and for agents the user started themselves. */
export function canToggleNativeChat(input: NativeChatAvailabilityInput): boolean {
if (input.experimentalNativeChatEnabled !== true) {
return false
}
if (input.contentType !== 'terminal') {
return false
}
return (
input.isChatViewMode === true ||
Boolean(input.launchAgent) ||
input.hasDetectedAgent === true ||
input.hasResolvedAgent === true
)
}
@@ -0,0 +1,213 @@
import { describe, expect, it } from 'vitest'
import {
applyMentionSuggestion,
applySkillSuggestion,
applySlashSuggestion,
deriveComposerAutocomplete,
EMPTY_HISTORY,
filterSkillSuggestions,
filterSlashCommands,
isSlashCommandDraft,
pushHistory,
recallNext,
recallPrevious,
slashCommandDispatchText,
type SlashCommandSuggestion
} from './native-chat-composer-state'
import type { DiscoveredSkill } from '../../../../shared/skills'
const COMMANDS: SlashCommandSuggestion[] = [
{ name: 'clear' },
{ name: 'compact' },
{ name: 'help' }
]
function skill(overrides: Partial<DiscoveredSkill>): DiscoveredSkill {
return {
id: overrides.name ?? 'skill',
name: 'typescript',
description: null,
providers: ['codex'],
sourceKind: 'repo',
sourceLabel: 'Repository',
rootPath: '/repo/.agents/skills',
directoryPath: '/repo/.agents/skills/typescript',
skillFilePath: '/repo/.agents/skills/typescript/SKILL.md',
installed: true,
fileCount: 1,
updatedAt: null,
...overrides
}
}
describe('deriveComposerAutocomplete — slash', () => {
it('enters slash mode for `/` at the start and filters by query', () => {
const result = deriveComposerAutocomplete('/cl', 3, COMMANDS)
expect(result.mode).toBe('slash')
if (result.mode !== 'slash') {
return
}
expect(result.query).toBe('cl')
expect(result.suggestions.map((c) => c.name)).toEqual(['clear'])
})
it('a bare `/` returns the full command list', () => {
const result = deriveComposerAutocomplete('/', 1, COMMANDS)
expect(result.mode).toBe('slash')
if (result.mode !== 'slash') {
return
}
expect(result.suggestions).toHaveLength(3)
})
it('does not fire slash mode after a space', () => {
expect(deriveComposerAutocomplete('/clear now', 10, COMMANDS).mode).toBe('none')
})
it('does not fire slash mode mid-line', () => {
expect(deriveComposerAutocomplete('hi /clear', 9, COMMANDS).mode).toBe('none')
})
})
describe('deriveComposerAutocomplete — mention', () => {
it('enters mention mode with the query after `@`', () => {
const result = deriveComposerAutocomplete('look at @src/ind', 16, COMMANDS)
expect(result.mode).toBe('mention')
if (result.mode !== 'mention') {
return
}
expect(result.query).toBe('src/ind')
})
it('fires at the start of input too', () => {
const result = deriveComposerAutocomplete('@foo', 4, COMMANDS)
expect(result.mode).toBe('mention')
if (result.mode !== 'mention') {
return
}
expect(result.query).toBe('foo')
})
it('does not fire for an email-like `@` (no preceding whitespace)', () => {
expect(deriveComposerAutocomplete('me@example', 10, COMMANDS).mode).toBe('none')
})
})
describe('deriveComposerAutocomplete — skill', () => {
const skills = [
skill({ name: 'typescript' }),
skill({ name: 'react-useeffect', directoryPath: '/repo/.agents/skills/react-useeffect' })
]
it('enters skill mode with the query after `$`', () => {
const result = deriveComposerAutocomplete('use $type', 9, COMMANDS, skills)
expect(result.mode).toBe('skill')
if (result.mode !== 'skill') {
return
}
expect(result.query).toBe('type')
expect(result.suggestions.map((entry) => entry.name)).toEqual(['typescript'])
})
it('fires at the start of input too', () => {
expect(deriveComposerAutocomplete('$react', 6, COMMANDS, skills).mode).toBe('skill')
})
it('does not fire inside shell-style text', () => {
expect(deriveComposerAutocomplete('price$tag', 9, COMMANDS, skills).mode).toBe('none')
})
})
describe('filterSlashCommands', () => {
it('is case-insensitive prefix match', () => {
expect(filterSlashCommands(COMMANDS, 'C').map((c) => c.name)).toEqual(['clear', 'compact'])
})
})
describe('isSlashCommandDraft', () => {
it('treats leading slash drafts as TUI commands, not chat prompts', () => {
expect(isSlashCommandDraft('/clear')).toBe(true)
expect(isSlashCommandDraft(' /compact')).toBe(true)
expect(isSlashCommandDraft('please run /clear')).toBe(false)
})
})
describe('filterSkillSuggestions', () => {
it('filters installed skills by name or directory basename', () => {
const skills = [
skill({ name: 'TypeScript' }),
skill({ name: 'Display Name', directoryPath: '/repo/.agents/skills/ref-oss' }),
skill({ name: 'hidden', installed: false })
]
expect(filterSkillSuggestions(skills, 'ref').map((entry) => entry.name)).toEqual([
'Display Name'
])
expect(filterSkillSuggestions(skills, 'h')).toEqual([])
})
})
describe('history recall', () => {
it('up-arrow on empty composer recalls the last sent input', () => {
const history = pushHistory(EMPTY_HISTORY, 'first')
const recall = recallPrevious(history)
expect(recall.draft).toBe('first')
expect(recall.history.index).toBe(0)
})
it('walks backward and clamps at the oldest entry', () => {
let history = pushHistory(EMPTY_HISTORY, 'a')
history = pushHistory(history, 'b')
const first = recallPrevious(history)
expect(first.draft).toBe('b')
const second = recallPrevious(first.history)
expect(second.draft).toBe('a')
const third = recallPrevious(second.history)
expect(third.draft).toBe('a') // clamped
})
it('down-arrow walks forward and returns to a live empty draft', () => {
let history = pushHistory(EMPTY_HISTORY, 'a')
history = pushHistory(history, 'b')
const up1 = recallPrevious(history) // 'b'
const up2 = recallPrevious(up1.history) // 'a'
const down = recallNext(up2.history) // 'b'
expect(down.draft).toBe('b')
const back = recallNext(down.history) // live
expect(back.draft).toBe('')
expect(back.history.index).toBeNull()
})
it('does not record blank sends or immediate duplicates', () => {
let history = pushHistory(EMPTY_HISTORY, ' ')
expect(history.entries).toHaveLength(0)
history = pushHistory(history, 'x')
history = pushHistory(history, 'x')
expect(history.entries).toHaveLength(1)
})
it('recall on empty history is a no-op', () => {
expect(recallPrevious(EMPTY_HISTORY).draft).toBeNull()
})
})
describe('apply suggestions', () => {
it('applySlashSuggestion replaces the token with a trailing space', () => {
expect(applySlashSuggestion({ name: 'clear' })).toBe('/clear ')
})
it('slashCommandDispatchText returns the command without completion whitespace', () => {
expect(slashCommandDispatchText({ name: 'clear' })).toBe('/clear')
})
it('applyMentionSuggestion replaces the active @token at the caret', () => {
const result = applyMentionSuggestion('open @sr more', 8, 'src/app.ts')
expect(result.draft).toBe('open @src/app.ts more')
expect(result.caret).toBe('open @src/app.ts '.length)
})
it('applySkillSuggestion replaces the active $token at the caret', () => {
const result = applySkillSuggestion('use $typ now', 8, 'typescript')
expect(result.draft).toBe('use $typescript now')
expect(result.caret).toBe('use $typescript '.length)
})
})
@@ -0,0 +1,187 @@
// Pure state machine for the native chat composer. Given the current draft,
// caret position, sent-input history, and the active agent's known slash
// commands/skills, it derives the autocomplete mode, the
// query, and the filtered suggestions. Keeping this DOM-free makes the slash /
// mention / skill / history behavior unit-testable; the .tsx only owns rendering and
// the actual textarea/caret wiring.
import type { DiscoveredSkill } from '../../../../shared/skills'
import {
filterSlashCommands,
isSlashCommandDraft,
applySlashSuggestion,
slashCommandDispatchText,
type SlashCommandSuggestion
} from '../../../../shared/native-chat-slash-commands'
export type { SlashCommandSuggestion }
export { filterSlashCommands, isSlashCommandDraft, applySlashSuggestion, slashCommandDispatchText }
export type ComposerAutocompleteMode = 'none' | 'slash' | 'mention' | 'skill'
export type ComposerAutocomplete =
| { mode: 'none' }
| { mode: 'slash'; query: string; suggestions: SlashCommandSuggestion[] }
| { mode: 'mention'; query: string }
| { mode: 'skill'; query: string; suggestions: DiscoveredSkill[] }
export type ComposerDerivation = {
autocomplete: ComposerAutocomplete
}
/**
* Detect the active autocomplete trigger from the text before the caret.
*
* Rules (intentionally conservative to avoid firing inside ordinary prose):
* - Slash: the draft starts with `/` and the caret is within that first token
* (no whitespace between `/` and the caret). Agent slash commands are only
* valid at the very start of the input, mirroring how the TUIs accept them.
* - Mention: an `@` immediately preceded by start-of-input or whitespace, with
* no whitespace between it and the caret. Works anywhere in the line so you
* can reference a file mid-sentence.
*/
export function deriveComposerAutocomplete(
draft: string,
caret: number,
agentCommands: readonly SlashCommandSuggestion[],
skills: readonly DiscoveredSkill[] = []
): ComposerAutocomplete {
const before = draft.slice(0, caret)
// Slash: only at the absolute start of the input, and only while the caret is
// still inside the unbroken command token.
if (before.startsWith('/') && !/\s/.test(before)) {
const query = before.slice(1)
return { mode: 'slash', query, suggestions: filterSlashCommands(agentCommands, query) }
}
const mentionMatch = before.match(/(?:^|\s)@(\S*)$/)
if (mentionMatch) {
return { mode: 'mention', query: mentionMatch[1] }
}
const skillMatch = before.match(/(?:^|\s)\$(\S*)$/)
if (skillMatch) {
const query = skillMatch[1]
return { mode: 'skill', query, suggestions: filterSkillSuggestions(skills, query) }
}
return { mode: 'none' }
}
export function filterSkillSuggestions(
skills: readonly DiscoveredSkill[],
query: string
): DiscoveredSkill[] {
const normalized = query.toLowerCase()
const installed = skills.filter((skill) => skill.installed)
if (normalized === '') {
return installed.slice(0, 12)
}
return installed
.filter((skill) => {
const name = skill.name.toLowerCase()
const dirName = skill.directoryPath.split(/[\\/]/).filter(Boolean).at(-1)?.toLowerCase()
return name.startsWith(normalized) || dirName?.startsWith(normalized)
})
.slice(0, 12)
}
export type HistoryState = {
/** Most-recent-last list of previously sent drafts. */
entries: readonly string[]
/** Cursor into `entries`; null means "live draft" (not recalling). */
index: number | null
}
export const EMPTY_HISTORY: HistoryState = { entries: [], index: null }
/** Append a sent draft to history and reset the recall cursor. Blank sends and
* immediate duplicates of the last entry are not recorded (shell-style). */
export function pushHistory(history: HistoryState, sent: string): HistoryState {
if (sent.trim() === '') {
return { entries: history.entries, index: null }
}
if (history.entries.at(-1) === sent) {
return { entries: history.entries, index: null }
}
return { entries: [...history.entries, sent], index: null }
}
export type HistoryRecall = {
history: HistoryState
/** The draft text to show, or null to leave the live draft untouched. */
draft: string | null
}
/**
* Move one step toward older entries (Up arrow). From the live draft this jumps
* to the most recent entry; thereafter it walks backward and clamps at the
* oldest. Returns the recalled draft, or null when there is nothing to recall.
*/
export function recallPrevious(history: HistoryState): HistoryRecall {
if (history.entries.length === 0) {
return { history, draft: null }
}
const nextIndex =
history.index === null ? history.entries.length - 1 : Math.max(0, history.index - 1)
return {
history: { entries: history.entries, index: nextIndex },
draft: history.entries[nextIndex]
}
}
/**
* Move one step toward newer entries (Down arrow). Walking past the newest entry
* returns to the live (empty) draft and clears the recall cursor. Returns null
* draft when not currently recalling.
*/
export function recallNext(history: HistoryState): HistoryRecall {
if (history.index === null) {
return { history, draft: null }
}
const nextIndex = history.index + 1
if (nextIndex >= history.entries.length) {
return { history: { entries: history.entries, index: null }, draft: '' }
}
return {
history: { entries: history.entries, index: nextIndex },
draft: history.entries[nextIndex]
}
}
/** Replace the active `@query` token before the caret with the chosen path.
* Returns the new full draft and the caret offset to place after insertion. */
export function applyMentionSuggestion(
draft: string,
caret: number,
path: string
): { draft: string; caret: number } {
const before = draft.slice(0, caret)
const after = draft.slice(caret)
const match = before.match(/(^|\s)@(\S*)$/)
if (!match) {
return { draft, caret }
}
const tokenStart = before.length - match[2].length - 1 // -1 for the '@'
const insertion = `@${path} `
const nextBefore = before.slice(0, tokenStart) + insertion
return { draft: nextBefore + after, caret: nextBefore.length }
}
export function applySkillSuggestion(
draft: string,
caret: number,
skillName: string
): { draft: string; caret: number } {
const before = draft.slice(0, caret)
const after = draft.slice(caret)
const match = before.match(/(^|\s)\$(\S*)$/)
if (!match) {
return { draft, caret }
}
const tokenStart = before.length - match[2].length - 1 // -1 for the '$'
const insertion = `$${skillName} `
const nextBefore = before.slice(0, tokenStart) + insertion
return { draft: nextBefore + after, caret: nextBefore.length }
}
@@ -0,0 +1,30 @@
import { translate } from '@/i18n/i18n'
import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection'
import type { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft'
export type NativeChatResolvedTarget = {
ptyId: string
settings: ReturnType<typeof getSettingsForAgentTabRuntimeOwner>
}
export function nativeChatComposerPlaceholder(hasPty: boolean, canSend: boolean): string {
if (!hasPty) {
return translate(
'components.native-chat.composer.noPty',
'No live terminal — toggle back to reconnect.'
)
}
if (!canSend) {
return translate('components.native-chat.composer.locked', 'Input is held by another device.')
}
return translate('components.native-chat.composer.placeholder', 'Send a message…')
}
export function nativeChatComposerTargetIsRemote(ptyId: string | null): boolean {
return ptyId !== null && isRemoteRuntimePtyId(ptyId)
}
export function formatNativeChatFileReference(filePath: string): string {
const escaped = filePath.replace(/"/g, '\\"')
return /\s/.test(filePath) ? `@"${escaped}"` : `@${filePath}`
}
@@ -0,0 +1,63 @@
import { describe, it, expect } from 'vitest'
import { diffFromText, diffFromToolCall } from './native-chat-diff'
describe('diffFromToolCall', () => {
it('returns null for non-edit tools', () => {
expect(diffFromToolCall('Bash', { command: 'ls' })).toBeNull()
})
it('builds del/add lines from old_string/new_string', () => {
const diff = diffFromToolCall('Edit', {
file_path: '/app.ts',
old_string: 'a\nb',
new_string: 'a\nc'
})
expect(diff).toEqual([
{ kind: 'meta', text: '/app.ts' },
{ kind: 'del', text: 'a' },
{ kind: 'del', text: 'b' },
{ kind: 'add', text: 'a' },
{ kind: 'add', text: 'c' }
])
})
it('reads Write content as adds', () => {
const diff = diffFromToolCall('Write', { path: '/new.ts', content: 'line1\nline2' })
expect(diff).toEqual([
{ kind: 'meta', text: '/new.ts' },
{ kind: 'add', text: 'line1' },
{ kind: 'add', text: 'line2' }
])
})
it('returns null when there is no old/new payload', () => {
expect(diffFromToolCall('Edit', { file_path: '/x' })).toBeNull()
})
})
describe('diffFromText', () => {
it('parses unified-diff text into coloured lines', () => {
const diff = diffFromText('@@ -1,2 +1,2 @@\n context\n-old\n+new')
expect(diff).toEqual([
{ kind: 'meta', text: '@@ -1,2 +1,2 @@' },
{ kind: 'context', text: ' context' },
{ kind: 'del', text: 'old' },
{ kind: 'add', text: 'new' }
])
})
it('returns null when there is not enough diff signal', () => {
expect(diffFromText('just a sentence with - a dash')).toBeNull()
expect(diffFromText('+only one add line')).toBeNull()
})
it('returns null for empty input', () => {
expect(diffFromText('')).toBeNull()
})
it('ignores +++/--- file headers as add/del', () => {
const diff = diffFromText('--- a/x\n+++ b/x\n-old\n+new')
expect(diff?.filter((l) => l.kind === 'add').map((l) => l.text)).toEqual(['new'])
expect(diff?.filter((l) => l.kind === 'del').map((l) => l.text)).toEqual(['old'])
})
})
@@ -0,0 +1,70 @@
// Why: agent edits show up as tool calls (Edit/Write with old/new strings) and
// tool results that contain unified-diff text. The chat renders these as inline
// coloured diffs like the terminal, so detection/parsing is pure and testable.
// Ported from the mobile diffFromToolCall/diffFromText (desktop parity).
export type DiffLineKind = 'add' | 'del' | 'context' | 'meta'
export type DiffLine = {
kind: DiffLineKind
text: string
}
const EDIT_TOOL_NAMES = new Set(['Edit', 'MultiEdit', 'Write', 'str_replace', 'apply_patch'])
function toLines(value: unknown): string[] {
return typeof value === 'string' ? value.replace(/\n$/, '').split('\n') : []
}
/** Build diff lines from an Edit-style tool call input (old_string/new_string),
* or null when the input isn't an editing payload. Old lines render as deletes,
* new lines as adds — a simple, readable before/after rather than a full LCS. */
export function diffFromToolCall(name: string, input: unknown): DiffLine[] | null {
if (!EDIT_TOOL_NAMES.has(name) || typeof input !== 'object' || input === null) {
return null
}
const obj = input as Record<string, unknown>
const oldText = obj.old_string ?? obj.oldString ?? obj.old
const newText = obj.new_string ?? obj.newString ?? obj.new ?? obj.content ?? obj.file_text
const dels = toLines(oldText).map((text): DiffLine => ({ kind: 'del', text }))
const adds = toLines(newText).map((text): DiffLine => ({ kind: 'add', text }))
if (dels.length === 0 && adds.length === 0) {
return null
}
const lines: DiffLine[] = []
if (typeof obj.file_path === 'string' || typeof obj.path === 'string') {
lines.push({ kind: 'meta', text: String(obj.file_path ?? obj.path) })
}
return [...lines, ...dels, ...adds]
}
/** Parse unified-diff-looking text into coloured lines, or null when the text
* doesn't read as a diff (no +/- lines). */
export function diffFromText(text: string): DiffLine[] | null {
if (typeof text !== 'string' || text.length === 0) {
return null
}
const raw = text.split('\n')
let added = 0
let removed = 0
const lines: DiffLine[] = raw.map((line): DiffLine => {
if (line.startsWith('@@') || line.startsWith('diff ') || line.startsWith('index ')) {
return { kind: 'meta', text: line }
}
if (line.startsWith('+') && !line.startsWith('+++')) {
added++
return { kind: 'add', text: line.slice(1) }
}
if (line.startsWith('-') && !line.startsWith('---')) {
removed++
return { kind: 'del', text: line.slice(1) }
}
return { kind: 'context', text: line }
})
// Require a meaningful amount of diff signal so ordinary prose isn't mistaken
// for a diff (a stray leading '-' bullet shouldn't trigger diff rendering).
if (added + removed < 2) {
return null
}
return lines
}
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest'
import { nativeChatCardDismissKey } from './native-chat-dismiss-key'
describe('nativeChatCardDismissKey', () => {
it('returns null for no card', () => {
expect(nativeChatCardDismissKey(null)).toBeNull()
})
it('keys a question by its count and first question text', () => {
const key = nativeChatCardDismissKey({
kind: 'question',
prompt: {
questions: [
{ question: 'Pick a color', multiSelect: false, options: [{ label: 'Red' }] },
{ question: 'Pick a size', multiSelect: false, options: [{ label: 'L' }] }
]
}
})
expect(key).toBe('question:2:Pick a color')
})
it('gives identical questions the same key (so a lingering re-emit stays hidden)', () => {
const make = (): ReturnType<typeof nativeChatCardDismissKey> =>
nativeChatCardDismissKey({
kind: 'question',
prompt: { questions: [{ question: 'Continue?', multiSelect: false, options: [] }] }
})
expect(make()).toBe(make())
})
it('keys an approval by its title and detail', () => {
const key = nativeChatCardDismissKey({
kind: 'approval',
approval: {
title: 'Allow Bash?',
detail: 'rm -rf build',
options: [{ label: 'Allow', send: '1' }]
}
})
expect(key).toBe('approval:Allow Bash?:rm -rf build')
})
it('distinguishes different approvals', () => {
const a = nativeChatCardDismissKey({
kind: 'approval',
approval: { title: 'Allow Bash?', options: [] }
})
const b = nativeChatCardDismissKey({
kind: 'approval',
approval: { title: 'Allow Write?', options: [] }
})
expect(a).not.toBe(b)
})
})

Some files were not shown because too many files have changed in this diff Show More