mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 16:03:26 +00:00
Merge branch 'main' of https://github.com/stablyai/orca into investigate-scrollback
This commit is contained in:
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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() })
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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--
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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' })
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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() })
|
||||
}
|
||||
})
|
||||
]
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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' })
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user