Merge remote-tracking branch 'origin/main' into brennanb2025/ctrl-c-agent-status

# Conflicts:
#	src/main/agent-hooks/server.test.ts
#	src/main/agent-hooks/server.ts
#	src/main/ipc/pty.ts
#	src/renderer/src/components/terminal-pane/pty-connection.ts
#	src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts
This commit is contained in:
brennanb2025
2026-05-19 01:21:43 -07:00
126 changed files with 9531 additions and 1244 deletions
+26
View File
@@ -1418,48 +1418,56 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-arm64-musl@0.47.0':
resolution: {integrity: sha512-IxtQC/sbBi4ubbY+MdwdanRWrG9InQJVZqyMsBa5IUaQcnSg86gQme574HxXMC1p4bo4YhV99zQ+wNnGCvEgzw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-ppc64-gnu@0.47.0':
resolution: {integrity: sha512-EWXEhOMbWO0q6eJSbu0QLkU8cKi0ljlYLngeDs2Ocu/pm1rrLwyQiYzlFbdnMRURI4w9ndr1sI9rSbhlJ5o23Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-gnu@0.47.0':
resolution: {integrity: sha512-tZrjS11TUiDuEpRaqdk8K9F9xETRyKXfuZKmdeW+Gj7coBnm7+8sBEfyt033EAFEQSlkniAXvBLh+Qja2ioGBQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-musl@0.47.0':
resolution: {integrity: sha512-KBFy+2CFKUCZzYwX2ZOPQKck1vjQbz+hextuc19G4r0WRJwadfAeuQMQRQvB+Ivc8brlbOVg7et8K7E467440g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-s390x-gnu@0.47.0':
resolution: {integrity: sha512-REUPFKVGSiK99B+9eaPhluEVglzaoj/SMykNC5SUiV2RSsBfV5lWN7Y0iCIc251Wz3GaeAGZsJ/zj3gjarxdFg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-gnu@0.47.0':
resolution: {integrity: sha512-KVftVSVEDeIfRW3TIeLe3aNI/iY4m1fu5mDwHcisKMZSCMKLkrhFsjowC7o9RoqNPxbbglm2+/6KAKBIts2t0Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-musl@0.47.0':
resolution: {integrity: sha512-DTsmGEaA2860Aq5VUyDO8/MT9NFxwVL93RnRYmpMwK6DsSkThmvEpqoUDDljziEpAedMRG19SCogrNbINSbLUQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxfmt/binding-openharmony-arm64@0.47.0':
resolution: {integrity: sha512-8r5BDro7fLOBoq1JXHLVSs55OlrxQhEso4HVo0TcY7OXJUPYfjPoOaYL5us+yIwqyP9rQwN+rxuiNFSmaxSuOQ==}
@@ -1532,48 +1540,56 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-arm64-musl@1.63.0':
resolution: {integrity: sha512-Q5mmZy/XWjuYFUuQyYjOvZ5U/JkKEwnpir6hGxhh6HcdP0V/BKxLo8dqkfF/t7r7AguB17dfS/8+go5AQDRR6g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-ppc64-gnu@1.63.0':
resolution: {integrity: sha512-uBGtuZ0TzLB4x5wVa82HGNvYqY8buwDhyCnCP0R0gkk9szqVsP0MeTtD5HX7EsEuFIt+aYmYxuxeVxs3nTSwtQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-gnu@1.63.0':
resolution: {integrity: sha512-h4s6FwxE+9MeA181o0dnDwHP32Y/bG8EiB/vrD6Ib+AMt6haigDc/0bUtI/sLmQDBMJnUfaCmtSSrEAqjtEVrA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-musl@1.63.0':
resolution: {integrity: sha512-2EaNcCBR8Mcjl5ARtuN3BdEpVkX7KpjSjMGZ/mJMIeaXgTtdz5ytg2VwygMSStA/k0ixfvZFoZOfjDEcouV5vQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-s390x-gnu@1.63.0':
resolution: {integrity: sha512-p4hlf/fd7TrYYl3QrWWD0GocqJefwMu3cHQhmi2FvEB/YOvFb5DZN3SMBaPi7B1TM5DeypkEtrVib674q1KKPg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-gnu@1.63.0':
resolution: {integrity: sha512-Vgq9rkRVcPcjbcH+ihYTfpeR7vCXfqpd+z5ItTGc0yYUV59L5ceHYN1iV4H9bKGV7Rn5hkVc7x3mSvHegduENA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-musl@1.63.0':
resolution: {integrity: sha512-3/Lkq/ncooA61rorrC+ZQed1Bc4VpGj+WnGsp58zmxKgvZ2vhreu+dcVyr3mX8NUpq7mfZ4gDDTou/yrF1Pd7A==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxlint/binding-openharmony-arm64@1.63.0':
resolution: {integrity: sha512-0/EdD/6hDkx5Mfd769PTjvEM8mZ/6Dfukp1dBCL/2PjlIVGEtYdNZyok6ChqYPsT9JcFnlQnUeQzO0/1L/oC9w==}
@@ -2035,36 +2051,42 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.0.0':
resolution: {integrity: sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rolldown/binding-linux-ppc64-gnu@1.0.0':
resolution: {integrity: sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.0.0':
resolution: {integrity: sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.0.0':
resolution: {integrity: sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.0.0':
resolution: {integrity: sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@rolldown/binding-openharmony-arm64@1.0.0':
resolution: {integrity: sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==}
@@ -4238,24 +4260,28 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "orca",
"version": "1.4.7-rc.2",
"version": "1.4.9-rc.0",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
+16 -5
View File
@@ -3237,8 +3237,8 @@ packages:
brace-expansion@2.1.0:
resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==}
brace-expansion@5.0.5:
resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==}
brace-expansion@5.0.6:
resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==}
engines: {node: 18 || 20 || >=22}
braces@3.0.3:
@@ -4125,6 +4125,10 @@ packages:
resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==}
engines: {node: '>=14.14'}
fs-extra@11.3.5:
resolution: {integrity: sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==}
engines: {node: '>=14.14'}
fs-extra@7.0.1:
resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==}
engines: {node: '>=6 <7 || >=8'}
@@ -6811,7 +6815,7 @@ snapshots:
dependencies:
cross-dirname: 0.1.0
debug: 4.4.3
fs-extra: 11.3.4
fs-extra: 11.3.5
minimist: 1.2.8
postject: 1.0.0-alpha.6
transitivePeerDependencies:
@@ -9165,7 +9169,7 @@ snapshots:
dependencies:
balanced-match: 1.0.2
brace-expansion@5.0.5:
brace-expansion@5.0.6:
dependencies:
balanced-match: 4.0.4
@@ -10181,6 +10185,13 @@ snapshots:
jsonfile: 6.2.1
universalify: 2.0.1
fs-extra@11.3.5:
dependencies:
graceful-fs: 4.2.11
jsonfile: 6.2.1
universalify: 2.0.1
optional: true
fs-extra@7.0.1:
dependencies:
graceful-fs: 4.2.11
@@ -11277,7 +11288,7 @@ snapshots:
minimatch@10.2.5:
dependencies:
brace-expansion: 5.0.5
brace-expansion: 5.0.6
minimatch@3.1.5:
dependencies:
+26 -2
View File
@@ -2,8 +2,9 @@
name: orca-cli
description: >-
Use the `orca` CLI to drive a running Orca editor — manage Orca worktrees;
create, read, and run shell commands in Orca-managed terminals; and automate
Orca's built-in browser (snapshot/click/fill/screenshot/tabs). Use this
create and manage scheduled automations; create, read, and run shell commands
in Orca-managed terminals; and automate Orca's built-in browser
(snapshot/click/fill/screenshot/tabs). Use this
instead of raw `git worktree`, ad hoc shell PTYs, or Playwright whenever the
task touches Orca state. Coding agents inside an Orca worktree should also use
it to keep the worktree comment fresh at meaningful checkpoints. Boundary with
@@ -27,6 +28,7 @@ Use `orca` for:
- updating the current worktree comment with meaningful progress checkpoints
- reading Orca-managed terminals and sending input to non-agent terminals
- stopping or waiting on Orca-managed terminals
- creating and managing scheduled Orca automations
- accessing repos known to Orca
Do not use `orca` when plain shell tools are simpler and Orca state does not matter.
@@ -36,6 +38,7 @@ Examples:
- updating the current worktree comment after a significant checkpoint, such as reproducing a bug, validating a fix, or handing off for review
- finding the Claude Code terminal for a worktree and reading its status
- checking which Orca worktrees have live terminal activity
- creating a scheduled automation that runs a prompt against a known repo or worktree
## Preconditions
@@ -98,6 +101,7 @@ orca terminal list --json
4. Act through Orca:
- `worktree create/set/rm`
- `automations list/show/create/edit/remove/run/runs`
- `terminal read/send/wait/stop`
5. When the agent reaches a significant checkpoint in the current worktree, update the Orca worktree comment so the UI reflects the latest work-in-progress:
@@ -142,6 +146,25 @@ Worktree selectors supported in focused v1:
- `issue:<number>`
- `active` / `current` to resolve the enclosing Orca-managed worktree from the shell `cwd`
### Automations
```bash
orca automations list --json
orca automations show <automationId> --json
orca automations create --name "Daily review" --trigger daily --time 09:00 --prompt "Review open changes" --provider codex --repo id:<repoId> --json
orca automations create --name "Weekday triage" --trigger "0 9 * * 1-5" --prompt "Triage issues" --provider claude --repo path:/abs/repo --disabled --json
orca automations edit <automationId> --name "Weekday review" --trigger weekdays --time 09:30 --json
orca automations run <automationId> --json
orca automations runs --id <automationId> --json
orca automations remove <automationId> --json
```
Automation schedules accept `hourly`, `daily`, `weekdays`, `weekly`, a 5-field cron expression, or an RRULE string. Use `--time <HH:MM>` with `daily`, `weekdays`, or `weekly`; use `--day <0-6>` only with `weekly`, where Sunday is `0`.
Use `--repo <selector>` for a new worktree per run, or `--workspace <selector>` / `--workspace-mode existing` when the automation should run in an existing Orca worktree. `--repo` and `--workspace` are mutually exclusive.
Why: automations are persisted through the running Orca runtime, so use the CLI instead of editing automation storage files directly. Prefer `--disabled` when creating an automation during tests or setup so it cannot run before the user reviews it.
### Terminal
Use selectors to discover terminals, then use the returned handle for repeated live interaction.
@@ -175,6 +198,7 @@ Why: `--direction horizontal` splits the pane **left and right** (new pane appea
## Agent Guidance
- If the user says to create/manage an Orca worktree, use `orca worktree ...`, not raw `git worktree ...`.
- If the user says to create/manage a scheduled Orca automation, use `orca automations ...`, not direct persistence edits.
- Treat Orca as the source of truth for Orca worktree and terminal tasks. Do not mix Orca-managed state with ad hoc git worktree commands unless Orca explicitly cannot perform the requested action.
- Prefer `--json` for all machine-driven use.
- Use `worktree ps` as the first summary view when many worktrees may exist.
+42 -1
View File
@@ -3,6 +3,7 @@ import { RuntimeClientError } from './runtime-client'
export type ParsedArgs = {
commandPath: string[]
flags: Map<string, string | boolean>
positionalFlagConflicts?: string[]
}
export type CommandSpec = {
@@ -10,6 +11,7 @@ export type CommandSpec = {
summary: string
usage: string
allowedFlags: string[]
positionalArgs?: string[]
examples?: string[]
notes?: string[]
}
@@ -62,7 +64,9 @@ export function supportsBrowserPageFlag(commandPath: string[]): boolean {
if (['open', 'status'].includes(commandPath[0])) {
return false
}
if (['repo', 'worktree', 'terminal', 'computer', 'note'].includes(commandPath[0])) {
if (
['automations', 'repo', 'worktree', 'terminal', 'computer', 'note'].includes(commandPath[0])
) {
return false
}
return ![
@@ -79,6 +83,7 @@ export function isCommandGroup(commandPath: string[]): boolean {
return (
(commandPath.length === 1 &&
[
'automations',
'repo',
'worktree',
'terminal',
@@ -101,6 +106,33 @@ export function isCommandGroup(commandPath: string[]): boolean {
)
}
export function normalizeCommandPositionals(specs: CommandSpec[], parsed: ParsedArgs): ParsedArgs {
for (const spec of specs) {
const positionalArgs = spec.positionalArgs ?? []
if (positionalArgs.length === 0) {
continue
}
if (parsed.commandPath.length !== spec.path.length + positionalArgs.length) {
continue
}
if (!matches(parsed.commandPath.slice(0, spec.path.length), spec.path)) {
continue
}
const flags = new Map(parsed.flags)
const values = parsed.commandPath.slice(spec.path.length)
// Why: validation runs inside main's error-reporting path, so normalization
// records ambiguity instead of throwing before CLI errors can be formatted.
const positionalFlagConflicts = positionalArgs.filter((name) => flags.has(name))
positionalArgs.forEach((name, index) => {
if (!flags.has(name)) {
flags.set(name, values[index])
}
})
return { commandPath: spec.path, flags, positionalFlagConflicts }
}
return parsed
}
export function findCommandSpec(
specs: CommandSpec[],
commandPath: string[]
@@ -117,6 +149,15 @@ export function validateCommandAndFlags(specs: CommandSpec[], parsed: ParsedArgs
)
}
if (parsed.positionalFlagConflicts && parsed.positionalFlagConflicts.length > 0) {
throw new RuntimeClientError(
'invalid_argument',
`Pass ${parsed.positionalFlagConflicts
.map((flag) => `--${flag}`)
.join(', ')} either positionally or as a flag, not both.`
)
}
for (const flag of parsed.flags.keys()) {
if (
!spec.allowedFlags.includes(flag) &&
+2
View File
@@ -1,6 +1,7 @@
import type { RuntimeClient } from './runtime-client'
import { RuntimeClientError } from './runtime-client'
import { CORE_HANDLERS } from './handlers/core'
import { AUTOMATION_HANDLERS } from './handlers/automations'
import { REPO_HANDLERS } from './handlers/repo'
import { WORKTREE_HANDLERS } from './handlers/worktree'
import { TERMINAL_HANDLERS } from './handlers/terminal'
@@ -29,6 +30,7 @@ function buildHandlers(): Map<string, CommandHandler> {
const table = new Map<string, CommandHandler>()
const groups = [
CORE_HANDLERS,
AUTOMATION_HANDLERS,
REPO_HANDLERS,
WORKTREE_HANDLERS,
TERMINAL_HANDLERS,
+64
View File
@@ -33,6 +33,8 @@ import type {
RuntimeWorktreePsResult,
RuntimeWorktreeRecord
} from '../shared/runtime-types'
import type { Automation, AutomationRun } from '../shared/automations-types'
import { formatAutomationSchedule } from '../shared/automation-schedules'
import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments'
import type { RuntimeRpcFailure, RuntimeRpcSuccess } from './runtime-client'
import { RuntimeClientError, RuntimeRpcFailureError } from './runtime-client'
@@ -282,6 +284,68 @@ export function formatWorktreeShow(result: { worktree: RuntimeWorktreeRecord }):
.join('\n')
}
export function formatAutomationList(result: { automations: Automation[] }): string {
if (result.automations.length === 0) {
return 'No automations found.'
}
return result.automations
.map((automation) => {
const status = automation.enabled ? 'enabled' : 'disabled'
return `${automation.id} ${automation.name} ${automation.agentId} ${status}\n${formatAutomationSchedule(automation.rrule)} next: ${new Date(automation.nextRunAt).toISOString()}`
})
.join('\n\n')
}
export function formatAutomationShow(result: { automation: Automation }): string {
const automation = result.automation
return [
`id: ${automation.id}`,
`name: ${automation.name}`,
`provider: ${automation.agentId}`,
`enabled: ${automation.enabled}`,
`schedule: ${formatAutomationSchedule(automation.rrule)}`,
`rrule: ${automation.rrule}`,
`nextRunAt: ${new Date(automation.nextRunAt).toISOString()}`,
`projectId: ${automation.projectId}`,
`workspaceMode: ${automation.workspaceMode}`,
`workspaceId: ${automation.workspaceId ?? 'null'}`,
`baseBranch: ${automation.baseBranch ?? 'null'}`,
`target: ${automation.executionTargetType}:${automation.executionTargetId}`,
`prompt: ${automation.prompt}`
].join('\n')
}
export function formatAutomationRemoved(result: { removed: boolean; id: string }): string {
return result.removed
? `Removed automation ${result.id}.`
: `Automation ${result.id} not removed.`
}
export function formatAutomationRun(result: { run: AutomationRun }): string {
return [
`id: ${result.run.id}`,
`automationId: ${result.run.automationId}`,
`title: ${result.run.title}`,
`status: ${result.run.status}`,
`trigger: ${result.run.trigger}`,
`scheduledFor: ${new Date(result.run.scheduledFor).toISOString()}`,
`workspaceId: ${result.run.workspaceId ?? 'null'}`,
`error: ${result.run.error ?? 'null'}`
].join('\n')
}
export function formatAutomationRuns(result: { runs: AutomationRun[] }): string {
if (result.runs.length === 0) {
return 'No automation runs found.'
}
return result.runs
.map(
(run) =>
`${run.id} ${run.automationId} ${run.status} ${run.trigger} ${new Date(run.scheduledFor).toISOString()}\n${run.title}${run.error ? `\nerror: ${run.error}` : ''}`
)
.join('\n\n')
}
export function formatSnapshot(result: BrowserSnapshotResult): string {
const header = `page: ${result.browserPageId}\n${result.title} — ${result.url}\n`
return header + result.snapshot
+338
View File
@@ -0,0 +1,338 @@
/* eslint-disable max-lines -- Why: automation handlers share schedule parsing, target resolution, and RPC payload shaping for one command family. */
import type {
Automation,
AutomationCreateInput,
AutomationRun,
AutomationSchedulePreset,
AutomationUpdateInput
} from '../../shared/automations-types'
import type { TuiAgent } from '../../shared/types'
import { buildAutomationRrule, isValidAutomationSchedule } from '../../shared/automation-schedules'
import { isTuiAgent } from '../../shared/tui-agent-config'
import type { CommandHandler } from '../dispatch'
import {
formatAutomationList,
formatAutomationRemoved,
formatAutomationRun,
formatAutomationRuns,
formatAutomationShow,
printResult
} from '../format'
import {
getOptionalPositiveIntegerFlag,
getOptionalStringFlag,
getRequiredStringFlag
} from '../flags'
import { RuntimeClientError } from '../runtime-client'
import { getOptionalWorktreeSelector, resolveCurrentWorktreeSelector } from '../selectors'
type AutomationCreateParams = Omit<AutomationCreateInput, 'projectId' | 'timezone'> & {
repo?: string
timezone?: string
workspace?: string
}
type AutomationUpdateParams = AutomationUpdateInput & {
repo?: string
workspace?: string
}
const PRESET_TRIGGERS = new Set<AutomationSchedulePreset>(['hourly', 'daily', 'weekdays', 'weekly'])
const SCHEDULE_MODIFIER_FLAGS = ['day', 'time'] as const
function getScheduleModifierFlag(flags: Map<string, string | boolean>): string | undefined {
return SCHEDULE_MODIFIER_FLAGS.find((flag) => flags.has(flag))
}
function validateScheduleModifierApplicability(
flags: Map<string, string | boolean>,
raw: string
): void {
const isPreset = PRESET_TRIGGERS.has(raw as AutomationSchedulePreset)
if (!isPreset) {
if (flags.has('time')) {
throw new RuntimeClientError(
'invalid_argument',
'--time can only be used with preset automation triggers'
)
}
if (flags.has('day')) {
throw new RuntimeClientError(
'invalid_argument',
'--day can only be used with the weekly automation preset'
)
}
return
}
if (raw === 'hourly' && flags.has('time')) {
throw new RuntimeClientError(
'invalid_argument',
'--time cannot be used with the hourly automation preset; use a cron trigger such as "30 * * * *" to choose the minute'
)
}
if (raw !== 'weekly' && flags.has('day')) {
throw new RuntimeClientError(
'invalid_argument',
'--day can only be used with the weekly automation preset'
)
}
}
function getOptionalDayFlag(flags: Map<string, string | boolean>): number | undefined {
const raw = flags.get('day')
if (raw === undefined) {
return undefined
}
const day = typeof raw === 'string' && /^\d+$/.test(raw) ? Number(raw) : Number.NaN
if (!Number.isInteger(day) || day < 0 || day > 6) {
throw new RuntimeClientError('invalid_argument', '--day must be an integer from 0 to 6')
}
return day
}
function getProviderFlag(flags: Map<string, string | boolean>): TuiAgent {
const provider = getRequiredStringFlag(flags, 'provider')
if (!isTuiAgent(provider)) {
throw new RuntimeClientError('invalid_argument', `Unknown provider: ${provider}`)
}
return provider
}
function getOptionalProviderFlag(flags: Map<string, string | boolean>): TuiAgent | undefined {
const provider = getOptionalStringFlag(flags, 'provider')
if (provider === undefined) {
return undefined
}
if (!isTuiAgent(provider)) {
throw new RuntimeClientError('invalid_argument', `Unknown provider: ${provider}`)
}
return provider
}
function getTimeFlag(flags: Map<string, string | boolean>): { hour: number; minute: number } {
const value = flags.get('time')
if (value === undefined) {
return { hour: 9, minute: 0 }
}
if (typeof value !== 'string' || value.length === 0) {
throw new RuntimeClientError('invalid_argument', '--time must use HH:MM format')
}
return parseTimeFlag(value)
}
function parseTimeFlag(value: string): { hour: number; minute: number } {
const match = /^(\d{1,2}):(\d{2})$/.exec(value)
if (!match) {
throw new RuntimeClientError('invalid_argument', '--time must use HH:MM format')
}
const hour = Number(match[1])
const minute = Number(match[2])
if (!Number.isInteger(hour) || hour < 0 || hour > 23 || minute < 0 || minute > 59) {
throw new RuntimeClientError('invalid_argument', '--time must be a valid 24-hour time')
}
return { hour, minute }
}
function getScheduleFlag(
flags: Map<string, string | boolean>,
required: boolean
): { rrule: string; dtstart: number } | undefined {
const trigger = getOptionalStringFlag(flags, 'trigger')
const schedule = getOptionalStringFlag(flags, 'schedule')
if (trigger && schedule) {
throw new RuntimeClientError(
'invalid_argument',
'Use either --trigger or --schedule, not both.'
)
}
const raw = trigger ?? schedule
if (!raw) {
const modifier = getScheduleModifierFlag(flags)
if (modifier) {
throw new RuntimeClientError(
'invalid_argument',
`--${modifier} requires --trigger or --schedule`
)
}
if (required) {
throw new RuntimeClientError('invalid_argument', 'Missing required --trigger')
}
return undefined
}
if (raw === 'manual') {
throw new RuntimeClientError(
'invalid_argument',
'Manual-only automations are not supported yet. Create a scheduled automation with --disabled and run it with `orca automations run <id>` when needed.'
)
}
validateScheduleModifierApplicability(flags, raw)
const { hour, minute } = raw === 'hourly' ? { hour: 0, minute: 0 } : getTimeFlag(flags)
const dayOfWeek = raw === 'weekly' ? (getOptionalDayFlag(flags) ?? 1) : 1
const rrule = PRESET_TRIGGERS.has(raw as AutomationSchedulePreset)
? buildAutomationRrule({
preset: raw as Exclude<AutomationSchedulePreset, 'custom'>,
hour,
minute,
dayOfWeek
})
: raw
if (!isValidAutomationSchedule(rrule)) {
throw new RuntimeClientError('invalid_argument', `Invalid automation trigger: ${raw}`)
}
return { rrule, dtstart: Date.now() }
}
function getEnabledFlag(flags: Map<string, string | boolean>): boolean | undefined {
const enabledFlag = flags.get('enabled')
const disabledFlag = flags.get('disabled')
if (typeof enabledFlag === 'string') {
throw new RuntimeClientError('invalid_argument', '--enabled does not take a value')
}
if (typeof disabledFlag === 'string') {
throw new RuntimeClientError('invalid_argument', '--disabled does not take a value')
}
const enabled = enabledFlag === true
const disabled = disabledFlag === true
if (enabled && disabled) {
throw new RuntimeClientError(
'invalid_argument',
'Use either --enabled or --disabled, not both.'
)
}
if (enabled) {
return true
}
if (disabled) {
return false
}
return undefined
}
function getWorkspaceModeFlag(
flags: Map<string, string | boolean>
): 'existing' | 'new_per_run' | undefined {
const value = getOptionalStringFlag(flags, 'workspace-mode')
if (value === undefined) {
return undefined
}
if (value === 'existing') {
return 'existing'
}
if (value === 'new-per-run' || value === 'new_per_run') {
return 'new_per_run'
}
throw new RuntimeClientError(
'invalid_argument',
'--workspace-mode must be existing or new-per-run'
)
}
async function resolveDefaultTarget(
flags: Map<string, string | boolean>,
cwd: string,
client: Parameters<CommandHandler>[0]['client']
): Promise<{ repo?: string; workspace?: string }> {
const repo = getOptionalStringFlag(flags, 'repo')
if (repo && getOptionalStringFlag(flags, 'workspace')) {
throw new RuntimeClientError('invalid_argument', 'Use either --repo or --workspace, not both.')
}
const workspace = await getOptionalWorktreeSelector(flags, 'workspace', cwd, client)
if (repo || workspace) {
return { repo, workspace }
}
if (client.isRemote) {
return {}
}
try {
return { workspace: await resolveCurrentWorktreeSelector(cwd, client) }
} catch {
return {}
}
}
async function getExplicitTarget(
flags: Map<string, string | boolean>,
cwd: string,
client: Parameters<CommandHandler>[0]['client']
): Promise<{ repo?: string; workspace?: string }> {
const repo = getOptionalStringFlag(flags, 'repo')
if (repo && getOptionalStringFlag(flags, 'workspace')) {
throw new RuntimeClientError('invalid_argument', 'Use either --repo or --workspace, not both.')
}
const workspace = await getOptionalWorktreeSelector(flags, 'workspace', cwd, client)
return { repo, workspace }
}
export const AUTOMATION_HANDLERS: Record<string, CommandHandler> = {
'automations list': async ({ client, json }) => {
const result = await client.call<{ automations: Automation[] }>('automation.list')
printResult(result, json, formatAutomationList)
},
'automations show': async ({ flags, client, json }) => {
const result = await client.call<{ automation: Automation }>('automation.show', {
id: getRequiredStringFlag(flags, 'id')
})
printResult(result, json, formatAutomationShow)
},
'automations create': async ({ flags, client, cwd, json }) => {
const schedule = getScheduleFlag(flags, true)
if (!schedule) {
throw new RuntimeClientError('invalid_argument', 'Missing required --trigger')
}
const target = await resolveDefaultTarget(flags, cwd, client)
const workspaceMode =
getWorkspaceModeFlag(flags) ?? (target.workspace ? 'existing' : 'new_per_run')
const result = await client.call<{ automation: Automation }>('automation.create', {
name: getRequiredStringFlag(flags, 'name'),
prompt: getRequiredStringFlag(flags, 'prompt'),
agentId: getProviderFlag(flags),
repo: target.repo,
workspace: target.workspace,
workspaceMode,
baseBranch: getOptionalStringFlag(flags, 'base-branch'),
timezone: getOptionalStringFlag(flags, 'timezone'),
enabled: getEnabledFlag(flags),
missedRunGraceMinutes: getOptionalPositiveIntegerFlag(flags, 'missed-run-grace-minutes'),
...schedule
} satisfies AutomationCreateParams)
printResult(result, json, formatAutomationShow)
},
'automations edit': async ({ flags, client, cwd, json }) => {
const target = await getExplicitTarget(flags, cwd, client)
const schedule = getScheduleFlag(flags, false)
const result = await client.call<{ automation: Automation }>('automation.update', {
id: getRequiredStringFlag(flags, 'id'),
updates: {
name: getOptionalStringFlag(flags, 'name'),
prompt: getOptionalStringFlag(flags, 'prompt'),
agentId: getOptionalProviderFlag(flags),
repo: target.repo,
workspace: target.workspace,
workspaceMode: getWorkspaceModeFlag(flags),
baseBranch: getOptionalStringFlag(flags, 'base-branch'),
timezone: getOptionalStringFlag(flags, 'timezone'),
enabled: getEnabledFlag(flags),
missedRunGraceMinutes: getOptionalPositiveIntegerFlag(flags, 'missed-run-grace-minutes'),
...schedule
} satisfies AutomationUpdateParams
})
printResult(result, json, formatAutomationShow)
},
'automations remove': async ({ flags, client, json }) => {
const id = getRequiredStringFlag(flags, 'id')
const result = await client.call<{ removed: boolean; id: string }>('automation.delete', { id })
printResult(result, json, formatAutomationRemoved)
},
'automations run': async ({ flags, client, json }) => {
const result = await client.call<{ run: AutomationRun }>('automation.runNow', {
id: getRequiredStringFlag(flags, 'id')
})
printResult(result, json, formatAutomationRun)
},
'automations runs': async ({ flags, client, json }) => {
const result = await client.call<{ runs: AutomationRun[] }>('automation.runs', {
automationId: getOptionalStringFlag(flags, 'id')
})
printResult(result, json, formatAutomationRuns)
}
}
+22 -1
View File
@@ -17,6 +17,15 @@ Environments:
environment show Show one saved remote Orca runtime
environment rm Remove a saved remote Orca runtime
Automations:
automations list List scheduled Orca automations
automations show Show one Orca automation
automations create Create a scheduled Orca automation
automations edit Edit an Orca automation
automations remove Remove an Orca automation and its run history
automations run Run an Orca automation now
automations runs List automation run history
Repos:
repo list List repos registered in Orca
repo add Add a project to Orca by filesystem path
@@ -345,7 +354,7 @@ export function formatFlagHelp(flag: string): string {
key: '--key <key> Key or combo to press, e.g. Escape or CmdOrCtrl+L',
limit: '--limit <n> Maximum number of rows to return',
'mouse-button': '--mouse-button <btn> Mouse button: left, right, or middle',
name: '--name <name> Name for the new worktree',
name: '--name <name> Name for the new worktree or automation',
'no-parent': '--no-parent Force no parent lineage',
'no-screenshot': '--no-screenshot Skip screenshot capture after the operation',
pages: '--pages <n> Number of scroll pages',
@@ -367,6 +376,18 @@ export function formatFlagHelp(flag: string): string {
'to-y': '--to-y <y> Destination window-local y coordinate',
worktree:
'--worktree <selector> Worktree selector such as id:<id>, branch:<branch>, issue:<number>, path:<path>, or active/current',
workspace: '--workspace <selector> Existing worktree selector for automation runs',
prompt: '--prompt <text> Automation prompt to pass to the agent',
provider: '--provider <agent> Agent id such as codex, claude, or gemini',
trigger: '--trigger <schedule> Automation schedule preset, cron, or RRULE',
schedule: '--schedule <schedule> Alias for --trigger',
time: '--time <HH:MM> Time used with daily/weekdays/weekly presets',
day: '--day <0-6> Day used with weekly preset, Sunday=0',
timezone: '--timezone <tz> IANA timezone for the automation',
enabled: '--enabled Enable the automation',
disabled: '--disabled Disable the automation',
'workspace-mode': '--workspace-mode <mode> existing or new-per-run',
'missed-run-grace-minutes': '--missed-run-grace-minutes <n> Missed-run grace window',
'value-stdin': '--value-stdin Read set-value payload from stdin',
'window-id': '--window-id <id> Target a window id from list-windows',
'window-index': '--window-index <n> Target a window index from list-windows',
+444
View File
@@ -1439,4 +1439,448 @@ describe('orca cli worktree awareness', () => {
expect(callMock).toHaveBeenCalledTimes(1)
expect(callMock).toHaveBeenCalledWith('browser.tabCurrent', { worktree: undefined })
})
it('creates an automation for the enclosing worktree by default', async () => {
queueFixtures(
callMock,
worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo', 'abc', 'repo-1')]),
okFixture('req_automation_create', {
automation: {
id: 'auto-1',
name: 'Daily review',
prompt: 'Review open changes',
agentId: 'codex',
projectId: 'repo-1',
executionTargetType: 'local',
executionTargetId: 'local',
schedulerOwner: 'local_host_service',
workspaceMode: 'existing',
workspaceId: 'repo-1::/tmp/repo/feature',
baseBranch: null,
timezone: 'America/Toronto',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: 1,
enabled: true,
nextRunAt: 2,
missedRunPolicy: 'run_once_within_grace',
missedRunGraceMinutes: 720,
createdAt: 1,
updatedAt: 1
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'automations',
'create',
'--name',
'Daily review',
'--trigger',
'daily',
'--prompt',
'Review open changes',
'--provider',
'codex',
'--json'
],
'/tmp/repo/feature/src'
)
expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { limit: 10_000 })
expect(callMock).toHaveBeenNthCalledWith(2, 'automation.create', {
name: 'Daily review',
prompt: 'Review open changes',
agentId: 'codex',
repo: undefined,
workspace: `path:${path.resolve('/tmp/repo/feature')}`,
workspaceMode: 'existing',
baseBranch: undefined,
timezone: undefined,
enabled: undefined,
missedRunGraceMinutes: undefined,
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: expect.any(Number)
})
})
it('rejects invalid automation --day values before calling the runtime', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
[
'automations',
'create',
'--name',
'Weekly review',
'--trigger',
'weekly',
'--day',
'7',
'--prompt',
'Review open changes',
'--provider',
'codex',
'--json'
],
'/tmp/repo'
)
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'--day must be an integer from 0 to 6'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it.each([
{
name: 'day on daily preset',
args: ['--trigger', 'daily', '--day', '2'],
message: '--day can only be used with the weekly automation preset'
},
{
name: 'time on custom cron',
args: ['--trigger', '0 9 * * *', '--time', '10:30'],
message: '--time can only be used with preset automation triggers'
},
{
name: 'time on hourly preset',
args: ['--trigger', 'hourly', '--time', '10:30'],
message: '--time cannot be used with the hourly automation preset'
}
])('rejects automation schedule modifier mismatch: $name', async ({ args, message }) => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
[
'automations',
'create',
'--name',
'Daily review',
...args,
'--prompt',
'Review open changes',
'--provider',
'codex',
'--json'
],
'/tmp/repo'
)
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(message)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it.each([
{
name: 'create',
args: [
'automations',
'create',
'--name',
'Daily review',
'--trigger',
'daily',
'--time',
'--prompt',
'Review open changes',
'--provider',
'codex',
'--json'
]
},
{
name: 'edit',
args: ['automations', 'edit', 'auto-1', '--trigger', 'daily', '--time', '--json']
}
])('rejects bare automation --time on $name', async ({ args }) => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(args, '/tmp/repo')
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'--time must use HH:MM format'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('rejects automation edit schedule modifiers without a schedule flag', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(['automations', 'edit', 'auto-1', '--day', '7', '--json'], '/tmp/repo')
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'--day requires --trigger or --schedule'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('rejects automation create with both repo and workspace targets', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
[
'automations',
'create',
'--name',
'Daily review',
'--trigger',
'daily',
'--prompt',
'Review open changes',
'--provider',
'codex',
'--repo',
'id:repo-1',
'--workspace',
'id:repo-1::/tmp/repo/feature',
'--json'
],
'/tmp/repo'
)
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'Use either --repo or --workspace, not both.'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('rejects automation edit with both repo and workspace targets', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
[
'automations',
'edit',
'auto-1',
'--repo',
'id:repo-1',
'--workspace',
'id:repo-1::/tmp/repo/feature',
'--json'
],
'/tmp/repo'
)
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'Use either --repo or --workspace, not both.'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it.each([
{ flag: 'enabled', value: 'false', message: '--enabled does not take a value' },
{ flag: 'disabled', value: 'false', message: '--disabled does not take a value' }
])('rejects automation create --$flag with a string value', async ({ flag, value, message }) => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
[
'automations',
'create',
'--name',
'Daily review',
'--trigger',
'daily',
'--prompt',
'Review open changes',
'--provider',
'codex',
'--repo',
'id:repo-1',
`--${flag}`,
value,
'--json'
],
'/tmp/repo'
)
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(message)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('resolves explicit automation create workspace active from cwd', async () => {
queueFixtures(
callMock,
worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo', 'abc', 'repo-1')]),
okFixture('req_automation_create', { automation: { id: 'auto-1', name: 'Daily review' } })
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'automations',
'create',
'--name',
'Daily review',
'--trigger',
'daily',
'--prompt',
'Review open changes',
'--provider',
'codex',
'--workspace',
'active',
'--json'
],
'/tmp/repo/feature/src'
)
expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { limit: 10_000 })
expect(callMock).toHaveBeenNthCalledWith(2, 'automation.create', {
name: 'Daily review',
prompt: 'Review open changes',
agentId: 'codex',
repo: undefined,
workspace: `path:${path.resolve('/tmp/repo/feature')}`,
workspaceMode: 'existing',
baseBranch: undefined,
timezone: undefined,
enabled: undefined,
missedRunGraceMinutes: undefined,
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: expect.any(Number)
})
})
it('resolves explicit automation edit workspace current from cwd', async () => {
queueFixtures(
callMock,
worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo', 'abc', 'repo-1')]),
okFixture('req_edit', { automation: { id: 'auto-1', name: 'Daily review' } })
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
['automations', 'edit', 'auto-1', '--workspace', 'current', '--enabled', '--json'],
'/tmp/repo/feature/src'
)
expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { limit: 10_000 })
expect(callMock).toHaveBeenNthCalledWith(2, 'automation.update', {
id: 'auto-1',
updates: {
name: undefined,
prompt: undefined,
agentId: undefined,
repo: undefined,
workspace: `path:${path.resolve('/tmp/repo/feature')}`,
workspaceMode: undefined,
baseBranch: undefined,
timezone: undefined,
enabled: true,
missedRunGraceMinutes: undefined
}
})
})
it('passes positional automation ids to edit, remove, run, and show', async () => {
queueFixtures(
callMock,
okFixture('req_edit', { automation: { id: 'auto-1', name: 'Paused' } }),
okFixture('req_remove', { removed: true, id: 'auto-1' }),
okFixture('req_run', {
run: {
id: 'run-1',
automationId: 'auto-1',
title: 'Paused run 1',
status: 'pending',
trigger: 'manual',
scheduledFor: 1,
workspaceId: null,
sessionKind: 'terminal',
chatSessionId: null,
terminalSessionId: null,
outputSnapshot: null,
usage: null,
error: null,
startedAt: null,
dispatchedAt: null,
createdAt: 1
}
}),
okFixture('req_show', { automation: { id: 'auto-1', name: 'Paused' } })
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['automations', 'edit', 'auto-1', '--disabled', '--json'], '/tmp/repo')
await main(['automations', 'remove', 'auto-1', '--json'], '/tmp/repo')
await main(['automations', 'run', 'auto-1', '--json'], '/tmp/repo')
await main(['automations', 'show', 'auto-1', '--json'], '/tmp/repo')
expect(callMock).toHaveBeenNthCalledWith(1, 'automation.update', {
id: 'auto-1',
updates: {
name: undefined,
prompt: undefined,
agentId: undefined,
repo: undefined,
workspace: undefined,
workspaceMode: undefined,
baseBranch: undefined,
timezone: undefined,
enabled: false,
missedRunGraceMinutes: undefined
}
})
expect(callMock).toHaveBeenNthCalledWith(2, 'automation.delete', { id: 'auto-1' })
expect(callMock).toHaveBeenNthCalledWith(3, 'automation.runNow', { id: 'auto-1' })
expect(callMock).toHaveBeenNthCalledWith(4, 'automation.show', { id: 'auto-1' })
})
it('rejects ambiguous positional and flag automation ids before dispatch', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(['automations', 'show', 'auto-1', '--id', 'auto-2', '--json'], '/tmp/repo')
expect(callMock).not.toHaveBeenCalled()
expect(logSpy).toHaveBeenCalledTimes(1)
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toMatchObject({
ok: false,
error: {
code: 'invalid_argument',
message: 'Pass --id either positionally or as a flag, not both.'
}
})
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
})
+2 -1
View File
@@ -2,6 +2,7 @@
import {
findCommandSpec,
isCommandGroup,
normalizeCommandPositionals,
parseArgs,
resolveHelpPath,
validateCommandAndFlags
@@ -20,7 +21,7 @@ function shouldIgnoreRemoteSelection(commandPath: string[]): boolean {
}
export async function main(argv = process.argv.slice(2), cwd = process.cwd()): Promise<void> {
const parsed = parseArgs(argv)
const parsed = normalizeCommandPositionals(COMMAND_SPECS, parseArgs(argv))
const helpPath = resolveHelpPath(parsed)
if (helpPath !== null) {
printHelp(COMMAND_SPECS, helpPath)
+91
View File
@@ -0,0 +1,91 @@
import type { CommandSpec } from '../args'
import { GLOBAL_FLAGS } from '../args'
const AUTOMATION_TARGET_FLAGS = ['repo', 'workspace', 'workspace-mode', 'base-branch']
const AUTOMATION_SCHEDULE_FLAGS = ['trigger', 'schedule', 'time', 'day', 'timezone']
const AUTOMATION_STATE_FLAGS = ['enabled', 'disabled', 'missed-run-grace-minutes']
export const AUTOMATION_COMMAND_SPECS: CommandSpec[] = [
{
path: ['automations', 'list'],
summary: 'List scheduled Orca automations',
usage: 'orca automations list [--json]',
allowedFlags: [...GLOBAL_FLAGS],
examples: ['orca automations list', 'orca automations list --json']
},
{
path: ['automations', 'show'],
summary: 'Show one Orca automation',
usage: 'orca automations show <id> [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'id'],
positionalArgs: ['id'],
examples: ['orca automations show 2f9e...', 'orca automations show --id 2f9e... --json']
},
{
path: ['automations', 'create'],
summary: 'Create a scheduled Orca automation',
usage:
'orca automations create --name <name> --trigger <preset|cron|rrule> --prompt <text> --provider <agent> [--repo <selector>|--workspace <selector>] [--json]',
allowedFlags: [
...GLOBAL_FLAGS,
'name',
'prompt',
'provider',
...AUTOMATION_TARGET_FLAGS,
...AUTOMATION_SCHEDULE_FLAGS,
...AUTOMATION_STATE_FLAGS
],
notes: [
'Trigger accepts hourly, daily, weekdays, weekly, a 5-field cron expression, or an RRULE string.',
'When --repo is omitted, the CLI uses the enclosing Orca worktree when one can be resolved from cwd.',
'Use --workspace to run in an existing worktree; otherwise the automation creates a new worktree per run.'
],
examples: [
'orca automations create --name "Daily review" --trigger daily --prompt "Review open changes" --provider codex',
'orca automations create --name "Weekday triage" --trigger "0 9 * * 1-5" --prompt "Triage issues" --provider claude --repo my-repo'
]
},
{
path: ['automations', 'edit'],
summary: 'Edit an Orca automation',
usage: 'orca automations edit <id> [--name <name>] [--trigger <preset|cron|rrule>] [--json]',
allowedFlags: [
...GLOBAL_FLAGS,
'id',
'name',
'prompt',
'provider',
...AUTOMATION_TARGET_FLAGS,
...AUTOMATION_SCHEDULE_FLAGS,
...AUTOMATION_STATE_FLAGS
],
positionalArgs: ['id'],
examples: [
'orca automations edit 2f9e... --disabled',
'orca automations edit --id 2f9e... --trigger "30 * * * *" --json'
]
},
{
path: ['automations', 'remove'],
summary: 'Remove an Orca automation and its run history',
usage: 'orca automations remove <id> [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'id'],
positionalArgs: ['id'],
examples: ['orca automations remove 2f9e...', 'orca automations remove --id 2f9e... --json']
},
{
path: ['automations', 'run'],
summary: 'Run an Orca automation now',
usage: 'orca automations run <id> [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'id'],
positionalArgs: ['id'],
examples: ['orca automations run 2f9e...', 'orca automations run --id 2f9e... --json']
},
{
path: ['automations', 'runs'],
summary: 'List automation run history',
usage: 'orca automations runs [--id <automation-id>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'id'],
examples: ['orca automations runs', 'orca automations runs --id 2f9e... --json']
}
]
+2
View File
@@ -1,6 +1,7 @@
import type { CommandSpec } from '../args'
import { BROWSER_ADVANCED_COMMAND_SPECS } from './browser-advanced'
import { BROWSER_BASIC_COMMAND_SPECS } from './browser-basic'
import { AUTOMATION_COMMAND_SPECS } from './automations'
import { CORE_COMMAND_SPECS } from './core'
import { ORCHESTRATION_COMMAND_SPECS } from './orchestration'
import { COMPUTER_COMMAND_SPECS } from './computer'
@@ -8,6 +9,7 @@ import { ENVIRONMENT_COMMAND_SPECS } from './environment'
export const COMMAND_SPECS: CommandSpec[] = [
...CORE_COMMAND_SPECS,
...AUTOMATION_COMMAND_SPECS,
...BROWSER_BASIC_COMMAND_SPECS,
...BROWSER_ADVANCED_COMMAND_SPECS,
...ORCHESTRATION_COMMAND_SPECS,
+27 -6
View File
@@ -3028,15 +3028,17 @@ describe('Endpoint file lifecycle', () => {
}
})
it('buildPtyEnv omits ORCA_AGENT_HOOK_ENDPOINT for development servers', async () => {
// Why: dev worktrees share the same orca-dev userData path. Exporting the
// shared endpoint file lets the most recently started dev app steal hook
// callbacks from terminals spawned by another worktree.
it('buildPtyEnv includes namespaced ORCA_AGENT_HOOK_ENDPOINT for development servers', async () => {
const server = new AgentHookServer()
await server.start({ env: 'development', userDataPath })
await server.start({
env: 'development',
userDataPath,
endpointNamespace: 'com.stablyai.orca.dev.test123'
})
try {
const env = server.buildPtyEnv()
expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBeUndefined()
expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBe(server.endpointFilePath)
expect(env.ORCA_AGENT_HOOK_ENDPOINT).toContain('com.stablyai.orca.dev.test123')
expect(env.ORCA_AGENT_HOOK_PORT).toBeTruthy()
expect(env.ORCA_AGENT_HOOK_TOKEN).toBeTruthy()
} finally {
@@ -3044,6 +3046,25 @@ describe('Endpoint file lifecycle', () => {
}
})
it('keeps endpoint files separate for parallel dev namespaces', async () => {
const firstServer = new AgentHookServer()
const secondServer = new AgentHookServer()
await firstServer.start({ env: 'development', userDataPath, endpointNamespace: 'dev-a' })
await secondServer.start({ env: 'development', userDataPath, endpointNamespace: 'dev-b' })
try {
expect(firstServer.endpointFilePath).not.toBe(secondServer.endpointFilePath)
expect(firstServer.buildPtyEnv().ORCA_AGENT_HOOK_ENDPOINT).toBe(firstServer.endpointFilePath)
expect(secondServer.buildPtyEnv().ORCA_AGENT_HOOK_ENDPOINT).toBe(
secondServer.endpointFilePath
)
expect(existsSync(firstServer.endpointFilePath!)).toBe(true)
expect(existsSync(secondServer.endpointFilePath!)).toBe(true)
} finally {
firstServer.stop()
secondServer.stop()
}
})
it('buildPtyEnv omits ORCA_AGENT_HOOK_ENDPOINT when no userDataPath was provided', async () => {
// Why: the endpoint file is opt-in via start({ userDataPath }). In tests
// and in the packaged main-process path where userData is unset for any
+15 -7
View File
@@ -709,7 +709,11 @@ export class AgentHookServer {
this.applyNormalizedStatus(event)
}
async start(options?: { env?: string; userDataPath?: string }): Promise<void> {
async start(options?: {
env?: string
userDataPath?: string
endpointNamespace?: string
}): Promise<void> {
if (this.server) {
return
}
@@ -718,7 +722,12 @@ export class AgentHookServer {
this.env = options.env
}
if (options?.userDataPath) {
this.endpointDir = join(options.userDataPath, 'agent-hooks')
// Why: dev builds share one userData path, so callers can namespace the
// endpoint file by dev instance while packaged builds keep the stable path
// that lets long-lived PTYs reconnect after app restart.
this.endpointDir = options.endpointNamespace
? join(options.userDataPath, 'agent-hooks', options.endpointNamespace)
: join(options.userDataPath, 'agent-hooks')
this.endpointFilePathCache = join(this.endpointDir, getEndpointFileName())
this.lastStatusFilePath = join(this.endpointDir, LAST_STATUS_FILE_NAME)
}
@@ -893,11 +902,10 @@ export class AgentHookServer {
ORCA_AGENT_HOOK_ENV: this.env,
ORCA_AGENT_HOOK_VERSION: ORCA_HOOK_PROTOCOL_VERSION
}
// Why: parallel dev instances share the `orca-dev` userData path, making
// endpoint.env a last-writer-wins global. Local dev PTYs already receive
// this server's port/token directly; packaged builds keep endpoint sourcing
// so PTYs that survive an app restart can reconnect to the replacement server.
if (this.env !== 'development' && this.endpointFileWritten && this.endpointFilePathCache) {
// Why: managed hooks source this file at invocation time. Packaged builds
// use a stable file for restart handoff; dev callers pass a per-instance
// namespace so parallel `pnpm dev` runs do not steal each other's hooks.
if (this.endpointFileWritten && this.endpointFilePathCache) {
env.ORCA_AGENT_HOOK_ENDPOINT = this.endpointFilePathCache
}
return env
+35
View File
@@ -89,6 +89,41 @@ describe('AutomationService', () => {
)
})
it('returns the persisted status for manual runs after dispatch is requested', async () => {
vi.setSystemTime(new Date('2026-05-13T08:00:00Z'))
const store = await createStore()
store.addRepo(makeRepo())
const automation = store.createAutomation({
name: 'Manual check',
prompt: 'Check the repo',
agentId: 'claude',
projectId: 'r1',
workspaceMode: 'existing',
workspaceId: 'wt1',
timezone: 'UTC',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: new Date('2026-05-14T00:00:00Z').getTime()
})
const send = vi.fn()
const service = new AutomationService(store, { tickMs: 60_000 })
service.setWebContents({
isDestroyed: () => false,
send
} as never)
service.setRendererReady()
const run = await service.runNow(automation.id)
expect(run.status).toBe('dispatching')
expect(store.listAutomationRuns(automation.id)[0]?.status).toBe('dispatching')
expect(send).toHaveBeenCalledWith(
'automations:dispatchRequested',
expect.objectContaining({
run: expect.objectContaining({ id: run.id, status: 'dispatching' })
})
)
})
it('attaches provider usage when a completed run can be attributed', async () => {
vi.setSystemTime(new Date('2026-05-13T10:00:00'))
const store = await createStore()
+9 -7
View File
@@ -69,8 +69,7 @@ export class AutomationService {
throw new Error('Automation not found.')
}
const run = this.store.createAutomationRun(automation, Date.now(), 'manual')
await this.requestDispatch(automation, run)
return run
return await this.requestDispatch(automation, run)
}
async markDispatchResult(result: AutomationDispatchResult): Promise<AutomationRun> {
@@ -214,25 +213,28 @@ export class AutomationService {
this.store.advanceAutomationNextRun(automation.id, now)
}
private async requestDispatch(automation: Automation, run: AutomationRun): Promise<void> {
private async requestDispatch(
automation: Automation,
run: AutomationRun
): Promise<AutomationRun> {
const webContents = this.webContents
if (!webContents || webContents.isDestroyed() || !this.rendererReady) {
this.store.updateAutomationRun({
return this.store.updateAutomationRun({
runId: run.id,
status: 'skipped_unavailable',
workspaceId: automation.workspaceId,
error: 'No Orca window was available to launch the automation.'
})
return
}
this.store.updateAutomationRun({
const updated = this.store.updateAutomationRun({
runId: run.id,
status: 'dispatching',
workspaceId: automation.workspaceId,
error: null
})
const payload: AutomationDispatchRequest = { automation, run }
const payload: AutomationDispatchRequest = { automation, run: updated }
webContents.send('automations:dispatchRequested', payload)
return updated
}
}
+12
View File
@@ -213,6 +213,18 @@ describe('getDiff', () => {
})
})
it('normalizes Windows separators before reading git blobs', async () => {
gitExecFileAsyncBufferMock.mockResolvedValueOnce({ stdout: Buffer.from('index-content\n') })
readFileMock.mockResolvedValue(Buffer.from('working-tree-content'))
await getDiff('/repo', 'src\\file.ts', false)
expect(gitExecFileAsyncBufferMock).toHaveBeenCalledWith(['show', ':src/file.ts'], {
cwd: '/repo',
maxBuffer: 10 * 1024 * 1024
})
})
it('falls back to HEAD for unstaged diffs when the file is not in the index', async () => {
gitExecFileAsyncBufferMock
.mockRejectedValueOnce(new Error('missing index'))
+6 -2
View File
@@ -811,8 +811,10 @@ async function readGitBlobAtIndexPath(
worktreePath: string,
filePath: string
): Promise<GitBlobReadResult> {
// Why: Git's `:<path>` syntax expects forward slashes even on Windows.
const gitPath = filePath.replace(/\\/g, '/')
try {
const { stdout } = await gitExecFileAsyncBuffer(['show', `:${filePath}`], {
const { stdout } = await gitExecFileAsyncBuffer(['show', `:${gitPath}`], {
cwd: worktreePath,
maxBuffer: MAX_GIT_SHOW_BYTES
})
@@ -828,9 +830,11 @@ async function readGitBlobAtOidPath(
oid: string,
filePath: string
): Promise<GitBlobReadResult> {
// Why: Git's `<oid>:<path>` syntax expects forward slashes even on Windows.
const gitPath = filePath.replace(/\\/g, '/')
try {
const { stdout } = await gitExecFileAsyncBuffer(
['show', '--end-of-options', `${oid}:${filePath}`],
['show', '--end-of-options', `${oid}:${gitPath}`],
{
cwd: worktreePath,
maxBuffer: MAX_GIT_SHOW_BYTES
+55
View File
@@ -6,6 +6,7 @@ const {
getOwnerRepoMock,
getIssueOwnerRepoMock,
gitExecFileAsyncMock,
extractExecErrorMock,
rateLimitGuardMock,
noteRateLimitSpendMock,
acquireMock,
@@ -16,6 +17,16 @@ const {
getOwnerRepoMock: vi.fn(),
getIssueOwnerRepoMock: vi.fn(),
gitExecFileAsyncMock: vi.fn(),
extractExecErrorMock: vi.fn((err: unknown) => {
if (err && typeof err === 'object') {
const e = err as { stderr?: unknown; stdout?: unknown; message?: unknown }
return {
stderr: typeof e.stderr === 'string' ? e.stderr : String(e.message ?? err),
stdout: typeof e.stdout === 'string' ? e.stdout : ''
}
}
return { stderr: String(err), stdout: '' }
}),
rateLimitGuardMock: vi.fn(() => ({ blocked: false })),
noteRateLimitSpendMock: vi.fn(),
acquireMock: vi.fn(),
@@ -32,6 +43,7 @@ vi.mock('./gh-utils', () => ({
ghRepoExecOptions: (context: { repoPath: string }) => ({ cwd: context.repoPath }),
getOwnerRepo: getOwnerRepoMock,
getIssueOwnerRepo: getIssueOwnerRepoMock,
extractExecError: extractExecErrorMock,
acquire: acquireMock,
release: releaseMock,
_resetOwnerRepoCache: vi.fn()
@@ -55,6 +67,7 @@ describe('getPRChecks', () => {
getOwnerRepoMock.mockReset()
getIssueOwnerRepoMock.mockReset()
gitExecFileAsyncMock.mockReset()
extractExecErrorMock.mockClear()
rateLimitGuardMock.mockReset()
rateLimitGuardMock.mockReturnValue({ blocked: false })
noteRateLimitSpendMock.mockReset()
@@ -125,6 +138,48 @@ describe('getPRChecks', () => {
])
})
it('treats gh pr checks "no checks reported" as an empty check list', async () => {
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: JSON.stringify({ check_runs: [] }) })
.mockRejectedValueOnce(
Object.assign(new Error('Command failed: gh pr checks 42'), {
stderr: "no checks reported on the 'codex/keybindings-toml' branch\n",
stdout: ''
})
)
const checks = await getPRChecks('/repo-root', 42, 'head-oid')
expect(checks).toEqual([])
expect(consoleWarnSpy).not.toHaveBeenCalled()
consoleWarnSpy.mockRestore()
})
it('keeps unexpected gh pr checks fallback failures inside the empty-checks contract', async () => {
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: JSON.stringify({ check_runs: [] }) })
.mockRejectedValueOnce(
Object.assign(new Error('Command failed: gh pr checks 42'), {
stderr: 'GraphQL: Could not resolve to a PullRequest',
stdout: ''
})
)
const checks = await getPRChecks('/repo-root', 42, 'head-oid')
expect(checks).toEqual([])
expect(consoleWarnSpy).toHaveBeenCalledWith(
'getPRChecks via head SHA failed, falling back to gh pr checks:',
expect.any(Error)
)
expect(consoleWarnSpy).toHaveBeenCalledWith('getPRChecks failed:', expect.any(Error))
consoleWarnSpy.mockRestore()
})
it('falls back to gh pr checks when the cached head SHA no longer resolves', async () => {
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock
+22 -6
View File
@@ -114,7 +114,16 @@ describe('listWorkItems', () => {
author: { login: 'octocat' },
isDraft: false,
headRefName: 'feature/add-feature',
baseRefName: 'main'
baseRefName: 'main',
reviewRequests: [
{
requestedReviewer: {
login: 'AmethystLiang',
name: 'Amethyst Liang',
avatarUrl: 'https://avatars.githubusercontent.com/u/1?v=4'
}
}
]
}
])
})
@@ -147,7 +156,7 @@ describe('listWorkItems', () => {
'--limit',
'10',
'--json',
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRepositoryOwner',
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRepositoryOwner,reviewRequests',
'--repo',
'acme/widgets',
'--assignee',
@@ -157,7 +166,7 @@ describe('listWorkItems', () => {
)
const prListFields = ghExecFileAsyncMock.mock.calls[1][0].join(',')
expect(prListFields).not.toContain('statusCheckRollup')
expect(prListFields).not.toContain('reviewRequests')
expect(prListFields).toContain('reviewRequests')
expect(prListFields).not.toContain('mergeStateStatus')
expect(items).toEqual([
{
@@ -182,7 +191,14 @@ describe('listWorkItems', () => {
updatedAt: '2026-03-28T00:00:00Z',
author: 'octocat',
branchName: 'feature/add-feature',
baseRefName: 'main'
baseRefName: 'main',
reviewRequests: [
{
login: 'AmethystLiang',
name: 'Amethyst Liang',
avatarUrl: 'https://avatars.githubusercontent.com/u/1?v=4'
}
]
}
])
})
@@ -215,7 +231,7 @@ describe('listWorkItems', () => {
'--limit',
'10',
'--json',
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRepositoryOwner',
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRepositoryOwner,reviewRequests',
'--repo',
'acme/widgets',
'--state',
@@ -315,7 +331,7 @@ describe('listWorkItems', () => {
'--limit',
'10',
'--json',
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRepositoryOwner',
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRepositoryOwner,reviewRequests',
'--repo',
'acme/widgets',
'--state',
+32
View File
@@ -84,6 +84,7 @@ vi.mock('./rate-limit', () => ({
import {
getPRComments,
getPRForBranch,
getWorkItem,
getPullRequestPushTarget,
mergePR,
resolveReviewThread,
@@ -833,6 +834,37 @@ describe('getPRForBranch', () => {
{ cwd: '/repo-root' }
)
})
it('normalizes reviewer avatars from REST pull request payloads', async () => {
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify({
number: 42,
title: 'Review me',
state: 'open',
html_url: 'https://github.com/acme/widgets/pull/42',
labels: [],
updated_at: '2026-03-28T00:00:00Z',
user: { login: 'author' },
draft: false,
requested_reviewers: [
{
login: 'AmethystLiang',
avatar_url: 'https://avatars.githubusercontent.com/u/1?v=4'
}
]
})
})
await expect(getWorkItem('/repo-root', 42, 'pr')).resolves.toMatchObject({
reviewRequests: [
{
login: 'AmethystLiang',
avatarUrl: 'https://avatars.githubusercontent.com/u/1?v=4'
}
]
})
})
})
describe('GitHub GraphQL rate-limit guard', () => {
+24 -8
View File
@@ -249,11 +249,12 @@ export async function getAuthenticatedViewer(): Promise<GitHubViewer | null> {
type MainWorkItem = Omit<GitHubWorkItem, 'repoId'>
const WORK_ITEM_PR_LIST_JSON_FIELDS =
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRepositoryOwner'
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRepositoryOwner,reviewRequests'
// Why: these fields are intentionally excluded from `gh pr list` because
// statusCheckRollup/review/merge metadata fan out into expensive GraphQL work
// across every row. Fetch them only for single-PR detail surfaces.
// statusCheckRollup/review decision/merge metadata fan out into expensive
// GraphQL work across every row. Requested reviewers are kept in the list
// payload because the Tasks table renders that column on first paint.
const WORK_ITEM_PR_DETAIL_JSON_FIELDS =
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRepositoryOwner,additions,deletions,changedFiles,reviewDecision,reviewRequests,latestReviews,assignees,statusCheckRollup,mergeable,mergeStateStatus,maintainerCanModify'
@@ -342,7 +343,12 @@ function userFromUnknown(
return {
login,
name: typeof raw.name === 'string' ? raw.name : null,
avatarUrl: typeof raw.avatarUrl === 'string' ? raw.avatarUrl : ''
avatarUrl:
typeof raw.avatarUrl === 'string'
? raw.avatarUrl
: typeof raw.avatar_url === 'string'
? raw.avatar_url
: ''
}
}
@@ -1769,7 +1775,15 @@ export async function getPRChecks(
if (ownerRepo) {
fallbackArgs.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`)
}
const { stdout } = await ghExecFileAsync(fallbackArgs, ghOptions)
const { stdout } = await ghExecFileAsync(fallbackArgs, ghOptions).catch((err: unknown) => {
const { stderr } = extractExecError(err)
// Why: `gh pr checks` exits non-zero when a PR genuinely has no check
// runs yet. Treat that as an empty optional section, not a load failure.
if (stderr.toLowerCase().includes('no checks reported')) {
return { stdout: '[]', stderr }
}
throw err
})
const data = JSON.parse(stdout) as { name: string; state: string; link: string }[]
return data.map((d) => ({
name: d.name,
@@ -1805,7 +1819,8 @@ export async function getPRChecks(
}[]
}
if (data.check_runs.length === 0) {
return fallbackToPRChecks()
const fallbackChecks = await fallbackToPRChecks()
return fallbackChecks
}
return data.check_runs.map((d) => ({
name: d.name,
@@ -1823,7 +1838,8 @@ export async function getPRChecks(
}
}
// Fallback: no branch provided, empty check-runs, or non-GitHub remote.
return fallbackToPRChecks()
const fallbackChecks = await fallbackToPRChecks()
return fallbackChecks
} catch (err) {
console.warn('getPRChecks failed:', err)
return []
@@ -2494,7 +2510,7 @@ export async function requestPRReviewers(
): Promise<{ ok: true } | { ok: false; error: string }> {
const logins = reviewers.map((reviewer) => reviewer.trim()).filter(Boolean)
if (logins.length === 0) {
return { ok: false, error: 'Enter at least one reviewer login' }
return { ok: false, error: 'Enter at least one reviewer' }
}
const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId))
const ownerRepo = await getOwnerRepo(repoPath, connectionId)
@@ -167,4 +167,73 @@ describe('getWorkItemDetails PR file viewed state', () => {
undefined
)
})
it('keeps PR details loadable when checks lookup fails', async () => {
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
getWorkItemMock.mockResolvedValueOnce({
id: 'pr:42',
type: 'pr',
number: 42,
title: 'Review files',
state: 'open',
url: 'https://github.com/stablyai/orca/pull/42',
labels: [],
updatedAt: '2026-04-01T00:00:00Z',
author: null
})
getOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' })
getPRCommentsMock.mockResolvedValue([])
getPRChecksMock.mockRejectedValue(
Object.assign(new Error('Command failed: gh pr checks 42'), {
stderr: "no checks reported on the 'codex/keybindings-toml' branch\n",
stdout: ''
})
)
ghExecFileAsyncMock.mockImplementation((args: string[]) => {
const query = args.find((arg) => arg.startsWith('query=')) ?? ''
if (query.includes('viewerViewedState')) {
return Promise.resolve({
stdout: JSON.stringify({
data: {
repository: {
pullRequest: {
id: 'PR_kwDO123',
files: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }
}
}
}
})
})
}
if (query.includes('participants')) {
return Promise.resolve({
stdout: JSON.stringify({
data: { repository: { pullRequest: { participants: { nodes: [] } } } }
})
})
}
const endpoint = args.find((arg) => arg.startsWith('repos/')) ?? ''
if (endpoint === 'repos/stablyai/orca/pulls/42') {
return Promise.resolve({
stdout: JSON.stringify({
body: 'PR body',
head: { sha: 'head-sha' },
base: { sha: 'base-sha' }
})
})
}
if (endpoint === 'repos/stablyai/orca/pulls/42/files?per_page=100') {
return Promise.resolve({ stdout: '[]' })
}
return Promise.reject(new Error(`unexpected gh call: ${args.join(' ')}`))
})
const details = await getWorkItemDetails('/repo-root', 42, 'pr')
expect(details?.body).toBe('PR body')
expect(details?.headSha).toBe('head-sha')
expect(details?.checks).toEqual([])
expect(consoleWarnSpy).toHaveBeenCalledOnce()
consoleWarnSpy.mockRestore()
})
})
+18 -3
View File
@@ -8,6 +8,7 @@ import type {
GitHubPRFileViewedState,
GitHubWorkItem,
GitHubWorkItemDetails,
PRCheckDetail,
PRComment
} from '../../shared/types'
import {
@@ -670,6 +671,22 @@ async function getMentionParticipants(
return mergeGitHubUsers([...participants, ...graphQlUsers])
}
async function getPRChecksForDetails(
repoPath: string,
prNumber: number,
headSha: string | undefined,
connectionId?: string | null
): Promise<PRCheckDetail[]> {
try {
return await getPRChecks(repoPath, prNumber, headSha, null, undefined, connectionId)
} catch (err) {
// Why: checks are auxiliary PR metadata; a gh CLI edge case must not block
// the user from opening the PR review drawer and reading the files/comments.
console.warn('getWorkItemDetails PR checks failed:', err)
return []
}
}
export async function getWorkItemDetails(
repoPath: string,
number: number,
@@ -740,9 +757,7 @@ export async function getWorkItemDetails(
// Promise.all above, so there's no ordering requirement between them.
const [mentionParticipants, checks] = await Promise.all([
getMentionParticipants(repoPath, item, comments, participants, connectionId),
shas?.headSha
? getPRChecks(repoPath, item.number, shas.headSha, null, undefined, connectionId)
: getPRChecks(repoPath, item.number, undefined, null, undefined, connectionId)
getPRChecksForDetails(repoPath, item.number, shas?.headSha, connectionId)
])
return {
+80 -1
View File
@@ -346,7 +346,7 @@ describe('getEffectiveHooks', () => {
expect(result?.scripts.setup).not.toContain('old-version')
})
it('does not fall back to local settings hooks by default when yaml is missing', async () => {
it('falls back to legacy local hooks when policy is unset and yaml is missing', async () => {
const fs = await import('fs')
vi.mocked(fs.existsSync).mockReturnValue(false)
@@ -357,6 +357,26 @@ describe('getEffectiveHooks', () => {
})
const result = getEffectiveHooks(repo)
expect(result).toEqual({
scripts: {
setup: 'echo "local setup"',
archive: 'echo "local archive"'
}
})
})
it('does not fall back to local hooks when policy is explicitly shared-only', async () => {
const fs = await import('fs')
vi.mocked(fs.existsSync).mockReturnValue(false)
const { getEffectiveHooks } = await import('./hooks')
const repo = makeRepo({
mode: 'override',
commandSourcePolicy: 'shared-only',
scripts: { setup: 'echo "local setup"', archive: 'echo "local archive"' }
})
const result = getEffectiveHooks(repo)
expect(result).toBeNull()
})
@@ -438,6 +458,21 @@ describe('getEffectiveHooks', () => {
})
})
it('does not fall back to legacy local hooks when yaml exists without supported hooks', async () => {
const fs = await import('fs')
vi.mocked(fs.existsSync).mockReturnValue(true)
vi.mocked(fs.readFileSync).mockReturnValue('futureFeature: enabled\n')
const { getEffectiveHooks } = await import('./hooks')
const repo = makeRepo({
mode: 'override',
scripts: { setup: 'echo "legacy setup"', archive: 'echo "legacy archive"' }
})
const result = getEffectiveHooks(repo)
expect(result).toBeNull()
})
it('treats legacy shared-first policy as orca.yaml only', async () => {
const fs = await import('fs')
vi.mocked(fs.existsSync).mockReturnValue(true)
@@ -468,6 +503,50 @@ describe('getEffectiveHooks', () => {
expect(result).toBeNull()
})
it('falls back to legacy local setup source only when yaml is missing', async () => {
const fs = await import('fs')
vi.mocked(fs.existsSync).mockReturnValue(false)
const { getSetupCommandSource } = await import('./hooks')
const repo = makeRepo({
mode: 'override',
scripts: { setup: 'echo "legacy setup"', archive: '' }
})
const result = getSetupCommandSource(repo)
expect(result).toEqual({ source: 'local', command: 'echo "legacy setup"' })
})
it('does not use legacy local setup source when yaml omits setup', async () => {
const fs = await import('fs')
vi.mocked(fs.existsSync).mockReturnValue(true)
vi.mocked(fs.readFileSync).mockReturnValue('scripts:\n archive: |\n echo "yaml archive"\n')
const { getSetupCommandSource } = await import('./hooks')
const repo = makeRepo({
mode: 'override',
scripts: { setup: 'echo "legacy setup"', archive: '' }
})
const result = getSetupCommandSource(repo)
expect(result).toBeNull()
})
it('does not use legacy local setup source when yaml exists without supported hooks', async () => {
const fs = await import('fs')
vi.mocked(fs.existsSync).mockReturnValue(true)
vi.mocked(fs.readFileSync).mockReturnValue('futureFeature: enabled\n')
const { getSetupCommandSource } = await import('./hooks')
const repo = makeRepo({
mode: 'override',
scripts: { setup: 'echo "legacy setup"', archive: '' }
})
const result = getSetupCommandSource(repo)
expect(result).toBeNull()
})
})
describe('runHook', () => {
+40 -7
View File
@@ -267,7 +267,8 @@ function ensureOrcaDirIgnored(repoPath: string): void {
function getEffectiveHookScript(
yamlScript: string | undefined,
localScript: string | undefined,
policy: HookCommandSourcePolicy
policy: HookCommandSourcePolicy,
legacyFallback: boolean
): string | undefined {
const shared = yamlScript?.trim()
const local = localScript?.trim()
@@ -280,16 +281,38 @@ function getEffectiveHookScript(
return [shared, local].filter(Boolean).join('\n') || undefined
}
// Why: existing users persisted local setup/archive scripts before
// commandSourcePolicy existed. Without a fallback the new default
// 'shared-only' would silently drop those scripts when orca.yaml is missing.
if (legacyFallback) {
return shared || local || undefined
}
return shared || undefined
}
function hasLegacyLocalScripts(repo: Repo): boolean {
return Boolean(
repo.hookSettings?.scripts.setup?.trim() || repo.hookSettings?.scripts.archive?.trim()
)
}
export function getEffectiveHooks(repo: Repo, worktreePath?: string): OrcaHooks | null {
const yamlHooks = loadHooks(worktreePath ?? repo.path)
const hooksRoot = worktreePath ?? repo.path
const yamlHooks = loadHooks(hooksRoot)
const yamlFileExists = hasHooksFile(hooksRoot)
const localSetup = repo.hookSettings?.scripts.setup
const localArchive = repo.hookSettings?.scripts.archive
const policy = normalizeHookCommandSourcePolicy(repo.hookSettings?.commandSourcePolicy)
const setup = getEffectiveHookScript(yamlHooks?.scripts.setup, localSetup, policy)
const archive = getEffectiveHookScript(yamlHooks?.scripts.archive, localArchive, policy)
const rawPolicy = repo.hookSettings?.commandSourcePolicy
const policy = normalizeHookCommandSourcePolicy(rawPolicy)
const legacyFallback = rawPolicy === undefined && !yamlFileExists && hasLegacyLocalScripts(repo)
const setup = getEffectiveHookScript(yamlHooks?.scripts.setup, localSetup, policy, legacyFallback)
const archive = getEffectiveHookScript(
yamlHooks?.scripts.archive,
localArchive,
policy,
legacyFallback
)
if (!setup && !archive) {
return null
@@ -330,9 +353,13 @@ export function getSetupCommandSource(
repo: Repo,
worktreePath?: string
): { source: 'yaml' | 'local' | 'both'; command: string } | null {
const yamlSetup = loadHooks(worktreePath ?? repo.path)?.scripts.setup?.trim()
const hooksRoot = worktreePath ?? repo.path
const yamlHooks = loadHooks(hooksRoot)
const yamlFileExists = hasHooksFile(hooksRoot)
const yamlSetup = yamlHooks?.scripts.setup?.trim()
const localSetup = repo.hookSettings?.scripts.setup?.trim()
const policy = normalizeHookCommandSourcePolicy(repo.hookSettings?.commandSourcePolicy)
const rawPolicy = repo.hookSettings?.commandSourcePolicy
const policy = normalizeHookCommandSourcePolicy(rawPolicy)
if (policy === 'local-only') {
return localSetup ? { source: 'local', command: localSetup } : null
@@ -346,6 +373,12 @@ export function getSetupCommandSource(
return { source: 'yaml', command: yamlSetup }
}
// Why: pre-policy persisted local setup scripts must keep working when
// orca.yaml is missing; otherwise upgrading silently drops the legacy hook.
if (rawPolicy === undefined && !yamlFileExists && localSetup && hasLegacyLocalScripts(repo)) {
return { source: 'local', command: localSetup }
}
return null
}
+12 -7
View File
@@ -108,6 +108,9 @@ let watcherShutdownDone = false
let automations: AutomationService | null = null
const isServeMode = process.argv.includes('--serve')
const devInstanceIdentity = getDevInstanceIdentity(is.dev)
const devAgentHookEndpointNamespace = devInstanceIdentity.isDev
? devInstanceIdentity.appUserModelId
: undefined
installUncaughtPipeErrorGuard()
// Why: propagate the Orca app version into `process.env` so PTY-env
@@ -164,12 +167,11 @@ function focusExistingWindow(): void {
//
// Why skip in dev: engineers routinely run `pnpm dev` in parallel from
// multiple worktrees while shipping features, and the lock makes the second
// `pnpm dev` exit silently. In dev we accept that `orca-runtime.json` and
// `endpoint.env` may race (the bundled `orca-dev` CLI / agent hooks route
// to whichever instance wrote last). The dev build is not used for real
// agent work, so that routing ambiguity is acceptable. Packaged Orca keeps
// the lock to protect against the corruption documented in PR #1326 /
// issue #1312.
// `pnpm dev` exit silently. In dev we accept that `orca-runtime.json` may race
// (the bundled `orca-dev` CLI routes to whichever instance wrote last). Agent
// hook endpoint files are namespaced per dev instance when the hook server
// starts below. Packaged Orca keeps the lock to protect against the corruption
// documented in PR #1326 / issue #1312.
const hasSingleInstanceLock =
is.dev && !isServeMode ? true : acquireSingleInstanceLock(app, focusExistingWindow)
if (!hasSingleInstanceLock) {
@@ -787,6 +789,7 @@ app.whenReady().then(async () => {
})
runtime = runtimeService
automations = new AutomationService(store, { claudeUsage, codexUsage })
runtimeService.setAutomationService(automations)
runtimeService.setAccountServices({ claudeAccounts, codexAccounts, rateLimits })
runtimeService.setCommitMessageAgentEnvironmentResolvers({
prepareForCodexLaunch: () =>
@@ -941,7 +944,9 @@ app.whenReady().then(async () => {
env: app.isPackaged ? 'production' : 'development',
// Why: hooks source this endpoint file at invocation time, so old PTY
// env still reaches the current Orca process after an app restart.
userDataPath: app.getPath('userData')
// Dev uses a namespace because all worktrees share `orca-dev`.
userDataPath: app.getPath('userData'),
endpointNamespace: devAgentHookEndpointNamespace
}),
onDaemonError: (error) => {
console.error('[daemon] Failed to start daemon PTY provider, falling back to local:', error)
+34
View File
@@ -580,6 +580,40 @@ describe('registerPtyHandlers', () => {
expect(env.ORCA_AGENT_HOOK_TOKEN).toBe('agent-token')
})
it('strips stale inherited hook receiver env before injecting this runtime', async () => {
const env = await spawnAndGetEnv({
ORCA_AGENT_HOOK_PORT: '1111',
ORCA_AGENT_HOOK_TOKEN: 'stale-token',
ORCA_AGENT_HOOK_ENV: 'production',
ORCA_AGENT_HOOK_VERSION: 'stale-version',
ORCA_AGENT_HOOK_ENDPOINT: '/tmp/stale-endpoint.env'
})
expect(env.ORCA_AGENT_HOOK_PORT).toBe('5678')
expect(env.ORCA_AGENT_HOOK_TOKEN).toBe('agent-token')
expect(env.ORCA_AGENT_HOOK_ENV).toBeUndefined()
expect(env.ORCA_AGENT_HOOK_VERSION).toBeUndefined()
expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBeUndefined()
})
it('does not leak inherited hook receiver env if the hook server is unavailable', async () => {
buildAgentHookEnvMock.mockReturnValueOnce({})
const env = await spawnAndGetEnv({
ORCA_AGENT_HOOK_PORT: '1111',
ORCA_AGENT_HOOK_TOKEN: 'stale-token',
ORCA_AGENT_HOOK_ENV: 'production',
ORCA_AGENT_HOOK_VERSION: 'stale-version',
ORCA_AGENT_HOOK_ENDPOINT: '/tmp/stale-endpoint.env'
})
expect(env.ORCA_AGENT_HOOK_PORT).toBeUndefined()
expect(env.ORCA_AGENT_HOOK_TOKEN).toBeUndefined()
expect(env.ORCA_AGENT_HOOK_ENV).toBeUndefined()
expect(env.ORCA_AGENT_HOOK_VERSION).toBeUndefined()
expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBeUndefined()
})
it('prepends local git/gh attribution shims when attribution is enabled', async () => {
const env = await spawnAndGetEnv(undefined, undefined, undefined, () => ({
enableGitHubAttribution: true
+13 -6
View File
@@ -80,6 +80,14 @@ const ptyPaneKey = new Map<string, string>()
// Kept in lock-step with ptyPaneKey via the same spawn and teardown sites.
const paneKeyPtyId = new Map<string, string>()
const AGENT_HOOK_RUNTIME_ENV_KEYS = [
'ORCA_AGENT_HOOK_PORT',
'ORCA_AGENT_HOOK_TOKEN',
'ORCA_AGENT_HOOK_ENV',
'ORCA_AGENT_HOOK_VERSION',
'ORCA_AGENT_HOOK_ENDPOINT'
] as const
export function getPtyIdForPaneKey(paneKey: string): string | undefined {
return paneKeyPtyId.get(paneKey)
}
@@ -305,12 +313,11 @@ export function buildPtyHostEnv(
// must inject the loopback receiver coordinates before the agent starts.
// Without these env vars the global hook config cannot map callbacks back
// to the correct Orca pane.
if (!opts.isPackaged) {
// Why: `pnpm dev` is often launched from an Orca-managed terminal, so
// process.env can already contain another instance's endpoint.env path.
// Dev PTYs must use their direct port/token only; otherwise hook scripts
// source the stale shared endpoint and report to the wrong app.
delete baseEnv.ORCA_AGENT_HOOK_ENDPOINT
// Why: nested Orca terminals can inherit another process's hook endpoint or
// token. Strip all hook runtime coordinates before injecting this PTY's fresh
// server values so callbacks route to the owning app/runtime.
for (const key of AGENT_HOOK_RUNTIME_ENV_KEYS) {
delete baseEnv[key]
}
Object.assign(baseEnv, agentHookServer.buildPtyEnv())
+84
View File
@@ -0,0 +1,84 @@
import { EventEmitter } from 'events'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { registerSshBrowseHandler } from './ssh-browse'
const { handleMock, removeHandlerMock } = vi.hoisted(() => ({
handleMock: vi.fn(),
removeHandlerMock: vi.fn()
}))
vi.mock('electron', () => ({
ipcMain: {
handle: handleMock,
removeHandler: removeHandlerMock
}
}))
type BrowseHandler = (
event: unknown,
args: { targetId: string; dirPath: string }
) => Promise<unknown>
function createMockChannel(): EventEmitter & { stderr: EventEmitter } {
return Object.assign(new EventEmitter(), {
stderr: new EventEmitter()
})
}
describe('registerSshBrowseHandler', () => {
let handler: BrowseHandler
beforeEach(() => {
handleMock.mockReset()
removeHandlerMock.mockReset()
handleMock.mockImplementation((_channel: string, registeredHandler: BrowseHandler) => {
handler = registeredHandler
})
})
it('bypasses remote ls aliases when listing a directory', async () => {
const channel = createMockChannel()
const exec = vi.fn().mockResolvedValue(channel)
const getConnectionManager = () => ({
getConnection: () => ({ exec })
})
registerSshBrowseHandler(getConnectionManager as never)
const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: '~' })
await Promise.resolve()
channel.emit('data', Buffer.from('/home/user\nsrc/\nREADME.md\nnotes file.txt\n'))
channel.emit('exit', 0)
channel.emit('close')
await expect(resultPromise).resolves.toEqual({
resolvedPath: '/home/user',
entries: [
{ name: 'src', isDirectory: true },
{ name: 'notes file.txt', isDirectory: false },
{ name: 'README.md', isDirectory: false }
]
})
expect(exec).toHaveBeenCalledWith('cd "$HOME" && pwd && command ls -1Ap')
})
it('escapes remote browse paths before invoking command ls', async () => {
const channel = createMockChannel()
const exec = vi.fn().mockResolvedValue(channel)
const getConnectionManager = () => ({
getConnection: () => ({ exec })
})
registerSshBrowseHandler(getConnectionManager as never)
const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: "/tmp/it's here" })
await Promise.resolve()
channel.emit('data', Buffer.from("/tmp/it's here\n"))
channel.emit('exit', 0)
channel.emit('close')
await expect(resultPromise).resolves.toEqual({
resolvedPath: "/tmp/it's here",
entries: []
})
expect(exec).toHaveBeenCalledWith("cd '/tmp/it'\\''s here' && pwd && command ls -1Ap")
})
})
+4 -4
View File
@@ -30,14 +30,14 @@ export function registerSshBrowseHandler(
throw new Error(`SSH connection "${args.targetId}" not found`)
}
// Why: using printf with a delimiter instead of ls avoids issues with
// filenames containing spaces or special characters. The -1 flag outputs
// one entry per line. The -p flag appends / to directories.
// Why: using one line per entry preserves filenames containing spaces.
// `command ls` bypasses user aliases/functions like `ls='eza ...'`.
// The -1 flag outputs one entry per line. The -p flag appends / to directories.
// We resolve ~ and get the absolute path via `cd <path> && pwd`.
// `cd` and `ls` are chained with `&&` so a failing `ls` (e.g. permission
// denied after a readable `cd ... && pwd`) propagates as a non-zero exit
// code rather than being indistinguishable from an empty directory.
const command = `cd ${shellEscape(args.dirPath)} && pwd && ls -1ap`
const command = `cd ${shellEscape(args.dirPath)} && pwd && command ls -1Ap`
const channel = await conn.exec(command)
return new Promise((resolve, reject) => {
+129 -122
View File
@@ -48,13 +48,6 @@ import { invalidateAuthorizedRootsCache } from './filesystem-auth'
import { createWorktreeSymlinks } from './worktree-symlinks'
import { normalizeSparseDirectories } from './sparse-checkout-directories'
async function readCommitSha(repoPath: string, ref: string): Promise<string> {
const { stdout } = await gitExecFileAsync(['rev-parse', '--verify', `${ref}^{commit}`], {
cwd: repoPath
})
return stdout.trim()
}
async function findRemoteForUrl(repoPath: string, remoteUrl: string): Promise<string | null> {
const target = parseGitHubOwnerRepo(remoteUrl)
try {
@@ -289,17 +282,50 @@ async function configureCreatedWorktreePushTargetSsh(
return target
}
async function resolveRemoteTrackingBaseSsh(
provider: SshGitProvider,
repoPath: string,
baseBranch: string
): Promise<RemoteTrackingBase | null> {
let remotes: string[]
try {
const { stdout } = await provider.exec(['remote'], repoPath)
remotes = stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
} catch {
return null
}
const remote = remotes
.filter((candidate) => baseBranch.startsWith(`${candidate}/`))
.sort((a, b) => b.length - a.length)[0]
if (!remote) {
return null
}
const branch = baseBranch.slice(remote.length + 1)
if (!branch) {
return null
}
return {
remote,
branch,
ref: `refs/remotes/${remote}/${branch}`,
base: baseBranch
}
}
export function notifyWorktreesChanged(mainWindow: BrowserWindow, repoId: string): void {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('worktrees:changed', { repoId })
}
}
// Why (§3.3): two-phase spinner. Main process fires `'fetching'` immediately
// after kicking off `git fetch` and `'creating'` after that fetch resolves
// (or is determined to be cache-fresh). Renderer swaps its spinner label in
// response; fallback is the static "Creating worktree..." label if no event
// arrives (e.g. renderer races destruction of the window).
// Why: two-phase spinner. Main process fires `'fetching'` before waiting on
// pre-create fetch work and `'creating'` immediately before `git worktree add`.
// Renderer swaps its spinner label in response; fallback is the static
// "Creating worktree..." label if no event arrives.
export function emitCreateWorktreeProgress(
mainWindow: BrowserWindow,
phase: 'fetching' | 'creating'
@@ -385,12 +411,33 @@ export async function createRemoteWorktree(
)
}
// Fetch latest
const remote = baseBranch.includes('/') ? baseBranch.split('/')[0] : 'origin'
try {
await provider.exec(['fetch', remote], repo.path)
} catch {
/* best-effort */
const remoteTrackingBase = await resolveRemoteTrackingBaseSsh(provider, repo.path, baseBranch)
if (remoteTrackingBase) {
try {
await provider.exec(
[
'fetch',
'--no-tags',
remoteTrackingBase.remote,
`+refs/heads/${remoteTrackingBase.branch}:${remoteTrackingBase.ref}`
],
repo.path
)
} catch {
throw new Error(
`Could not refresh base ref "${baseBranch}" from "${remoteTrackingBase.remote}". Check your network and try again.`
)
}
} else {
// Why: local or otherwise non-remote-tracking bases preserve legacy
// best-effort fetch behavior. Only remote-tracking bases must fail closed,
// because creating from them after a failed refresh silently makes stale worktrees.
const fallbackRemote = baseBranch.includes('/') ? baseBranch.split('/')[0] : 'origin'
try {
await provider.exec(['fetch', fallbackRemote], repo.path)
} catch {
/* best-effort */
}
}
let preparedPushTarget: GitPushTarget | undefined
@@ -524,10 +571,9 @@ export async function createLocalWorktree(
? sanitizeWorktreeDisplayName(args.displayName)
: undefined
// Why (§3.3): determine the base branch (and therefore the remote we need to
// fetch) FIRST, so the fetch can overlap all pre-create work below. Neither
// of these calls depends on the suffix loop / PR probe / branch-conflict
// resolution, so they are safe to hoist.
// Why: resolve the base before branch/path selection so remote-tracking bases
// can be refreshed before `git worktree add`. Creating first and repairing
// later races setup scripts, agents, and user edits.
const baseBranch = args.baseBranch || repo.worktreeBaseRef || getDefaultBaseRef(repo.path)
if (!baseBranch) {
// Why: getDefaultBaseRef may return null when none of origin/HEAD,
@@ -540,31 +586,23 @@ export async function createLocalWorktree(
)
}
let optimisticBase: RemoteTrackingBase | null = null
let optimisticFetchPromise: Promise<RemoteFetchResult> | null = null
let initialBaseStatus: CreateWorktreeResult['initialBaseStatus']
let remoteTrackingBase: RemoteTrackingBase | null = null
let remoteTrackingRefresh: {
base: RemoteTrackingBase
hadLocalBaseRef: boolean
promise: Promise<RemoteFetchResult>
} | null = null
let legacyFetchPromise: Promise<void> | null = null
if (runtime) {
optimisticBase = await runtime.resolveRemoteTrackingBase(repo.path, baseBranch)
if (optimisticBase) {
const hasLocalBaseRef = await runtime.hasRemoteTrackingRef(repo.path, optimisticBase)
if (hasLocalBaseRef) {
const isFresh = await runtime.isRemoteFetchFresh(repo.path, optimisticBase.remote)
if (!isFresh) {
optimisticFetchPromise = runtime.getOrStartRemoteFetch(repo.path, optimisticBase.remote)
}
} else {
emitCreateWorktreeProgress(mainWindow, 'fetching')
const result = await runtime.getOrStartRemoteFetch(repo.path, optimisticBase.remote)
if (!(await runtime.hasRemoteTrackingRef(repo.path, optimisticBase))) {
if (!result.ok) {
throw new Error(
`Could not fetch base ref "${baseBranch}" from "${optimisticBase.remote}". Check your network and try again.`
)
}
throw new Error(`Base ref "${baseBranch}" was not found after fetching.`)
}
remoteTrackingBase = await runtime.resolveRemoteTrackingBase(repo.path, baseBranch)
if (remoteTrackingBase) {
const hasLocalBaseRef = await runtime.hasRemoteTrackingRef(repo.path, remoteTrackingBase)
emitCreateWorktreeProgress(mainWindow, 'fetching')
remoteTrackingRefresh = {
base: remoteTrackingBase,
hadLocalBaseRef: hasLocalBaseRef,
promise: runtime.getOrStartRemoteTrackingBaseRefresh(repo.path, remoteTrackingBase)
}
} else {
// Why: when the base branch does not match a configured remote prefix
@@ -593,6 +631,40 @@ export async function createLocalWorktree(
const wslInfo = isWslPath(repo.path) ? parseWslPath(repo.path) : null
const wslHome = wslInfo ? getWslHome(wslInfo.distro) : null
const workspaceRoot = wslHome ? join(wslHome, 'orca', 'workspaces') : settings.workspaceDir
// Why: this validation does not depend on remote refs, so it can overlap a
// required remote-tracking base refresh.
const primarySetupScript = getEffectiveHooks(repo)?.scripts.setup
if (primarySetupScript) {
shouldRunSetupForCreate(repo, args.setupDecision)
}
const sparseDirectories = args.sparseCheckout
? normalizeSparseDirectories(args.sparseCheckout.directories)
: []
if (args.sparseCheckout && sparseDirectories.length === 0) {
throw new Error('Sparse checkout requires at least one repo-relative directory.')
}
let sparsePresetId: string | undefined
if (args.sparseCheckout?.presetId) {
const preset = store
.getSparsePresets(repo.id)
.find((entry) => entry.id === args.sparseCheckout?.presetId)
if (preset?.repoId === repo.id) {
try {
const presetDirectories = normalizeSparseDirectories(preset.directories)
// Why: use Set-based comparison so directory order does not affect
// attribution — matches the renderer's sparseDirectoriesMatch logic.
const presetSet = new Set(presetDirectories)
const directoriesMatch =
presetDirectories.length === sparseDirectories.length &&
sparseDirectories.every((entry) => presetSet.has(entry))
sparsePresetId = directoriesMatch ? preset.id : undefined
} catch {
// Why: corrupt preset data should not block creation or falsely label the new worktree.
}
}
}
let effectiveRequestedName = requestedName
let effectiveSanitizedName = sanitizedName
let branchName = ''
@@ -682,39 +754,18 @@ export async function createLocalWorktree(
)
}
// Why: `ask` is a pre-create choice gate, not a post-create side effect.
// Resolve it before mutating git state so missing UI input cannot strand
// a real worktree on disk while the renderer reports "create failed". The
// actual run/skip decision is recomputed after the worktree exists against
// the worktree-bound setup script.
const primarySetupScript = getEffectiveHooks(repo)?.scripts.setup
if (primarySetupScript) {
shouldRunSetupForCreate(repo, args.setupDecision)
}
const sparseDirectories = args.sparseCheckout
? normalizeSparseDirectories(args.sparseCheckout.directories)
: []
if (args.sparseCheckout && sparseDirectories.length === 0) {
throw new Error('Sparse checkout requires at least one repo-relative directory.')
}
let sparsePresetId: string | undefined
if (args.sparseCheckout?.presetId) {
const preset = store
.getSparsePresets(repo.id)
.find((entry) => entry.id === args.sparseCheckout?.presetId)
if (preset?.repoId === repo.id) {
try {
const presetDirectories = normalizeSparseDirectories(preset.directories)
// Why: use Set-based comparison so directory order does not affect
// attribution — matches the renderer's sparseDirectoriesMatch logic.
const presetSet = new Set(presetDirectories)
const directoriesMatch =
presetDirectories.length === sparseDirectories.length &&
sparseDirectories.every((entry) => presetSet.has(entry))
sparsePresetId = directoriesMatch ? preset.id : undefined
} catch {
// Why: corrupt preset data should not block creation or falsely label the new worktree.
}
if (remoteTrackingRefresh) {
const result = await remoteTrackingRefresh.promise
if (!result.ok) {
throw new Error(
`Could not refresh base ref "${baseBranch}" from "${remoteTrackingRefresh.base.remote}". Check your network and try again.`
)
}
if (
!remoteTrackingRefresh.hadLocalBaseRef &&
!(await runtime?.hasRemoteTrackingRef(repo.path, remoteTrackingRefresh.base))
) {
throw new Error(`Base ref "${baseBranch}" was not found after fetching.`)
}
}
@@ -860,53 +911,9 @@ export async function createLocalWorktree(
}
}
if (runtime && optimisticBase && optimisticFetchPromise) {
initialBaseStatus = {
repoId: repo.id,
worktreeId,
status: 'checking',
base: optimisticBase.base,
remote: optimisticBase.remote
}
runtime.emitWorktreeBaseStatus(initialBaseStatus)
// Why: record the reconcile token BEFORE the rev-parse await so a racing
// worktree remove during the await isn't a no-op (its
// clearOptimisticReconcileToken would then run before the token exists,
// letting the post-await record install a fresh token whose reconcile
// would re-populate base status for a worktree that no longer exists).
const token = runtime.recordOptimisticReconcileToken(worktreeId)
try {
const createdBaseSha = await readCommitSha(created.path, 'HEAD')
void runtime
.reconcileWorktreeBaseStatus({
repoId: repo.id,
repoPath: repo.path,
worktreeId,
base: optimisticBase,
branchName,
createdBaseSha,
token,
fetchPromise: optimisticFetchPromise
})
.catch((error) => {
console.warn(`[worktree-base-status] reconcile failed for ${worktreeId}:`, error)
})
} catch (error) {
console.warn(`[worktree-base-status] failed to read created base for ${worktreeId}:`, error)
runtime.emitWorktreeBaseStatus({
repoId: repo.id,
worktreeId,
status: 'unknown',
base: optimisticBase.base,
remote: optimisticBase.remote
})
}
}
notifyWorktreesChanged(mainWindow, repo.id)
return {
worktree,
...(setup ? { setup } : {}),
...(initialBaseStatus ? { initialBaseStatus } : {})
...(setup ? { setup } : {})
}
}
+1 -1
View File
@@ -186,7 +186,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => {
const runtimeStub = {
resolveRemoteTrackingBase: vi.fn().mockResolvedValue(null),
hasRemoteTrackingRef: vi.fn().mockResolvedValue(false),
isRemoteFetchFresh: vi.fn().mockResolvedValue(false),
getOrStartRemoteTrackingBaseRefresh: vi.fn().mockResolvedValue({ ok: true }),
getOrStartRemoteFetch: vi.fn().mockResolvedValue({ ok: true }),
fetchRemoteWithCache: vi.fn().mockResolvedValue(undefined),
emitWorktreeBaseStatus: vi.fn(),
+126 -22
View File
@@ -176,7 +176,7 @@ describe('registerWorktreeHandlers', () => {
let runtimeStub: {
resolveRemoteTrackingBase: ReturnType<typeof vi.fn>
hasRemoteTrackingRef: ReturnType<typeof vi.fn>
isRemoteFetchFresh: ReturnType<typeof vi.fn>
getOrStartRemoteTrackingBaseRefresh: ReturnType<typeof vi.fn>
getOrStartRemoteFetch: ReturnType<typeof vi.fn>
fetchRemoteWithCache: ReturnType<typeof vi.fn>
emitWorktreeBaseStatus: ReturnType<typeof vi.fn>
@@ -319,7 +319,7 @@ describe('registerWorktreeHandlers', () => {
runtimeStub = {
resolveRemoteTrackingBase: vi.fn().mockResolvedValue(null),
hasRemoteTrackingRef: vi.fn().mockResolvedValue(false),
isRemoteFetchFresh: vi.fn().mockResolvedValue(false),
getOrStartRemoteTrackingBaseRefresh: vi.fn().mockResolvedValue({ ok: true }),
getOrStartRemoteFetch: vi.fn().mockResolvedValue({ ok: true }),
fetchRemoteWithCache: vi.fn().mockResolvedValue(undefined),
emitWorktreeBaseStatus: vi.fn(),
@@ -761,7 +761,12 @@ describe('registerWorktreeHandlers', () => {
worktreeBaseRef: 'origin/main'
}
const provider = {
exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }),
exec: vi.fn().mockImplementation(async (args: string[]) => {
if (args[0] === 'remote') {
return { stdout: 'origin\n', stderr: '' }
}
return { stdout: '', stderr: '' }
}),
addWorktree: vi.fn().mockResolvedValue(undefined),
listWorktrees: vi.fn().mockResolvedValue([
{
@@ -811,6 +816,50 @@ describe('registerWorktreeHandlers', () => {
})
})
it('does not create an SSH worktree when remote-tracking base refresh fails', async () => {
const repo = {
id: 'repo-ssh',
path: '/remote/repo',
displayName: 'ssh',
badgeColor: '#000',
addedAt: 0,
connectionId: 'conn-1',
worktreeBaseRef: 'origin/main'
}
const provider = {
exec: vi.fn().mockImplementation(async (args: string[]) => {
if (args[0] === 'remote') {
return { stdout: 'origin\n', stderr: '' }
}
if (args[0] === 'fetch') {
throw new Error('network unavailable')
}
return { stdout: '', stderr: '' }
}),
addWorktree: vi.fn(),
listWorktrees: vi.fn()
}
const mux = {
request: vi.fn().mockResolvedValue(undefined),
notify: vi.fn()
}
store.getRepos.mockReturnValue([repo])
store.getRepo.mockReturnValue(repo)
getSshGitProviderMock.mockReturnValue(provider)
getActiveMultiplexerMock.mockReturnValue(mux)
await expect(
handlers['worktrees:create'](null, {
repoId: 'repo-ssh',
name: 'improve-dashboard'
})
).rejects.toThrow(
'Could not refresh base ref "origin/main" from "origin". Check your network and try again.'
)
expect(provider.addWorktree).not.toHaveBeenCalled()
})
it('prunes stale child lineage after a successful SSH worktree scan proves the child is missing', async () => {
const repo = {
id: 'repo-ssh',
@@ -909,7 +958,7 @@ describe('registerWorktreeHandlers', () => {
)
})
it('does not await a cold fetch when the remote-tracking base exists locally', async () => {
it('awaits a cold refresh before creating from an existing remote-tracking base', async () => {
const remoteBase = {
remote: 'origin',
branch: 'main',
@@ -922,7 +971,7 @@ describe('registerWorktreeHandlers', () => {
})
runtimeStub.resolveRemoteTrackingBase.mockResolvedValue(remoteBase)
runtimeStub.hasRemoteTrackingRef.mockResolvedValue(true)
runtimeStub.getOrStartRemoteFetch.mockReturnValue(pendingFetch)
runtimeStub.getOrStartRemoteTrackingBaseRefresh.mockReturnValue(pendingFetch)
listWorktreesMock.mockResolvedValue([
{
path: '/workspace/improve-dashboard',
@@ -939,33 +988,88 @@ describe('registerWorktreeHandlers', () => {
name: 'improve-dashboard'
}) as Promise<unknown>
const result = await Promise.race([
createPromise,
new Promise((resolve) => setTimeout(() => resolve('timed-out'), 0))
const earlyResult = await Promise.race([
createPromise.then(() => 'resolved'),
new Promise((resolve) => setTimeout(() => resolve('pending'), 0))
])
expect(result).not.toBe('timed-out')
expect(earlyResult).toBe('pending')
expect(addWorktreeMock).not.toHaveBeenCalled()
expect(runtimeStub.getOrStartRemoteFetch).toHaveBeenCalledWith('/workspace/repo', 'origin')
expect(runtimeStub.getOrStartRemoteTrackingBaseRefresh).toHaveBeenCalledWith(
'/workspace/repo',
remoteBase
)
expect(runtimeStub.fetchRemoteWithCache).not.toHaveBeenCalled()
expect(runtimeStub.emitWorktreeBaseStatus).toHaveBeenCalledWith({
repoId: 'repo-1',
worktreeId: 'repo-1::/workspace/improve-dashboard',
status: 'checking',
base: 'origin/main',
remote: 'origin'
})
expect(runtimeStub.reconcileWorktreeBaseStatus).toHaveBeenCalledWith(
resolveFetch()
const result = await createPromise
expect(addWorktreeMock).toHaveBeenCalled()
expect(result).toEqual(
expect.objectContaining({
createdBaseSha: 'created-sha',
fetchPromise: pendingFetch
worktree: expect.objectContaining({ id: 'repo-1::/workspace/improve-dashboard' })
})
)
})
it('does not create when the pre-create remote-tracking refresh fails', async () => {
const remoteBase = {
remote: 'origin',
branch: 'main',
ref: 'refs/remotes/origin/main',
base: 'origin/main'
}
runtimeStub.resolveRemoteTrackingBase.mockResolvedValue(remoteBase)
runtimeStub.hasRemoteTrackingRef.mockResolvedValue(true)
runtimeStub.getOrStartRemoteTrackingBaseRefresh.mockResolvedValue({
ok: false,
errorKind: 'git_error'
})
await expect(
handlers['worktrees:create'](null, {
repoId: 'repo-1',
name: 'improve-dashboard'
})
).rejects.toThrow(
'Could not refresh base ref "origin/main" from "origin". Check your network and try again.'
)
expect(addWorktreeMock).not.toHaveBeenCalled()
})
it('refreshes before create even when the remote-tracking base was recently fetched', async () => {
const remoteBase = {
remote: 'origin',
branch: 'main',
ref: 'refs/remotes/origin/main',
base: 'origin/main'
}
runtimeStub.resolveRemoteTrackingBase.mockResolvedValue(remoteBase)
runtimeStub.hasRemoteTrackingRef.mockResolvedValue(true)
listWorktreesMock.mockResolvedValue([
{
path: '/workspace/improve-dashboard',
head: 'created-sha',
branch: 'improve-dashboard',
isBare: false,
isMainWorktree: false
}
])
gitExecFileAsyncMock.mockResolvedValue({ stdout: 'created-sha\n', stderr: '' })
const result = await handlers['worktrees:create'](null, {
repoId: 'repo-1',
name: 'improve-dashboard'
})
expect(runtimeStub.getOrStartRemoteTrackingBaseRefresh).toHaveBeenCalledWith(
'/workspace/repo',
remoteBase
)
expect(result).toEqual(
expect.objectContaining({
initialBaseStatus: expect.objectContaining({ status: 'checking', base: 'origin/main' })
worktree: expect.objectContaining({ id: 'repo-1::/workspace/improve-dashboard' })
})
)
resolveFetch()
})
it('throws a clear error when no default base ref can be resolved', async () => {
+135
View File
@@ -129,4 +129,139 @@ describe('OrcaRuntimeService.fetchRemoteWithCache', () => {
base: 'foo/bar/main'
})
})
it('refreshes a remote-tracking base with an exact no-tags refspec', async () => {
mockFetchResults([{ stdout: '', stderr: '' }])
const runtime = new OrcaRuntimeService(null)
await runtime.getOrStartRemoteTrackingBaseRefresh('/repo/f', {
remote: 'origin',
branch: 'main',
ref: 'refs/remotes/origin/main',
base: 'origin/main'
})
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
['fetch', '--no-tags', 'origin', '+refs/heads/main:refs/remotes/origin/main'],
{ cwd: '/repo/f' }
)
})
it('shares an in-flight remote-tracking base refresh without using freshness cache', async () => {
let resolveFetch!: () => void
const pending = new Promise<{ stdout: string; stderr: string }>((resolve) => {
resolveFetch = () => resolve({ stdout: '', stderr: '' })
})
mockFetchResults([pending, { stdout: '', stderr: '' }])
const runtime = new OrcaRuntimeService(null)
const base = {
remote: 'origin',
branch: 'main',
ref: 'refs/remotes/origin/main',
base: 'origin/main'
}
const first = runtime.getOrStartRemoteTrackingBaseRefresh('/repo/g', base)
const second = runtime.getOrStartRemoteTrackingBaseRefresh('/repo/g', base)
await new Promise((resolve) => setTimeout(resolve, 0))
expect(fetchCallCount()).toBe(1)
resolveFetch()
await Promise.all([first, second])
await runtime.getOrStartRemoteTrackingBaseRefresh('/repo/g', base)
expect(fetchCallCount()).toBe(2)
})
it('queues a full remote fetch behind an in-flight remote-tracking base refresh', async () => {
let resolveBaseFetch!: () => void
let resolveFullFetch!: () => void
const pendingBaseFetch = new Promise<{ stdout: string; stderr: string }>((resolve) => {
resolveBaseFetch = () => resolve({ stdout: '', stderr: '' })
})
const pendingFullFetch = new Promise<{ stdout: string; stderr: string }>((resolve) => {
resolveFullFetch = () => resolve({ stdout: '', stderr: '' })
})
mockFetchResults([pendingBaseFetch, pendingFullFetch])
const runtime = new OrcaRuntimeService(null)
const base = {
remote: 'origin',
branch: 'main',
ref: 'refs/remotes/origin/main',
base: 'origin/main'
}
const baseRefresh = runtime.getOrStartRemoteTrackingBaseRefresh('/repo/h', base)
await new Promise((resolve) => setTimeout(resolve, 0))
expect(fetchCallCount()).toBe(1)
const fullFetch = runtime.getOrStartRemoteFetch('/repo/h', 'origin')
await new Promise((resolve) => setTimeout(resolve, 0))
expect(fetchCallCount()).toBe(1)
resolveBaseFetch()
await vi.waitFor(() => expect(fetchCallCount()).toBe(2))
resolveFullFetch()
await expect(Promise.all([baseRefresh, fullFetch])).resolves.toEqual([
{ ok: true },
{ ok: true }
])
const fetchCalls = gitExecFileAsyncMock.mock.calls.filter(
([argv]) => Array.isArray(argv) && argv[0] === 'fetch'
)
expect(fetchCalls).toEqual([
[
['fetch', '--no-tags', 'origin', '+refs/heads/main:refs/remotes/origin/main'],
{ cwd: '/repo/h' }
],
[['fetch', 'origin'], { cwd: '/repo/h' }]
])
})
it('queues an exact base refresh behind an in-flight full remote fetch', async () => {
let resolveFullFetch!: () => void
let resolveBaseFetch!: () => void
const pendingFullFetch = new Promise<{ stdout: string; stderr: string }>((resolve) => {
resolveFullFetch = () => resolve({ stdout: '', stderr: '' })
})
const pendingBaseFetch = new Promise<{ stdout: string; stderr: string }>((resolve) => {
resolveBaseFetch = () => resolve({ stdout: '', stderr: '' })
})
mockFetchResults([pendingFullFetch, pendingBaseFetch])
const runtime = new OrcaRuntimeService(null)
const base = {
remote: 'origin',
branch: 'main',
ref: 'refs/remotes/origin/main',
base: 'origin/main'
}
const fullFetch = runtime.getOrStartRemoteFetch('/repo/i', 'origin')
await new Promise((resolve) => setTimeout(resolve, 0))
expect(fetchCallCount()).toBe(1)
const baseRefresh = runtime.getOrStartRemoteTrackingBaseRefresh('/repo/i', base)
await new Promise((resolve) => setTimeout(resolve, 0))
expect(fetchCallCount()).toBe(1)
resolveFullFetch()
await vi.waitFor(() => expect(fetchCallCount()).toBe(2))
resolveBaseFetch()
await expect(Promise.all([fullFetch, baseRefresh])).resolves.toEqual([
{ ok: true },
{ ok: true }
])
const fetchCalls = gitExecFileAsyncMock.mock.calls.filter(
([argv]) => Array.isArray(argv) && argv[0] === 'fetch'
)
expect(fetchCalls).toEqual([
[['fetch', 'origin'], { cwd: '/repo/i' }],
[
['fetch', '--no-tags', 'origin', '+refs/heads/main:refs/remotes/origin/main'],
{ cwd: '/repo/i' }
]
])
})
})
@@ -0,0 +1,141 @@
import { describe, expect, it, vi } from 'vitest'
import { OrcaRuntimeService } from './orca-runtime'
import type { Automation } from '../../shared/automations-types'
import type { Repo } from '../../shared/types'
const repo: Repo = {
id: 'repo-1',
path: '/tmp/orca',
displayName: 'orca',
badgeColor: 'blue',
addedAt: 1,
kind: 'git'
}
function makeStore(existingAutomations: Automation[] = []) {
return {
getRepos: vi.fn(() => [repo]),
createAutomation: vi.fn((input) => ({
id: 'auto-1',
executionTargetType: 'local',
executionTargetId: 'local',
schedulerOwner: 'local_host_service',
nextRunAt: 2,
missedRunPolicy: 'run_once_within_grace',
createdAt: 1,
updatedAt: 1,
...input
})),
listAutomations: vi.fn(() => existingAutomations),
listAutomationRuns: vi.fn(() => []),
updateAutomation: vi.fn((id, updates) => ({ ...existingAutomations[0], id, ...updates })),
deleteAutomation: vi.fn(),
getSettings: vi.fn(() => ({
workspaceDir: '/tmp',
nestWorkspaces: false,
refreshLocalBaseRefOnWorktreeCreate: false,
branchPrefix: '',
branchPrefixCustom: ''
})),
getAllWorktreeMeta: vi.fn(() => new Map()),
getWorktreeMeta: vi.fn(),
setWorktreeMeta: vi.fn(),
removeWorktreeMeta: vi.fn(),
getGitHubCache: vi.fn()
}
}
const existingAutomation = {
id: 'auto-1',
name: 'Daily review',
prompt: 'Review changes',
agentId: 'codex',
projectId: 'repo-1',
executionTargetType: 'local',
executionTargetId: 'local',
schedulerOwner: 'local_host_service',
workspaceMode: 'new_per_run',
workspaceId: null,
baseBranch: 'main',
timezone: 'UTC',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: 1,
enabled: true,
nextRunAt: 2,
missedRunPolicy: 'run_once_within_grace',
missedRunGraceMinutes: 720,
createdAt: 1,
updatedAt: 1
} satisfies Automation
describe('OrcaRuntimeService automation methods', () => {
it('creates repo-scoped automations through the shared store', async () => {
const store = makeStore()
const runtime = new OrcaRuntimeService(store as never)
const automation = await runtime.createAutomation({
name: 'Daily review',
prompt: 'Review changes',
agentId: 'codex',
repo: 'repo-1',
workspaceMode: 'new_per_run',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: 1
})
expect(store.createAutomation).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Daily review',
prompt: 'Review changes',
agentId: 'codex',
projectId: 'repo-1',
workspaceMode: 'new_per_run',
workspaceId: null
})
)
expect(automation.id).toBe('auto-1')
})
it('updates and deletes existing automations through the shared store', async () => {
const store = makeStore([existingAutomation])
const runtime = new OrcaRuntimeService(store as never)
const updated = await runtime.updateAutomation('auto-1', { enabled: false })
const removed = runtime.deleteAutomation('auto-1')
expect(store.updateAutomation).toHaveBeenCalledWith('auto-1', { enabled: false })
expect(updated).toMatchObject({
prompt: existingAutomation.prompt,
baseBranch: existingAutomation.baseBranch,
workspaceMode: existingAutomation.workspaceMode,
enabled: false
})
expect(store.deleteAutomation).toHaveBeenCalledWith('auto-1')
expect(removed).toEqual({ removed: true, id: 'auto-1' })
})
it('preserves explicit nullable fields in sparse automation updates', async () => {
const store = makeStore([existingAutomation])
const runtime = new OrcaRuntimeService(store as never)
await runtime.updateAutomation('auto-1', { baseBranch: null })
expect(store.updateAutomation).toHaveBeenCalledWith('auto-1', { baseBranch: null })
})
it('rejects repo-only updates for existing-workspace automations', async () => {
const existing = {
...existingAutomation,
workspaceMode: 'existing',
workspaceId: 'repo-1::/tmp/orca-worktree',
baseBranch: null
} satisfies Automation
const store = makeStore([existing])
const runtime = new OrcaRuntimeService(store as never)
await expect(runtime.updateAutomation('auto-1', { repo: 'repo-2' })).rejects.toThrow(
'Repo updates for existing-workspace automation require workspaceMode new_per_run.'
)
expect(store.updateAutomation).not.toHaveBeenCalled()
})
})
+278
View File
@@ -733,6 +733,97 @@ describe('OrcaRuntimeService', () => {
})
})
it('refreshes runtime remote-tracking bases before creating local worktrees', async () => {
const runtime = new OrcaRuntimeService(store)
const refresh = deferred<{ stdout: string; stderr: string }>()
const createdWorktree = {
path: '/tmp/workspaces/cli-fresh-base',
head: 'def',
branch: 'cli-fresh-base',
isBare: false,
isMainWorktree: false
}
computeWorktreePathMock.mockReturnValue(createdWorktree.path)
ensurePathWithinWorkspaceMock.mockReturnValue(createdWorktree.path)
vi.mocked(listWorktrees).mockResolvedValueOnce([createdWorktree])
const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args) => {
if (args[0] === 'remote') {
return { stdout: 'origin\n', stderr: '' }
}
if (args[0] === 'rev-parse' && args.includes('--git-common-dir')) {
return { stdout: '/tmp/repo/.git\n', stderr: '' }
}
if (args[0] === 'rev-parse' && args[1] === '--verify') {
return { stdout: 'base-sha\n', stderr: '' }
}
if (args[0] === 'fetch') {
return refresh.promise
}
return { stdout: '', stderr: '' }
})
try {
const createPromise = runtime.createManagedWorktree({
repoSelector: 'id:repo-1',
name: 'cli-fresh-base'
})
await vi.waitFor(() => {
expect(gitSpy).toHaveBeenCalledWith(
['fetch', '--no-tags', 'origin', '+refs/heads/main:refs/remotes/origin/main'],
{ cwd: TEST_REPO_PATH }
)
})
expect(addWorktree).not.toHaveBeenCalled()
refresh.resolve({ stdout: '', stderr: '' })
const result = await createPromise
expect(addWorktree).toHaveBeenCalledWith(
TEST_REPO_PATH,
createdWorktree.path,
'cli-fresh-base',
'origin/main',
false
)
expect(result.worktree).toMatchObject({ path: createdWorktree.path })
} finally {
gitSpy.mockRestore()
}
})
it('does not create runtime local worktrees when remote-tracking base refresh fails', async () => {
const runtime = new OrcaRuntimeService(store)
const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args) => {
if (args[0] === 'remote') {
return { stdout: 'origin\n', stderr: '' }
}
if (args[0] === 'rev-parse' && args.includes('--git-common-dir')) {
return { stdout: '/tmp/repo/.git\n', stderr: '' }
}
if (args[0] === 'rev-parse' && args[1] === '--verify') {
return { stdout: 'base-sha\n', stderr: '' }
}
if (args[0] === 'fetch') {
throw new Error('network unavailable')
}
return { stdout: '', stderr: '' }
})
try {
await expect(
runtime.createManagedWorktree({
repoSelector: 'id:repo-1',
name: 'cli-refresh-fails'
})
).rejects.toThrow(
'Could not refresh base ref "origin/main" from "origin". Check your network and try again.'
)
expect(addWorktree).not.toHaveBeenCalled()
} finally {
gitSpy.mockRestore()
}
})
it('creates a branchNameOverride worktree from the selected matching remote base ref', async () => {
const runtime = new OrcaRuntimeService(store)
vi.spyOn(gitRunner, 'gitExecFileAsync').mockResolvedValue({ stdout: '', stderr: '' })
@@ -5485,6 +5576,193 @@ describe('OrcaRuntimeService', () => {
expect(worktreeBaseStatus).not.toHaveBeenCalled()
})
const remoteTrackingBase = {
remote: 'origin',
branch: 'main',
ref: 'refs/remotes/origin/main',
base: 'origin/main'
}
function createReconcileRuntime(): {
runtime: OrcaRuntimeService
worktreeBaseStatus: ReturnType<typeof vi.fn>
} {
const worktreeBaseStatus = vi.fn()
const runtime = new OrcaRuntimeService(store)
runtime.setNotifier({
worktreeBaseStatus,
worktreeRemoteBranchConflict: vi.fn()
} as never)
return { runtime, worktreeBaseStatus }
}
function mockReconcileGit(options: {
postFetchSha?: string
ancestor?: boolean
baseRefMissing?: boolean
}) {
const { postFetchSha = 'new-base-sha', ancestor = true, baseRefMissing = false } = options
return vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args, options) => {
const command = args as string[]
const cwd = (options as { cwd?: string } | undefined)?.cwd
if (
cwd === TEST_REPO_PATH &&
command[0] === 'rev-parse' &&
command[1] === '--verify' &&
command[2] === `${remoteTrackingBase.ref}^{commit}`
) {
if (baseRefMissing) {
throw new Error('missing base ref')
}
return { stdout: `${postFetchSha}\n`, stderr: '' }
}
if (cwd === TEST_REPO_PATH && command[0] === 'merge-base') {
if (!ancestor) {
throw new Error('not ancestor')
}
return { stdout: '', stderr: '' }
}
if (cwd === TEST_REPO_PATH && command[0] === 'rev-list') {
return { stdout: '3\n', stderr: '' }
}
if (cwd === TEST_REPO_PATH && command[0] === 'log') {
return { stdout: 'base commit 3\nbase commit 2\n', stderr: '' }
}
if (cwd === TEST_REPO_PATH && command[0] === 'config') {
throw new Error('config missing')
}
if (
cwd === TEST_REPO_PATH &&
command[0] === 'rev-parse' &&
command[1] === '--verify' &&
command[2] === 'refs/remotes/origin/feature^{commit}'
) {
throw new Error('no publish branch conflict')
}
throw new Error(`unexpected git command: ${command.join(' ')}`)
})
}
async function reconcileWithToken(runtime: OrcaRuntimeService, token: string): Promise<void> {
await runtime.reconcileWorktreeBaseStatus({
repoId: TEST_REPO_ID,
repoPath: TEST_REPO_PATH,
worktreeId: TEST_WORKTREE_ID,
base: remoteTrackingBase,
branchName: 'feature',
createdBaseSha: 'created-base-sha',
token,
fetchPromise: Promise.resolve({ ok: true })
})
}
it('emits drift without mutating when the fetched base fast-forwards created HEAD', async () => {
const { runtime, worktreeBaseStatus } = createReconcileRuntime()
const token = runtime.recordOptimisticReconcileToken(TEST_WORKTREE_ID)
const gitSpy = mockReconcileGit({})
try {
await reconcileWithToken(runtime, token)
expect(gitSpy).not.toHaveBeenCalledWith(['reset', '--hard', 'new-base-sha'], {
cwd: TEST_WORKTREE_PATH
})
expect(worktreeBaseStatus).toHaveBeenCalledWith(
expect.objectContaining({
status: 'drift',
behind: 3,
recentSubjects: ['base commit 3', 'base commit 2']
})
)
} finally {
gitSpy.mockRestore()
}
})
it('emits current when the fetched base still matches created HEAD', async () => {
const { runtime, worktreeBaseStatus } = createReconcileRuntime()
const token = runtime.recordOptimisticReconcileToken(TEST_WORKTREE_ID)
const gitSpy = mockReconcileGit({ postFetchSha: 'created-base-sha' })
try {
await reconcileWithToken(runtime, token)
expect(worktreeBaseStatus).toHaveBeenCalledWith({
repoId: TEST_REPO_ID,
worktreeId: TEST_WORKTREE_ID,
base: 'origin/main',
remote: 'origin',
status: 'current'
})
} finally {
gitSpy.mockRestore()
}
})
it('emits base_changed without mutation when the fetched base rewrote history', async () => {
const { runtime, worktreeBaseStatus } = createReconcileRuntime()
const token = runtime.recordOptimisticReconcileToken(TEST_WORKTREE_ID)
const gitSpy = mockReconcileGit({ ancestor: false })
try {
await reconcileWithToken(runtime, token)
expect(gitSpy).not.toHaveBeenCalledWith(['reset', '--hard', 'new-base-sha'], {
cwd: TEST_WORKTREE_PATH
})
expect(worktreeBaseStatus).toHaveBeenCalledWith(
expect.objectContaining({ status: 'base_changed' })
)
} finally {
gitSpy.mockRestore()
}
})
it('skips stale-token reconciles without mutating or emitting stale status', async () => {
const stale = createReconcileRuntime()
const staleToken = stale.runtime.recordOptimisticReconcileToken(TEST_WORKTREE_ID)
stale.runtime.recordOptimisticReconcileToken(TEST_WORKTREE_ID)
const staleGitSpy = mockReconcileGit({})
try {
await reconcileWithToken(stale.runtime, staleToken)
expect(stale.worktreeBaseStatus).not.toHaveBeenCalled()
expect(staleGitSpy).not.toHaveBeenCalled()
} finally {
staleGitSpy.mockRestore()
}
})
it('emits unknown without mutation when fetch fails or the base ref is missing', async () => {
const fetchFailure = createReconcileRuntime()
const fetchFailureToken = fetchFailure.runtime.recordOptimisticReconcileToken(TEST_WORKTREE_ID)
await fetchFailure.runtime.reconcileWorktreeBaseStatus({
repoId: TEST_REPO_ID,
repoPath: TEST_REPO_PATH,
worktreeId: TEST_WORKTREE_ID,
base: remoteTrackingBase,
branchName: 'feature',
createdBaseSha: 'created-base-sha',
token: fetchFailureToken,
fetchPromise: Promise.resolve({ ok: false, errorKind: 'git_error' })
})
expect(fetchFailure.worktreeBaseStatus).toHaveBeenCalledWith(
expect.objectContaining({ status: 'unknown' })
)
const missingBase = createReconcileRuntime()
const missingBaseToken = missingBase.runtime.recordOptimisticReconcileToken(TEST_WORKTREE_ID)
const gitSpy = mockReconcileGit({ baseRefMissing: true })
try {
await reconcileWithToken(missingBase.runtime, missingBaseToken)
expect(gitSpy).not.toHaveBeenCalledWith(['reset', '--hard', 'new-base-sha'], {
cwd: TEST_WORKTREE_PATH
})
expect(missingBase.worktreeBaseStatus).toHaveBeenCalledWith(
expect.objectContaining({ status: 'unknown' })
)
} finally {
gitSpy.mockRestore()
}
})
it('invalidates the filesystem-auth cache after CLI worktree creation', async () => {
// Reproduces: CLI-created worktrees fail with "Access denied: unknown
// repository or worktree path" because the filesystem-auth cache was
+304 -44
View File
@@ -14,6 +14,13 @@ import { basename, isAbsolute, join } from 'path'
import { mkdir, readdir, rm, stat } from 'fs/promises'
import { OrchestrationDb } from './orchestration/db'
import { formatMessagesForInjection } from './orchestration/formatter'
import type {
Automation,
AutomationCreateInput,
AutomationRun,
AutomationUpdateInput,
AutomationWorkspaceMode
} from '../../shared/automations-types'
import type {
BaseRefSearchResult,
CreateWorktreeResult,
@@ -91,6 +98,7 @@ import type {
BrowserTabInfo,
BrowserScreencastResult
} from '../../shared/runtime-types'
import type { AutomationService } from '../automations/service'
import { RuntimeBrowserCommands } from './orca-runtime-browser'
import { RuntimeFileCommands } from './orca-runtime-files'
import { RuntimeGitCommands } from './orca-runtime-git'
@@ -323,6 +331,11 @@ type RuntimeStore = {
getWorkspaceSession?: Store['getWorkspaceSession']
getUI?: Store['getUI']
updateUI?: Store['updateUI']
listAutomations?: Store['listAutomations']
listAutomationRuns?: Store['listAutomationRuns']
createAutomation?: Store['createAutomation']
updateAutomation?: Store['updateAutomation']
deleteAutomation?: Store['deleteAutomation']
getSettings(): {
workspaceDir: string
nestWorkspaces: boolean
@@ -339,6 +352,31 @@ type RuntimeStore = {
updateSettings?: (updates: Partial<GlobalSettings>) => unknown
}
export type RuntimeAutomationCreateInput = Omit<
AutomationCreateInput,
'projectId' | 'workspaceId' | 'workspaceMode' | 'timezone'
> & {
repo?: string
workspace?: string
workspaceMode?: AutomationWorkspaceMode
timezone?: string
}
export type RuntimeAutomationUpdateInput = Omit<
AutomationUpdateInput,
'projectId' | 'workspaceId'
> & {
repo?: string
workspace?: string
}
function hasRuntimeAutomationUpdateValue<K extends keyof RuntimeAutomationUpdateInput>(
updates: RuntimeAutomationUpdateInput,
key: K
): boolean {
return Object.hasOwn(updates, key) && updates[key] !== undefined
}
type RuntimeLeafRecord = RuntimeSyncedLeaf & {
ptyGeneration: number
connected: boolean
@@ -887,16 +925,21 @@ export class OrcaRuntimeService {
// vice-versa. Keyed by `<repoPath>::<remote>` so multi-remote repos (even
// though v1 only uses `origin`) don't cross-contaminate. The in-flight Map
// also provides serialization — two concurrent callers share a single
// underlying `git fetch`. Lifecycle rules are enforced in
// `fetchRemoteWithCache` and MUST NOT be duplicated elsewhere:
// underlying `git fetch`. Full-remote fetch lifecycle rules:
// - entry inserted BEFORE await,
// - `.finally()` removes the entry on BOTH success and rejection,
// - timestamp written ONLY on success (rejection must not make the
// 30s freshness cache lie).
// A literal "insert before await / read-back after await" without these
// three rules wedges all future creates on the same repo after a single
// DNS hiccup until process restart (see §3.3 Lifecycle).
// three rules wedges future fetches on the same repo after a single
// DNS hiccup until process restart (see §3.3 Lifecycle). Exact base-ref
// refreshes share the in-flight rule but intentionally do not write the
// full-remote freshness timestamp.
private fetchInflight = new Map<string, Promise<RemoteFetchResult>>()
// Why: `git fetch origin` and `git fetch origin <refspec>` contend for the
// same repo remote/ref locks. This queue serializes all fetch shapes for one
// canonical repo+remote while still letting same-shape callers share promises.
private remoteFetchQueueTail = new Map<string, Promise<RemoteFetchResult>>()
private fetchLastCompletedAt = new Map<string, number>()
// Why: `getCanonicalFetchKey` is awaited from every freshness probe and
// every getOrStartRemoteFetch call. Without memoization the warm-cache hot
@@ -908,6 +951,7 @@ export class OrcaRuntimeService {
private readonly getLocalProviderFn: (() => IPtyProvider) | null
private accountServices: RuntimeAccountServices | null = null
private commitMessageAgentEnv: CommitMessageAgentEnvironmentResolvers | null = null
private automationService: AutomationService | null = null
private mobileDictation: {
id: string
owner: string
@@ -961,6 +1005,163 @@ export class OrcaRuntimeService {
return this.store.getUI()
}
listAutomations(): Automation[] {
if (!this.store?.listAutomations) {
throw new Error('runtime_unavailable')
}
return this.store.listAutomations()
}
listAutomationRuns(automationId?: string): AutomationRun[] {
if (!this.store?.listAutomationRuns) {
throw new Error('runtime_unavailable')
}
return this.store.listAutomationRuns(automationId)
}
showAutomation(id: string): Automation {
const automation = this.listAutomations().find((entry) => entry.id === id)
if (!automation) {
throw new Error('Automation not found.')
}
return automation
}
async createAutomation(input: RuntimeAutomationCreateInput): Promise<Automation> {
if (!this.store?.createAutomation) {
throw new Error('runtime_unavailable')
}
const target = await this.resolveAutomationTarget(input)
return this.store.createAutomation({
name: input.name,
prompt: input.prompt,
agentId: input.agentId,
projectId: target.projectId,
workspaceMode: target.workspaceMode,
workspaceId: target.workspaceId,
baseBranch: input.baseBranch,
timezone: input.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone,
rrule: input.rrule,
dtstart: input.dtstart,
enabled: input.enabled,
missedRunGraceMinutes: input.missedRunGraceMinutes
})
}
async updateAutomation(id: string, updates: RuntimeAutomationUpdateInput): Promise<Automation> {
if (!this.store?.updateAutomation) {
throw new Error('runtime_unavailable')
}
const current = this.showAutomation(id)
const patch: AutomationUpdateInput = {}
if (hasRuntimeAutomationUpdateValue(updates, 'name')) {
patch.name = updates.name
}
if (hasRuntimeAutomationUpdateValue(updates, 'prompt')) {
patch.prompt = updates.prompt
}
if (hasRuntimeAutomationUpdateValue(updates, 'agentId')) {
patch.agentId = updates.agentId
}
if (hasRuntimeAutomationUpdateValue(updates, 'baseBranch')) {
patch.baseBranch = updates.baseBranch
}
if (hasRuntimeAutomationUpdateValue(updates, 'timezone')) {
patch.timezone = updates.timezone
}
if (hasRuntimeAutomationUpdateValue(updates, 'rrule')) {
patch.rrule = updates.rrule
}
if (hasRuntimeAutomationUpdateValue(updates, 'dtstart')) {
patch.dtstart = updates.dtstart
}
if (hasRuntimeAutomationUpdateValue(updates, 'enabled')) {
patch.enabled = updates.enabled
}
if (hasRuntimeAutomationUpdateValue(updates, 'missedRunGraceMinutes')) {
patch.missedRunGraceMinutes = updates.missedRunGraceMinutes
}
const targetChanged =
hasRuntimeAutomationUpdateValue(updates, 'repo') ||
hasRuntimeAutomationUpdateValue(updates, 'workspace') ||
hasRuntimeAutomationUpdateValue(updates, 'workspaceMode')
if (targetChanged) {
const target = await this.resolveAutomationTarget(updates, current)
patch.projectId = target.projectId
patch.workspaceMode = target.workspaceMode
patch.workspaceId = target.workspaceId
}
return this.store.updateAutomation(id, patch)
}
deleteAutomation(id: string): { removed: boolean; id: string } {
if (!this.store?.deleteAutomation) {
throw new Error('runtime_unavailable')
}
this.showAutomation(id)
this.store.deleteAutomation(id)
return { removed: true, id }
}
async runAutomationNow(id: string): Promise<AutomationRun> {
if (!this.automationService) {
throw new Error('runtime_unavailable')
}
return await this.automationService.runNow(id)
}
private async resolveAutomationTarget(
input: {
repo?: string
workspace?: string
workspaceMode?: AutomationWorkspaceMode
baseBranch?: string | null
},
current?: Automation
): Promise<{
projectId: string
workspaceMode: AutomationWorkspaceMode
workspaceId?: string | null
}> {
const hasRepo = input.repo !== undefined
const hasWorkspace = input.workspace !== undefined
if (
current?.workspaceMode === 'existing' &&
hasRepo &&
!hasWorkspace &&
input.workspaceMode !== 'new_per_run'
) {
throw new Error(
'Repo updates for existing-workspace automation require workspaceMode new_per_run.'
)
}
const workspace = input.workspace ? await this.showManagedWorktree(input.workspace) : null
const repo = input.repo ? await this.showRepo(input.repo) : null
const workspaceMode =
input.workspaceMode ??
(workspace
? 'existing'
: input.repo && !current
? 'new_per_run'
: (current?.workspaceMode ?? 'new_per_run'))
if (workspaceMode === 'existing') {
const workspaceId = workspace?.id ?? current?.workspaceId
const projectId = workspace?.repoId ?? current?.projectId
if (repo && repo.id !== projectId) {
throw new Error('Selected workspace belongs to a different repo.')
}
if (!workspaceId || !projectId) {
throw new Error('Existing-workspace automation requires --workspace.')
}
return { projectId, workspaceMode, workspaceId }
}
const projectId = repo?.id ?? workspace?.repoId ?? current?.projectId
if (!projectId) {
throw new Error('Automation requires --repo or --workspace.')
}
return { projectId, workspaceMode: 'new_per_run', workspaceId: null }
}
// Why: lazy initialization — the DB path depends on Electron's userData
// which may not be finalized until after app.ready. Also allows unit tests
// to inject an in-memory DB without touching the filesystem.
@@ -977,6 +1178,10 @@ export class OrcaRuntimeService {
this._orchestrationDb = db
}
setAutomationService(service: AutomationService): void {
this.automationService = service
}
getRuntimeId(): string {
return this.runtimeId
}
@@ -5558,19 +5763,33 @@ export class OrcaRuntimeService {
const wslHome = wslInfo ? getWslHome(wslInfo.distro) : null
const workspaceRoot = wslHome ? join(wslHome, 'orca', 'workspaces') : settings.workspaceDir
worktreePath = ensurePathWithinWorkspace(worktreePath, workspaceRoot)
const remote = baseBranch.includes('/') ? baseBranch.split('/')[0] : 'origin'
// Why (§3.3 Lifecycle): route through the shared fetch cache so back-to-back
// CLI creates on the same repo don't each pay the round-trip, and so a
// subsequent dispatch probe within the 30s window reuses this result. The
// helper swallows rejection (log-and-proceed) so a DNS hiccup never wedges
// future creates and CLI creation stays usable offline — same intent as
// the previous try/catch around gitExecFileSync.
try {
await this.fetchRemoteWithCache(repo.path, remote)
} catch {
// Why: belt-and-suspenders. fetchRemoteWithCache already logs and does
// not throw; the outer try/catch guarantees create-path tolerance even
// if future refactors change that contract.
const remoteTrackingBase = await this.resolveRemoteTrackingBase(repo.path, baseBranch)
if (remoteTrackingBase) {
const hadLocalBaseRef = await this.hasRemoteTrackingRef(repo.path, remoteTrackingBase)
const refreshResult = await this.getOrStartRemoteTrackingBaseRefresh(
repo.path,
remoteTrackingBase
)
if (!refreshResult.ok) {
throw new Error(
`Could not refresh base ref "${baseBranch}" from "${remoteTrackingBase.remote}". Check your network and try again.`
)
}
if (!hadLocalBaseRef && !(await this.hasRemoteTrackingRef(repo.path, remoteTrackingBase))) {
throw new Error(`Base ref "${baseBranch}" was not found after fetching.`)
}
} else {
const remote = baseBranch.includes('/') ? baseBranch.split('/')[0] : 'origin'
// Why: local bases keep legacy best-effort fetch behavior. Remote-tracking
// bases fail closed above because stale create-from-base is worse than a
// clear retryable error.
try {
await this.fetchRemoteWithCache(repo.path, remote)
} catch {
// Why: belt-and-suspenders. fetchRemoteWithCache already logs and does
// not throw; the outer try/catch guarantees create-path tolerance even
// if future refactors change that contract.
}
}
const sparseDirectories = args.sparseCheckout
@@ -5923,9 +6142,8 @@ export class OrcaRuntimeService {
/**
* Fetch `remote` in `repoPath`, sharing the 30s freshness window + in-flight
* serialization with all other callers (renderer-create path, CLI create,
* dispatch drift probe). Never rejects — callers log-and-proceed on offline
* failures (§3.3 Lifecycle).
* serialization with all other callers. Never rejects — callers
* log-and-proceed on offline failures (§3.3 Lifecycle).
*
* Why a shared cache on the runtime instead of module-scoped: §7.1 relies on
* one cache for BOTH the renderer create path and `probeWorktreeDrift`. A
@@ -5957,10 +6175,19 @@ export class OrcaRuntimeService {
return resolved
}
async isRemoteFetchFresh(repoPath: string, remote: string): Promise<boolean> {
const key = await this.getCanonicalFetchKey(repoPath, remote)
const lastAt = this.fetchLastCompletedAt.get(key)
return lastAt !== undefined && Date.now() - lastAt < FETCH_FRESHNESS_MS
private enqueueRemoteFetch(
remoteKey: string,
runFetch: () => Promise<RemoteFetchResult>
): Promise<RemoteFetchResult> {
const previous = this.remoteFetchQueueTail.get(remoteKey)
const promise = previous ? previous.then(runFetch, runFetch) : runFetch()
this.remoteFetchQueueTail.set(remoteKey, promise)
promise.finally(() => {
if (this.remoteFetchQueueTail.get(remoteKey) === promise) {
this.remoteFetchQueueTail.delete(remoteKey)
}
})
return promise
}
async getOrStartRemoteFetch(repoPath: string, remote: string): Promise<RemoteFetchResult> {
@@ -5980,26 +6207,59 @@ export class OrcaRuntimeService {
return existing
}
const promise = gitExecFileAsync(['fetch', remote], { cwd: repoPath })
.then((): RemoteFetchResult => {
// Why (§3.3 Lifecycle): timestamp on success ONLY. Writing on rejection
// would make the freshness cache lie about the last known remote state.
this.fetchLastCompletedAt.set(key, Date.now())
return { ok: true }
})
.catch((err): RemoteFetchResult => {
// Why: swallow here so awaiters don't throw at the await site. Outer
// create/dispatch paths are already tolerant of offline fetch failure;
// this is the behavioral contract of this helper.
console.warn(`[fetchRemoteWithCache] ${remote} fetch failed for ${repoPath}:`, err)
return { ok: false, errorKind: 'git_error' }
})
.finally(() => {
// Why (§3.3 Lifecycle): evict on BOTH success and rejection. A
// rejected entry that survived in the Map would wedge every future
// create on this repo until Orca restarted (the F2 bug §3.3 pins).
this.fetchInflight.delete(key)
})
const promise = this.enqueueRemoteFetch(key, () =>
gitExecFileAsync(['fetch', remote], { cwd: repoPath })
.then((): RemoteFetchResult => {
// Why (§3.3 Lifecycle): timestamp on success ONLY. Writing on rejection
// would make the freshness cache lie about the last known remote state.
this.fetchLastCompletedAt.set(key, Date.now())
return { ok: true }
})
.catch((err): RemoteFetchResult => {
// Why: swallow here so awaiters don't throw at the await site. Outer
// create/dispatch paths are already tolerant of offline fetch failure;
// this is the behavioral contract of this helper.
console.warn(`[fetchRemoteWithCache] ${remote} fetch failed for ${repoPath}:`, err)
return { ok: false, errorKind: 'git_error' }
})
).finally(() => {
// Why (§3.3 Lifecycle): evict on BOTH success and rejection. A
// rejected entry that survived in the Map would wedge every future
// create on this repo until Orca restarted (the F2 bug §3.3 pins).
this.fetchInflight.delete(key)
})
this.fetchInflight.set(key, promise)
return promise
}
async getOrStartRemoteTrackingBaseRefresh(
repoPath: string,
base: RemoteTrackingBase
): Promise<RemoteFetchResult> {
const remoteKey = await this.getCanonicalFetchKey(repoPath, base.remote)
const key = await this.getCanonicalFetchKey(repoPath, `base:${base.remote}:${base.branch}`)
const existing = this.fetchInflight.get(key)
if (existing) {
return existing
}
const promise = this.enqueueRemoteFetch(remoteKey, () =>
gitExecFileAsync(
['fetch', '--no-tags', base.remote, `+refs/heads/${base.branch}:${base.ref}`],
{ cwd: repoPath }
)
.then((): RemoteFetchResult => ({ ok: true }))
.catch((err): RemoteFetchResult => {
console.warn(
`[refreshRemoteTrackingBase] ${base.base} refresh failed for ${repoPath}:`,
err
)
return { ok: false, errorKind: 'git_error' }
})
).finally(() => {
this.fetchInflight.delete(key)
})
this.fetchInflight.set(key, promise)
return promise
@@ -0,0 +1,120 @@
import { describe, expect, it, vi } from 'vitest'
import { RpcDispatcher } from '../dispatcher'
import type { RpcRequest } from '../core'
import type { OrcaRuntimeService } from '../../orca-runtime'
import { AUTOMATION_METHODS } from './automations'
function makeRequest(method: string, params?: unknown): RpcRequest {
return { id: 'req-1', authToken: 'tok', method, params }
}
describe('automation RPC methods', () => {
it('routes automation CRUD and run operations to the runtime server', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
listAutomations: vi.fn().mockReturnValue([{ id: 'auto-1', name: 'Daily review' }]),
showAutomation: vi.fn().mockReturnValue({ id: 'auto-1', name: 'Daily review' }),
createAutomation: vi.fn().mockResolvedValue({ id: 'auto-2', name: 'New review' }),
updateAutomation: vi.fn().mockResolvedValue({ id: 'auto-1', name: 'Paused' }),
deleteAutomation: vi.fn().mockReturnValue({ removed: true, id: 'auto-1' }),
runAutomationNow: vi.fn().mockResolvedValue({ id: 'run-1', automationId: 'auto-1' }),
listAutomationRuns: vi.fn().mockReturnValue([{ id: 'run-1', automationId: 'auto-1' }])
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: AUTOMATION_METHODS })
await dispatcher.dispatch(makeRequest('automation.list'))
await dispatcher.dispatch(makeRequest('automation.show', { id: 'auto-1' }))
await dispatcher.dispatch(
makeRequest('automation.create', {
name: 'New review',
prompt: 'Review changes',
agentId: 'codex',
repo: 'repo-1',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: 1
})
)
await dispatcher.dispatch(
makeRequest('automation.update', {
id: 'auto-1',
updates: {
enabled: false,
rrule: '0 9 * * 1-5',
dtstart: 2
}
})
)
await dispatcher.dispatch(makeRequest('automation.delete', { id: 'auto-1' }))
await dispatcher.dispatch(makeRequest('automation.runNow', { id: 'auto-1' }))
await dispatcher.dispatch(makeRequest('automation.runs', { automationId: 'auto-1' }))
expect(runtime.listAutomations).toHaveBeenCalled()
expect(runtime.showAutomation).toHaveBeenCalledWith('auto-1')
expect(runtime.createAutomation).toHaveBeenCalledWith(
expect.objectContaining({
name: 'New review',
prompt: 'Review changes',
agentId: 'codex',
repo: 'repo-1'
})
)
expect(runtime.updateAutomation).toHaveBeenCalledWith(
'auto-1',
expect.objectContaining({ enabled: false, rrule: '0 9 * * 1-5' })
)
expect(runtime.deleteAutomation).toHaveBeenCalledWith('auto-1')
expect(runtime.runAutomationNow).toHaveBeenCalledWith('auto-1')
expect(runtime.listAutomationRuns).toHaveBeenCalledWith('auto-1')
})
it('rejects unknown providers and invalid schedules', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
createAutomation: vi.fn()
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: AUTOMATION_METHODS })
await expect(
dispatcher.dispatch(
makeRequest('automation.create', {
name: 'Bad provider',
prompt: 'Run',
agentId: 'not-real',
repo: 'repo-1',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: 1
})
)
).resolves.toMatchObject({ ok: false, error: { code: 'invalid_argument' } })
await expect(
dispatcher.dispatch(
makeRequest('automation.create', {
name: 'Bad schedule',
prompt: 'Run',
agentId: 'codex',
repo: 'repo-1',
rrule: 'not a schedule',
dtstart: 1
})
)
).resolves.toMatchObject({ ok: false, error: { code: 'invalid_argument' } })
})
it('preserves null baseBranch update values through the RPC boundary', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
updateAutomation: vi.fn().mockResolvedValue({ id: 'auto-1', baseBranch: null })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: AUTOMATION_METHODS })
await dispatcher.dispatch(
makeRequest('automation.update', {
id: 'auto-1',
updates: { baseBranch: null }
})
)
expect(runtime.updateAutomation).toHaveBeenCalledWith('auto-1', { baseBranch: null })
})
})
+116
View File
@@ -0,0 +1,116 @@
import { z } from 'zod'
import { isValidAutomationSchedule } from '../../../../shared/automation-schedules'
import { isTuiAgent } from '../../../../shared/tui-agent-config'
import { defineMethod, type RpcMethod } from '../core'
import {
OptionalBoolean,
OptionalPlainString,
OptionalPositiveInt,
OptionalString,
requiredNumber,
requiredString
} from '../schemas'
const TuiAgent = requiredString('Missing provider').refine(isTuiAgent, {
message: 'Unknown provider'
})
const AutomationWorkspaceMode = z.enum(['existing', 'new_per_run']).optional()
const AutomationSchedule = requiredString('Missing trigger').refine(isValidAutomationSchedule, {
message: 'Invalid automation trigger'
})
const OptionalNullablePlainString = z
.unknown()
.transform((value) => (value === null || typeof value === 'string' ? value : undefined))
.pipe(z.union([z.string(), z.null(), z.undefined()]))
.optional()
const AutomationId = z.object({
id: requiredString('Missing automation id')
})
const AutomationRuns = z.object({
automationId: OptionalString
})
const AutomationCreate = z.object({
name: requiredString('Missing automation name'),
prompt: requiredString('Missing automation prompt'),
agentId: TuiAgent,
repo: OptionalString,
workspace: OptionalString,
workspaceMode: AutomationWorkspaceMode,
baseBranch: OptionalPlainString,
timezone: OptionalString,
rrule: AutomationSchedule,
dtstart: requiredNumber('Missing trigger start time'),
enabled: OptionalBoolean,
missedRunGraceMinutes: OptionalPositiveInt
})
const AutomationUpdateFields = z.object({
name: OptionalString,
prompt: OptionalString,
agentId: TuiAgent.optional(),
repo: OptionalString,
workspace: OptionalString,
workspaceMode: AutomationWorkspaceMode,
// Why: update patches distinguish omitted from null so callers can clear a saved base branch.
baseBranch: OptionalNullablePlainString,
timezone: OptionalString,
rrule: AutomationSchedule.optional(),
dtstart: requiredNumber('Missing trigger start time').optional(),
enabled: OptionalBoolean,
missedRunGraceMinutes: OptionalPositiveInt
})
const AutomationUpdate = z.object({
id: requiredString('Missing automation id'),
updates: AutomationUpdateFields
})
export const AUTOMATION_METHODS: RpcMethod[] = [
defineMethod({
name: 'automation.list',
params: null,
handler: (_params, { runtime }) => ({ automations: runtime.listAutomations() })
}),
defineMethod({
name: 'automation.show',
params: AutomationId,
handler: (params, { runtime }) => ({ automation: runtime.showAutomation(params.id) })
}),
defineMethod({
name: 'automation.create',
params: AutomationCreate,
handler: async (params, { runtime }) => ({
automation: await runtime.createAutomation(params)
})
}),
defineMethod({
name: 'automation.update',
params: AutomationUpdate,
handler: async (params, { runtime }) => ({
automation: await runtime.updateAutomation(params.id, params.updates)
})
}),
defineMethod({
name: 'automation.delete',
params: AutomationId,
handler: (params, { runtime }) => runtime.deleteAutomation(params.id)
}),
defineMethod({
name: 'automation.runNow',
params: AutomationId,
handler: async (params, { runtime }) => ({ run: await runtime.runAutomationNow(params.id) })
}),
defineMethod({
name: 'automation.runs',
params: AutomationRuns,
handler: (params, { runtime }) => ({
runs: runtime.listAutomationRuns(params.automationId)
})
})
]
+2
View File
@@ -1,5 +1,6 @@
import type { RpcAnyMethod } from '../core'
import { STATUS_METHODS } from './status'
import { AUTOMATION_METHODS } from './automations'
import { REPO_METHODS } from './repo'
import { WORKTREE_METHODS } from './worktree'
import { TERMINAL_METHODS } from './terminal'
@@ -26,6 +27,7 @@ import { CLIENT_UI_METHODS } from './client-ui'
// auditing the security boundary or wiring new CLI commands.
export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [
...STATUS_METHODS,
...AUTOMATION_METHODS,
...REPO_METHODS,
...WORKTREE_METHODS,
...TERMINAL_METHODS,
+3 -4
View File
@@ -109,7 +109,7 @@ function createTerminalOutputBatcher(onFlush: (data: string) => void): {
return
}
if (!timer) {
// Why: Paseo coalesces terminal stream output before crossing the
// Why: terminal stream output should be coalesced before crossing the
// network. Desktop runtime subscribers need the same burst boundary.
timer = setTimeout(flush, TERMINAL_OUTPUT_FLUSH_MS)
if (typeof timer.unref === 'function') {
@@ -673,9 +673,8 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
}
}),
// Why: desktop remote sessions can have dozens of panes. One streaming RPC
// owns the binary socket and routes terminal slots by streamId, mirroring
// Paseo's slot-based terminal data plane while keeping legacy subscribe as
// the compatibility fallback.
// owns the binary socket and routes terminal slots by streamId while keeping
// legacy subscribe as the compatibility fallback.
defineStreamingMethod({
name: 'terminal.multiplex',
params: TerminalMultiplex,
@@ -292,6 +292,173 @@ describe('terminal multiplex RPC', () => {
await dispatchPromise
})
it('preserves LF input frames before writing to the multiplexed PTY', async () => {
const messages: string[] = []
const handlers = new Map<
number,
(frame: NonNullable<ReturnType<typeof decodeTerminalStreamFrame>>) => void
>()
const cleanups = new Map<string, () => void>()
const runtime = stubRuntime({
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
serializeTerminalBuffer: vi.fn().mockResolvedValue(null),
getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }),
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()),
subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()),
subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()),
subscribeToDriverChanges: vi.fn().mockReturnValue(vi.fn()),
getTerminalFitOverride: vi.fn().mockReturnValue(null),
getDriver: vi.fn().mockReturnValue({ kind: 'idle' }),
registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => {
cleanups.set(id, cleanup)
}),
cleanupSubscription: vi.fn((id: string) => {
const cleanup = cleanups.get(id)
cleanups.delete(id)
cleanup?.()
}),
waitForTerminal: vi.fn(() => new Promise<RuntimeTerminalWait>(() => {})),
sendTerminal: vi.fn().mockResolvedValue({ accepted: true }),
updateDesktopViewport: vi.fn().mockResolvedValue(true)
})
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
const dispatchPromise = dispatcher.dispatchStreaming(
makeRequest('terminal.multiplex', {}),
(msg) => messages.push(msg),
{
connectionId: 'conn-byte-preserving',
sendBinary: vi.fn(),
registerBinaryStreamHandler: (streamId, handler) => {
handlers.set(streamId, handler)
return () => handlers.delete(streamId)
}
}
)
await vi.waitFor(() =>
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true)
)
handlers.get(0)?.(
decodeTerminalStreamFrame(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Subscribe,
streamId: 0,
seq: 1,
payload: encodeTerminalStreamJson({
streamId: 9,
terminal: 'terminal-1',
client: { id: 'desktop-1', type: 'desktop' }
})
})
)!
)
await vi.waitFor(() => expect(handlers.has(9)).toBe(true))
handlers.get(9)?.(
decodeTerminalStreamFrame(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Input,
streamId: 9,
seq: 2,
payload: encodeTerminalStreamText('echo one\necho two\r\n')
})
)!
)
await vi.waitFor(() =>
expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', {
text: 'echo one\necho two\r\n',
enter: false,
interrupt: false
})
)
runtime.cleanupSubscription('terminal-multiplex:conn-byte-preserving')
await dispatchPromise
})
it('preserves LF input frames before writing to the subscribed PTY', async () => {
const messages: string[] = []
const handlers = new Map<
number,
(frame: NonNullable<ReturnType<typeof decodeTerminalStreamFrame>>) => void
>()
const cleanups = new Map<string, () => void>()
const runtime = stubRuntime({
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
serializeTerminalBuffer: vi.fn().mockResolvedValue(null),
getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }),
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()),
subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()),
subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()),
getDriver: vi.fn().mockReturnValue({ kind: 'idle' }),
registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => {
cleanups.set(id, cleanup)
}),
cleanupSubscription: vi.fn((id: string) => {
const cleanup = cleanups.get(id)
cleanups.delete(id)
cleanup?.()
}),
waitForTerminal: vi.fn(() => new Promise<RuntimeTerminalWait>(() => {})),
sendTerminal: vi.fn().mockResolvedValue({ accepted: true }),
updateDesktopViewport: vi.fn().mockResolvedValue(true)
})
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
const dispatchPromise = dispatcher.dispatchStreaming(
makeRequest('terminal.subscribe', {
terminal: 'terminal-1',
client: { id: 'desktop-1', type: 'desktop' },
capabilities: { terminalBinaryStream: 1 }
}),
(msg) => messages.push(msg),
{
connectionId: 'conn-subscribe-byte-preserving',
sendBinary: vi.fn(),
registerBinaryStreamHandler: (streamId, handler) => {
handlers.set(streamId, handler)
return () => handlers.delete(streamId)
}
}
)
await vi.waitFor(() =>
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true)
)
const streamId = JSON.parse(
messages.find((msg) => JSON.parse(msg).result?.type === 'subscribed')!
).result.streamId as number
handlers.get(streamId)?.(
decodeTerminalStreamFrame(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Input,
streamId,
seq: 1,
payload: encodeTerminalStreamText('printf a\nprintf b\r\n')
})
)!
)
await vi.waitFor(() =>
expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', {
text: 'printf a\nprintf b\r\n',
enter: false,
interrupt: false
})
)
runtime.cleanupSubscription('terminal-1:desktop-1')
await dispatchPromise
})
it('bounds live output queued while a multiplex snapshot is loading', async () => {
vi.useFakeTimers()
try {
@@ -0,0 +1,25 @@
import { describe, expect, it, vi } from 'vitest'
import { readBlobAtIndex, readBlobAtOid, type GitBufferExec } from './git-handler-ops'
describe('git blob readers', () => {
it('normalizes Windows separators before reading OID blobs', async () => {
const gitBuffer = vi.fn<GitBufferExec>().mockResolvedValue(Buffer.from('head-content'))
const result = await readBlobAtOid(gitBuffer, '/repo', 'HEAD', 'src\\file.ts')
expect(gitBuffer).toHaveBeenCalledWith(
['show', '--end-of-options', 'HEAD:src/file.ts'],
'/repo'
)
expect(result.content).toBe('head-content')
})
it('normalizes Windows separators before reading index blobs', async () => {
const gitBuffer = vi.fn<GitBufferExec>().mockResolvedValue(Buffer.from('index-content'))
const result = await readBlobAtIndex(gitBuffer, '/repo', 'src\\file.ts')
expect(gitBuffer).toHaveBeenCalledWith(['show', '--end-of-options', ':src/file.ts'], '/repo')
expect(result.content).toBe('index-content')
})
})
+6 -2
View File
@@ -23,8 +23,10 @@ export async function readBlobAtOid(
oid: string,
filePath: string
): Promise<{ content: string; isBinary: boolean }> {
// Why: Git's `<oid>:<path>` syntax expects forward slashes even on Windows.
const gitPath = filePath.replace(/\\/g, '/')
try {
const buf = await gitBuffer(['show', `${oid}:${filePath}`], cwd)
const buf = await gitBuffer(['show', '--end-of-options', `${oid}:${gitPath}`], cwd)
return bufferToBlob(buf, filePath)
} catch {
return { content: '', isBinary: false }
@@ -36,8 +38,10 @@ export async function readBlobAtIndex(
cwd: string,
filePath: string
): Promise<{ content: string; isBinary: boolean }> {
// Why: Git's `:<path>` syntax expects forward slashes even on Windows.
const gitPath = filePath.replace(/\\/g, '/')
try {
const buf = await gitBuffer(['show', `:${filePath}`], cwd)
const buf = await gitBuffer(['show', '--end-of-options', `:${gitPath}`], cwd)
return bufferToBlob(buf, filePath)
} catch {
return { content: '', isBinary: false }
+6 -7
View File
@@ -807,19 +807,18 @@
@keyframes settings-section-flash {
0% {
border-color: var(--ring);
box-shadow:
0 0 0 2px color-mix(in srgb, var(--ring) 45%, transparent),
0 1px 2px 0 rgb(0 0 0 / 0.05);
background: color-mix(in srgb, var(--ring) 8%, transparent);
box-shadow: inset 3px 0 0 var(--ring);
}
60% {
border-color: var(--ring);
box-shadow:
0 0 0 2px color-mix(in srgb, var(--ring) 30%, transparent),
0 1px 2px 0 rgb(0 0 0 / 0.05);
background: color-mix(in srgb, var(--ring) 5%, transparent);
box-shadow: inset 3px 0 0 color-mix(in srgb, var(--ring) 70%, transparent);
}
100% {
border-color: var(--border);
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
background: transparent;
box-shadow: inset 0 0 0 transparent;
}
}
+271 -14
View File
@@ -34,11 +34,13 @@ import {
RefreshCw,
Send,
UndoDot,
Users,
X
} from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { ButtonGroup } from '@/components/ui/button-group'
import { Input } from '@/components/ui/input'
import { Sheet, SheetContent, SheetDescription, SheetTitle } from '@/components/ui/sheet'
import { VisuallyHidden } from 'radix-ui'
import {
@@ -84,6 +86,11 @@ import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-cl
import { useRepoLabels, useRepoAssignees, useImmediateMutation } from '@/hooks/useIssueMetadata'
import { useRepoLabelsBySlug, useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata'
import IssueSourceIndicator, { sameGitHubOwnerRepo } from '@/components/github/IssueSourceIndicator'
import {
appendGitHubPRRequestedReviewers,
getGitHubPRReviewerRows,
normalizeGitHubReviewerLogins
} from '@/components/github-pr-reviewer-display'
import type {
GitHubOwnerRepo,
GitHubPRFile,
@@ -177,6 +184,10 @@ type GitHubItemDialogProps = {
repoId?: string | null
/** Called when the user clicks the primary CTA to start work from this item. */
onUse: (item: GitHubWorkItem) => void
onReviewRequestsChange?: (
itemKey: { id: string; repoId: string },
reviewRequests: GitHubAssignableUser[]
) => void
onClose: () => void
/** Optional Project-origin context. When set, edits in the dialog are
* routed via slug-addressed mutation IPCs against the row's actual repo
@@ -359,6 +370,193 @@ function WorkItemStateBadge({
)
}
function ReviewerAvatar({
login,
avatarUrl
}: {
login: string
avatarUrl: string
}): React.JSX.Element {
if (avatarUrl) {
return (
<img
src={avatarUrl}
alt=""
loading="lazy"
decoding="async"
title={login}
className="size-6 shrink-0 rounded-full border border-border/50 bg-muted object-cover"
/>
)
}
return (
<span
title={login}
className="inline-flex size-6 shrink-0 items-center justify-center rounded-full border border-border/50 bg-muted text-[10px] font-medium text-muted-foreground"
>
{login.slice(0, 1).toUpperCase()}
</span>
)
}
function PRReviewersPanel({
item,
loading,
repoPath,
onReviewersRequested
}: {
item: GitHubWorkItem
loading: boolean
repoPath: string | null
onReviewersRequested: (reviewRequests: GitHubAssignableUser[]) => void
}): React.JSX.Element {
const [reviewerInput, setReviewerInput] = useState('')
const [submitting, setSubmitting] = useState(false)
const [localReviewRequests, setLocalReviewRequests] = useState<GitHubAssignableUser[]>(
() => item.reviewRequests ?? []
)
const patchWorkItem = useAppStore((s) => s.patchWorkItem)
useEffect(() => {
setLocalReviewRequests(item.reviewRequests ?? [])
}, [item.id, item.reviewRequests])
const displayItem = { ...item, reviewRequests: localReviewRequests }
const reviewers = getGitHubPRReviewerRows(displayItem)
const selectedReviewerLogins = useMemo(
() =>
new Set(
localReviewRequests.map((reviewer) => reviewer.login.trim().toLowerCase()).filter(Boolean)
),
[localReviewRequests]
)
const hasReviewerMetadata =
item.reviewDecision !== undefined ||
localReviewRequests.length > 0 ||
item.reviewRequests !== undefined ||
item.latestReviews !== undefined
const canRequestReview =
!!repoPath || getActiveRuntimeTarget(useAppStore.getState().settings).kind === 'environment'
const handleRequestReview = async (event: React.FormEvent<HTMLFormElement>): Promise<void> => {
event.preventDefault()
if (submitting) {
return
}
const logins = normalizeGitHubReviewerLogins(
reviewerInput.split(/[\s,]+/),
selectedReviewerLogins
)
if (logins.length === 0) {
toast.error('Enter a reviewer')
return
}
if (localReviewRequests.length + logins.length > 15) {
toast.error('You can request up to 15 reviewers')
return
}
const target = getActiveRuntimeTarget(useAppStore.getState().settings)
if (target.kind !== 'environment' && !repoPath) {
toast.error('No repo context available for this pull request.')
return
}
setSubmitting(true)
try {
const result =
target.kind === 'environment'
? await callRuntimeRpc<{ ok: boolean; error?: string }>(
target,
'github.requestPRReviewers',
{ repo: item.repoId, prNumber: item.number, reviewers: logins },
{ timeoutMs: 30_000 }
)
: await window.api.gh.requestPRReviewers({
repoPath: repoPath ?? '',
repoId: item.repoId,
prNumber: item.number,
reviewers: logins
})
if (!result.ok) {
toast.error(result.error ?? 'Failed to request reviewer')
return
}
const nextReviewRequests = appendGitHubPRRequestedReviewers(localReviewRequests, logins)
setLocalReviewRequests(nextReviewRequests)
patchWorkItem(item.id, { reviewRequests: nextReviewRequests }, item.repoId)
onReviewersRequested(nextReviewRequests)
setReviewerInput('')
toast.success(logins.length === 1 ? 'Reviewer requested' : 'Reviewers requested')
} catch {
toast.error('Failed to request reviewer')
} finally {
setSubmitting(false)
}
}
return (
<aside className="rounded-lg border border-border/50 bg-card/50 shadow-xs">
<div className="flex h-10 items-center gap-2 border-b border-border/50 px-3">
<Users className="size-3.5 text-muted-foreground" />
<span className="text-[13px] font-medium text-foreground">Reviewers</span>
{reviewers.length > 0 ? (
<span className="ml-auto rounded-full border border-border/50 bg-muted/30 px-1.5 py-0.5 text-[11px] tabular-nums text-muted-foreground">
{reviewers.length}
</span>
) : null}
</div>
<div className="px-3 py-2.5">
{loading && !hasReviewerMetadata ? (
<div className="flex items-center gap-2 py-1 text-[12px] text-muted-foreground">
<LoaderCircle className="size-3.5 animate-spin" />
Loading reviewers
</div>
) : reviewers.length > 0 ? (
<div className="flex flex-col gap-2">
{reviewers.map((reviewer) => (
<div key={reviewer.login} className="flex min-w-0 items-center gap-2">
<ReviewerAvatar login={reviewer.login} avatarUrl={reviewer.avatarUrl} />
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium text-foreground">
{reviewer.login}
</div>
{reviewer.name ? (
<div className="truncate text-[11px] text-muted-foreground">
{reviewer.name}
</div>
) : null}
</div>
<span className="shrink-0 text-[11px] text-muted-foreground">
{reviewer.stateLabel}
</span>
</div>
))}
</div>
) : (
<div className="py-1 text-[12px] text-muted-foreground">No reviewers requested.</div>
)}
<form className="mt-3 flex items-center gap-2" onSubmit={handleRequestReview}>
<Input
value={reviewerInput}
onChange={(event) => setReviewerInput(event.target.value)}
disabled={submitting || !canRequestReview}
placeholder="Reviewer"
aria-label="Reviewer"
className="h-8 min-w-0 flex-1 rounded-md border-border/50 bg-background text-xs"
/>
<Button
type="submit"
size="sm"
disabled={submitting || !canRequestReview || reviewerInput.trim().length === 0}
className="h-8 shrink-0 px-2.5 text-xs"
>
{submitting ? <LoaderCircle className="size-3.5 animate-spin" /> : 'Request'}
</Button>
</form>
</div>
</aside>
)
}
function fileStatusTone(status: GitHubPRFile['status']): string {
switch (status) {
case 'added':
@@ -743,6 +941,25 @@ function patchCachedPRChecks(cacheKey: string, checks: PRCheckDetail[]): void {
})
}
function patchCachedPRReviewRequests(
cacheKey: string,
reviewRequests: GitHubAssignableUser[]
): void {
const prev = workItemDetailsCache.get(cacheKey)
if (!prev?.details) {
return
}
touchWorkItemDetailsCache(cacheKey, {
...prev,
details: {
...prev.details,
item: { ...prev.details.item, reviewRequests }
},
fetchedAt: Date.now(),
error: undefined
})
}
// Why: install once at module load — every dialog instance shares the cache,
// so a single subscription is enough. The preload bridge re-emits the
// main-process broadcast for every window, so each renderer invalidates its
@@ -1484,7 +1701,8 @@ function ConversationTab({
onUse,
onMutated,
onChecksUpdated,
onCommentAdded
onCommentAdded,
onReviewersRequested
}: {
item: GitHubWorkItem
repoPath: string | null
@@ -1504,6 +1722,7 @@ function ConversationTab({
onMutated: () => void
onChecksUpdated: (checks: PRCheckDetail[]) => void
onCommentAdded: (comment: PRComment) => void
onReviewersRequested: (reviewRequests: GitHubAssignableUser[]) => void
}): React.JSX.Element {
const authorLabel = item.author ?? 'unknown'
const [replyingTo, setReplyingTo] = useState<number | null>(null)
@@ -1573,7 +1792,7 @@ function ConversationTab({
const startWorkspaceButton = (
<Button
onClick={() => onUse(item)}
className="w-full justify-center gap-2"
className="self-start justify-center gap-2 xl:self-stretch"
aria-label={`Start workspace from ${item.type === 'pr' ? 'PR' : 'issue'}`}
>
{`Start workspace from ${item.type === 'pr' ? 'PR' : 'issue'}`}
@@ -1594,6 +1813,12 @@ function ConversationTab({
onStateChange={onStateChange}
onMutated={onMutated}
/>
<PRReviewersPanel
item={item}
loading={loading}
repoPath={repoPath}
onReviewersRequested={onReviewersRequested}
/>
<aside className="rounded-lg border border-border/50 bg-card/50 shadow-xs">
<div className="flex h-10 items-center gap-2 border-b border-border/50 px-3">
<CircleDashed className="size-3.5 text-muted-foreground" />
@@ -1899,10 +2124,10 @@ function PRActionsPanel({
const applyStatePatch = useCallback(
(state: GitHubWorkItem['state']) => {
onStateChange(state)
patchWorkItem(item.id, { state })
patchWorkItem(item.id, { state }, item.repoId)
patchProjectRowIfNeeded(state)
},
[item.id, onStateChange, patchProjectRowIfNeeded, patchWorkItem]
[item.id, item.repoId, onStateChange, patchProjectRowIfNeeded, patchWorkItem]
)
const handleStateChange = async (): Promise<void> => {
@@ -1980,7 +2205,7 @@ function PRActionsPanel({
type="button"
variant={nextState === 'closed' ? 'destructive' : 'secondary'}
size="sm"
className="w-full justify-center gap-2"
className="w-fit justify-center gap-2 xl:w-full"
disabled={!canMutateState || statePending}
onClick={() => void handleStateChange()}
>
@@ -2002,7 +2227,7 @@ function PRActionsPanel({
type="button"
variant="outline"
size="sm"
className="w-full justify-center gap-2"
className="w-fit justify-center gap-2 xl:w-full"
disabled={mergePending || localState === 'closed' || localState === 'merged'}
>
{mergePending ? (
@@ -2668,16 +2893,16 @@ function GHEditSection({
}),
onOptimistic: () => {
onStateChange(newState)
patchWorkItem(item.id, { state: newState })
patchWorkItem(item.id, { state: newState }, item.repoId)
patchProjectRowIfNeeded({ state: newState })
},
onRevert: () => {
onStateChange(prevState)
patchWorkItem(item.id, { state: prevState })
patchWorkItem(item.id, { state: prevState }, item.repoId)
patchProjectRowIfNeeded({ state: prevState })
},
onSuccess: () => {
patchWorkItem(item.id, { state: newState })
patchWorkItem(item.id, { state: newState }, item.repoId)
patchProjectRowIfNeeded({ state: newState })
onMutated()
},
@@ -2717,7 +2942,7 @@ function GHEditSection({
}),
onOptimistic: () => {
onLabelsChange(newLabels)
patchWorkItem(item.id, { labels: newLabels })
patchWorkItem(item.id, { labels: newLabels }, item.repoId)
patchProjectRowIfNeeded({ labels: newLabels })
},
onSuccess: () => {
@@ -2725,7 +2950,7 @@ function GHEditSection({
},
onRevert: () => {
onLabelsChange(prevLabels)
patchWorkItem(item.id, { labels: prevLabels })
patchWorkItem(item.id, { labels: prevLabels }, item.repoId)
patchProjectRowIfNeeded({ labels: prevLabels })
},
onError: (err) => toast.error(err)
@@ -2742,12 +2967,12 @@ function GHEditSection({
}),
onOptimistic: () => {
onLabelsChange(newLabels)
patchWorkItem(item.id, { labels: newLabels })
patchWorkItem(item.id, { labels: newLabels }, item.repoId)
patchProjectRowIfNeeded({ labels: newLabels })
},
onRevert: () => {
onLabelsChange(prevLabels)
patchWorkItem(item.id, { labels: prevLabels })
patchWorkItem(item.id, { labels: prevLabels }, item.repoId)
patchProjectRowIfNeeded({ labels: prevLabels })
},
onSuccess: () => {
@@ -3202,6 +3427,7 @@ export default function GitHubItemDialog({
repoId,
projectOrigin,
onUse,
onReviewRequestsChange,
onClose
}: GitHubItemDialogProps): React.JSX.Element {
const [tab, setTab] = useState<ItemDialogTab>('conversation')
@@ -3445,6 +3671,28 @@ export default function GitHubItemDialog({
}, [repoPath, effectiveRepoId, workItem, detailsCacheKey, refetchTick])
const Icon = workItem?.type === 'pr' ? GitPullRequest : CircleDot
const displayWorkItem = useMemo<GitHubWorkItem | null>(() => {
if (!workItem) {
return null
}
if (!details?.item) {
return workItem
}
return { ...workItem, ...details.item, repoId: workItem.repoId }
}, [details?.item, workItem])
useEffect(() => {
if (!workItem || details?.item.reviewRequests === undefined) {
return
}
// Why: PR details can carry fresher reviewer metadata than the list row;
// push it back so the Tasks review chip doesn't keep a stale snapshot.
onReviewRequestsChange?.(
{ id: workItem.id, repoId: workItem.repoId },
details.item.reviewRequests
)
}, [details?.item.reviewRequests, onReviewRequestsChange, workItem])
const body = details?.body ?? ''
const comments = details?.comments ?? []
const files = details?.files ?? []
@@ -3725,7 +3973,7 @@ export default function GitHubItemDialog({
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-sleek">
<TabsContent value="conversation" className="mt-0">
<ConversationTab
item={workItem}
item={displayWorkItem ?? workItem}
repoPath={repoPath}
repoId={effectiveRepoId}
body={body}
@@ -3756,6 +4004,15 @@ export default function GitHubItemDialog({
}
}}
onCommentAdded={appendOptimisticComment}
onReviewersRequested={(nextReviewRequests) => {
if (detailsCacheKey) {
patchCachedPRReviewRequests(detailsCacheKey, nextReviewRequests)
}
onReviewRequestsChange?.(
{ id: workItem.id, repoId: workItem.repoId },
nextReviewRequests
)
}}
/>
</TabsContent>
File diff suppressed because it is too large Load Diff
@@ -1,75 +1,20 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import type { OpenFile } from '@/store/slices/editor'
import type { GitStatusEntry } from '../../../../shared/types'
import { ConflictReviewPanel } from './ConflictComponents'
import { describe, expect, it } from 'vitest'
import { getNextConflictNavigationIndex } from './ConflictComponents'
function createConflictReviewFile(overrides: Partial<OpenFile> = {}): OpenFile {
return {
id: 'repo::/repo::conflict-review',
filePath: '/repo',
relativePath: 'Conflict Review',
worktreeId: 'repo::/repo',
language: 'text',
isDirty: false,
mode: 'conflict-review',
conflictReview: {
source: 'live-summary',
snapshotTimestamp: Date.UTC(2026, 4, 17, 19, 9, 7),
entries: [
{
path: 'src/renderer/src/store/slices/linear.test.ts',
conflictKind: 'both_added'
},
{
path: 'src/renderer/src/store/slices/linear.ts',
conflictKind: 'both_modified'
}
]
},
...overrides
}
}
function createLiveEntry(
path: string,
status: GitStatusEntry['status'] = 'modified'
): GitStatusEntry {
return {
path,
status,
area: 'unstaged',
conflictKind: path.endsWith('.test.ts') ? 'both_added' : 'both_modified',
conflictStatus: 'unresolved',
conflictStatusSource: 'git'
}
}
describe('ConflictReviewPanel', () => {
it('renders unresolved conflicts as a left file tree', () => {
const file = createConflictReviewFile()
const html = renderToStaticMarkup(
<ConflictReviewPanel
file={file}
liveEntries={[
createLiveEntry('src/renderer/src/store/slices/linear.test.ts', 'added'),
createLiveEntry('src/renderer/src/store/slices/linear.ts')
]}
onOpenEntry={vi.fn()}
selectedFile={null}
selectedContent={null}
onDismiss={vi.fn()}
onRefreshSnapshot={vi.fn()}
onReturnToSourceControl={vi.fn()}
/>
describe('getNextConflictNavigationIndex', () => {
it('cycles through conflicts in both directions', () => {
expect(
getNextConflictNavigationIndex({ currentIndex: null, direction: 'next', total: 3 })
).toBe(0)
expect(getNextConflictNavigationIndex({ currentIndex: 2, direction: 'next', total: 3 })).toBe(0)
expect(
getNextConflictNavigationIndex({ currentIndex: 0, direction: 'previous', total: 3 })
).toBe(2)
expect(
getNextConflictNavigationIndex({ currentIndex: null, direction: 'previous', total: 3 })
).toBe(2)
expect(getNextConflictNavigationIndex({ currentIndex: 0, direction: 'next', total: 0 })).toBe(
null
)
expect(html).toContain('Files')
expect(html).toContain('Collapse file tree')
expect(html).toContain('src/renderer/src/store/slices')
expect(html).toContain('linear.test.ts')
expect(html).toContain('linear.ts')
expect(html).toContain('Select a conflict from the file tree')
expect(html).not.toContain('Choose which version to keep, or combine them')
})
})
@@ -1,5 +1,14 @@
import React from 'react'
import { CircleCheck, GitMerge, PanelLeftOpen, RefreshCw, TriangleAlert, X } from 'lucide-react'
import {
ChevronDown,
ChevronUp,
CircleCheck,
GitMerge,
PanelLeftOpen,
RefreshCw,
TriangleAlert,
X
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
@@ -29,13 +38,41 @@ export const CONFLICT_HINT_MAP: Record<GitConflictKind, string> = {
const EMPTY_CONFLICT_REVIEW_ENTRIES: readonly ConflictReviewEntry[] = []
let conflictReviewFileTreeCollapsedPreference = false
type ConflictNavigationDirection = 'previous' | 'next'
type ConflictReviewPanelEntry = ConflictReviewEntry & {
liveEntry?: GitStatusEntry
}
export function getNextConflictNavigationIndex({
currentIndex,
direction,
total
}: {
currentIndex: number | null
direction: ConflictNavigationDirection
total: number
}): number | null {
if (total <= 0) {
return null
}
if (currentIndex === null || currentIndex < 0 || currentIndex >= total) {
return direction === 'previous' ? total - 1 : 0
}
return direction === 'previous' ? (currentIndex + total - 1) % total : (currentIndex + 1) % total
}
export function ConflictBanner({
file,
entry
entry,
conflictNavigation
}: {
file: OpenFile
entry: GitStatusEntry | null
conflictNavigation?: {
currentIndex: number | null
total: number
onJump: (direction: ConflictNavigationDirection) => void
}
}): React.JSX.Element | null {
const conflict = file.conflict
if (!conflict) {
@@ -54,15 +91,58 @@ export function ConflictBanner({
: 'border-emerald-500/20 bg-emerald-500/5'
)}
>
<div className="flex items-center gap-2">
{isUnresolved ? (
<TriangleAlert className="size-3.5 shrink-0 text-destructive" />
) : (
<CircleCheck className="size-3.5 shrink-0 text-emerald-600 dark:text-emerald-400" />
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
{isUnresolved ? (
<TriangleAlert className="size-3.5 shrink-0 text-destructive" />
) : (
<CircleCheck className="size-3.5 shrink-0 text-emerald-600 dark:text-emerald-400" />
)}
<span className="min-w-0 truncate font-medium text-foreground">
{label} conflict · {CONFLICT_KIND_LABELS[conflict.conflictKind]}
</span>
{conflictNavigation && conflictNavigation.total > 0 && (
<span className="shrink-0 px-1 text-[11px] tabular-nums text-muted-foreground">
{(conflictNavigation.currentIndex ?? 0) + 1} / {conflictNavigation.total}
</span>
)}
</div>
{conflictNavigation && conflictNavigation.total > 0 && (
<div className="flex shrink-0 items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label="Previous conflict"
onClick={() => conflictNavigation.onJump('previous')}
>
<ChevronUp className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
Previous conflict
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label="Next conflict"
onClick={() => conflictNavigation.onJump('next')}
>
<ChevronDown className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
Next conflict
</TooltipContent>
</Tooltip>
</div>
)}
<span className="font-medium text-foreground">
{label} conflict · {CONFLICT_KIND_LABELS[conflict.conflictKind]}
</span>
</div>
{/* Why: the hint is omitted here because the file is already open in the
editor below. Showing "Open and edit…" or similar would be confusing
@@ -131,7 +211,7 @@ export function ConflictReviewPanel({
() => new Map(liveEntries.map((entry) => [entry.path, entry])),
[liveEntries]
)
const treeEntries = React.useMemo(
const treeEntries = React.useMemo<readonly ConflictReviewPanelEntry[]>(
() =>
snapshotEntries.map((entry) => ({
...entry,
@@ -143,7 +223,6 @@ export function ConflictReviewPanel({
(entry) => entry.liveEntry?.conflictStatus === 'unresolved'
)
const unresolvedCount = unresolvedSnapshotEntries.length
const resolvedCount = Math.max(0, snapshotEntries.length - unresolvedCount)
const snapshotTime = new Date(
file.conflictReview?.snapshotTimestamp ?? Date.now()
).toLocaleTimeString()
@@ -219,40 +298,10 @@ export function ConflictReviewPanel({
Refresh
</Button>
</div>
<div className="min-h-0 flex-1">
<div className="flex min-h-0 flex-1 flex-col">
{selectedContent ?? (
<div className="flex h-full min-h-0 items-center justify-center px-6 text-center">
<div className="max-w-md space-y-3">
<div className="mx-auto flex size-10 items-center justify-center rounded-md border border-border bg-card text-muted-foreground">
<GitMerge className="size-4" />
</div>
<div className="text-sm font-medium text-foreground">
Select a conflict from the file tree
</div>
<div className="text-xs text-muted-foreground">
Open each unresolved file, edit the conflict markers, then refresh this snapshot.
</div>
{resolvedCount > 0 && (
<div className="text-xs text-muted-foreground">
{resolvedCount} conflict{resolvedCount === 1 ? '' : 's'} no longer live in Git.
</div>
)}
<div className="flex items-center justify-center gap-2">
<Button
type="button"
size="sm"
variant="outline"
onClick={onReturnToSourceControl}
>
<GitMerge className="size-3.5" />
Source Control
</Button>
<Button type="button" size="sm" variant="ghost" onClick={onDismiss}>
<X className="size-3.5" />
Dismiss
</Button>
</div>
</div>
<div className="flex h-full min-h-0 items-center justify-center px-6 text-center text-sm text-muted-foreground">
Loading conflict contents...
</div>
)}
</div>
@@ -7,10 +7,16 @@ already live in their own modules. */
import React, { lazy } from 'react'
import { AlertCircle, RefreshCw } from 'lucide-react'
import { detectLanguage } from '@/lib/language-detect'
import { joinPath } from '@/lib/path'
import { useAppStore } from '@/store'
import { Button } from '@/components/ui/button'
import { ChangesModeView } from './ChangesModeView'
import { ConflictBanner, ConflictPlaceholderView, ConflictReviewPanel } from './ConflictComponents'
import {
ConflictBanner,
ConflictPlaceholderView,
ConflictReviewPanel,
getNextConflictNavigationIndex
} from './ConflictComponents'
import type { MarkdownViewMode, OpenFile } from '@/store/slices/editor'
import type { GitStatusEntry, GitDiffResult } from '../../../../shared/types'
import { RICH_MARKDOWN_MAX_SIZE_BYTES } from '../../../../shared/constants'
@@ -19,6 +25,7 @@ import { getMarkdownRichModeUnsupportedMessage } from './markdown-rich-mode'
import { extractFrontMatter, prependFrontMatter } from './markdown-frontmatter'
import { RichMarkdownErrorBoundary } from './RichMarkdownErrorBoundary'
import { useMarkdownDocuments } from './useMarkdownDocuments'
import { findGitConflictBlocks } from './monaco-conflict-decorations'
const MonacoEditor = lazy(() => import('./MonacoEditor'))
const DiffViewer = lazy(() => import('./DiffViewer'))
@@ -146,6 +153,10 @@ export function EditorContent({
const openConflictReview = useAppStore((s) => s.openConflictReview)
const closeFile = useAppStore((s) => s.closeFile)
const setRightSidebarTab = useAppStore((s) => s.setRightSidebarTab)
const setPendingEditorReveal = useAppStore((s) => s.setPendingEditorReveal)
const [conflictNavigationIndexByFile, setConflictNavigationIndexByFile] = React.useState<
Record<string, number>
>({})
const md = useMarkdownDocuments(activeFile, isMarkdown, mdViewMode, handleSave)
const activeConflictEntry =
worktreeEntries.find((entry) => entry.path === activeFile.relativePath) ?? null
@@ -160,6 +171,102 @@ export function EditorContent({
activeFile.diffSource === 'combined-branch' ||
activeFile.diffSource === 'combined-commit')
const getConflictNavigation = React.useCallback(
(file: OpenFile, content: string) => {
const blocks = findGitConflictBlocks(content)
if (blocks.length === 0) {
return undefined
}
const currentIndex = conflictNavigationIndexByFile[file.id] ?? null
return {
currentIndex,
total: blocks.length,
onJump: (direction: 'previous' | 'next') => {
const nextIndex = getNextConflictNavigationIndex({
currentIndex,
direction,
total: blocks.length
})
if (nextIndex === null) {
return
}
const line = blocks[nextIndex].startLine
const markerLine = content.split(/\r?\n/)[line - 1] ?? ''
setConflictNavigationIndexByFile((prev) => ({ ...prev, [file.id]: nextIndex }))
// Why: a same-location reveal can be requested twice before Monaco
// consumes the first one. Clearing first guarantees the prop changes
// and the mounted editor runs its reveal effect again.
setPendingEditorReveal(null)
queueMicrotask(() => {
setPendingEditorReveal({
filePath: file.filePath,
line,
column: 1,
matchLength: markerLine.length
})
})
}
}
},
[conflictNavigationIndexByFile, setPendingEditorReveal]
)
const openConflictEntry = React.useCallback(
(entry: GitStatusEntry) => {
if (activeFile.mode !== 'conflict-review') {
return
}
openConflictReviewFile(
activeFile.id,
activeFile.worktreeId,
activeFile.filePath,
entry,
detectLanguage(entry.path)
)
},
[
activeFile.filePath,
activeFile.id,
activeFile.mode,
activeFile.worktreeId,
openConflictReviewFile
]
)
const createConflictReviewContentFile = (entry: GitStatusEntry): OpenFile => {
const absolutePath = joinPath(activeFile.filePath, entry.path)
const conflict =
entry.conflictKind && entry.conflictStatus && entry.conflictStatusSource
? entry.status === 'deleted'
? {
kind: 'conflict-placeholder' as const,
conflictKind: entry.conflictKind,
conflictStatus: entry.conflictStatus,
conflictStatusSource: entry.conflictStatusSource,
message:
'This file is in a conflict state, but no working-tree file is available to edit.',
guidance: 'Resolve the conflict in Git or restore one side before reopening it.'
}
: {
kind: 'conflict-editable' as const,
conflictKind: entry.conflictKind,
conflictStatus: entry.conflictStatus,
conflictStatusSource: entry.conflictStatusSource
}
: undefined
return {
id: absolutePath,
filePath: absolutePath,
relativePath: entry.path,
worktreeId: activeFile.worktreeId,
language: detectLanguage(entry.path),
isDirty: false,
mode: 'edit',
conflict
}
}
const renderMonacoEditor = (fc: FileContent): React.JSX.Element => (
// Why: Without a key, React reuses the same MonacoEditor instance when
// switching tabs or split panes, just updating props. That means
@@ -324,77 +431,113 @@ export function EditorContent({
return <div className="h-full min-h-0">{renderMonacoEditor(fc)}</div>
}
const renderConflictReviewSelectedContent = (selectedFile: OpenFile): React.JSX.Element => {
if (selectedFile.conflict?.kind === 'conflict-placeholder') {
return <ConflictPlaceholderView file={selectedFile} />
const renderConflictReviewEditorContent = ({
contentFile,
entry,
className,
viewStateKeySuffix,
readOnly = false,
autoHeight = false
}: {
contentFile: OpenFile
entry: GitStatusEntry | null
className: string
viewStateKeySuffix: string
readOnly?: boolean
autoHeight?: boolean
}): React.JSX.Element => {
if (contentFile.conflict?.kind === 'conflict-placeholder') {
return (
<div className={className}>
<ConflictPlaceholderView file={contentFile} />
</div>
)
}
const fc = fileContents[selectedFile.id]
const fc = fileContents[contentFile.id]
if (!fc) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
Loading...
<div className={className}>
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Loading...
</div>
</div>
)
}
if (fc.loadError) {
return (
<FileLoadErrorView message={fc.loadError} onRetry={() => reloadFileContent(selectedFile)} />
<div className={className}>
<FileLoadErrorView
message={fc.loadError}
onRetry={() => reloadFileContent(contentFile)}
/>
</div>
)
}
if (fc.isBinary) {
if (fc.isImage) {
return (
<ImageViewer
content={fc.content}
filePath={selectedFile.filePath}
mimeType={fc.mimeType}
/>
<div className={className}>
<ImageViewer
content={fc.content}
filePath={contentFile.filePath}
mimeType={fc.mimeType}
/>
</div>
)
}
return (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
Binary file — cannot display
<div className={className}>
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Binary file — cannot display
</div>
</div>
)
}
const selectedConflictEntry =
worktreeEntries.find((entry) => entry.path === selectedFile.relativePath) ?? null
const selectedLanguage = detectLanguage(selectedFile.relativePath)
const selectedLanguage = detectLanguage(contentFile.relativePath)
const monacoSelectedLanguage = selectedLanguage === 'notebook' ? 'json' : selectedLanguage
const selectedViewStateKey = `${selectedFile.filePath}::${viewStateScopeId}`
const selectedViewStateKey = `${contentFile.filePath}::${viewStateScopeId}:${viewStateKeySuffix}`
const selectedContent = editBuffers[contentFile.id] ?? fc.content
return (
<div className="flex min-h-0 flex-1 flex-col">
{selectedFile.conflict && (
<ConflictBanner file={selectedFile} entry={selectedConflictEntry} />
<div className={className}>
{contentFile.conflict && (
<ConflictBanner
file={contentFile}
entry={entry}
conflictNavigation={getConflictNavigation(contentFile, selectedContent)}
/>
)}
<div className="min-h-0 flex-1">
<div className={autoHeight ? 'shrink-0' : 'min-h-0 flex-1'}>
<MonacoEditor
key={`${viewStateScopeId}:${selectedFile.id}`}
filePath={selectedFile.filePath}
key={`${viewStateScopeId}:${contentFile.id}:${viewStateKeySuffix}`}
filePath={contentFile.filePath}
viewStateKey={selectedViewStateKey}
relativePath={selectedFile.relativePath}
content={editBuffers[selectedFile.id] ?? fc.content}
relativePath={contentFile.relativePath}
content={selectedContent}
language={monacoSelectedLanguage}
onContentChange={(content) => handleContentChangeForFile(selectedFile, content)}
onSave={(content) => handleSaveForFile(selectedFile, content)}
worktreeId={selectedFile.worktreeId}
onContentChange={
readOnly ? () => {} : (content) => handleContentChangeForFile(contentFile, content)
}
onSave={readOnly ? () => {} : (content) => handleSaveForFile(contentFile, content)}
worktreeId={contentFile.worktreeId}
markdownAnnotationsEnabled={false}
conflictDecorationsEnabled={selectedFile.conflict?.conflictStatus === 'unresolved'}
conflictDecorationsEnabled={contentFile.conflict?.conflictStatus === 'unresolved'}
readOnly={readOnly}
autoHeight={autoHeight}
revealLine={
pendingEditorReveal?.filePath === selectedFile.filePath
pendingEditorReveal?.filePath === contentFile.filePath
? pendingEditorReveal.line
: undefined
}
revealColumn={
pendingEditorReveal?.filePath === selectedFile.filePath
pendingEditorReveal?.filePath === contentFile.filePath
? pendingEditorReveal.column
: undefined
}
revealMatchLength={
pendingEditorReveal?.filePath === selectedFile.filePath
pendingEditorReveal?.filePath === contentFile.filePath
? pendingEditorReveal.matchLength
: undefined
}
@@ -404,25 +547,57 @@ export function EditorContent({
)
}
const renderConflictReviewSelectedContent = (selectedFile: OpenFile): React.JSX.Element => {
const selectedConflictEntry =
worktreeEntries.find((entry) => entry.path === selectedFile.relativePath) ?? null
return renderConflictReviewEditorContent({
contentFile: selectedFile,
entry: selectedConflictEntry,
className: 'flex min-h-0 flex-1 flex-col',
viewStateKeySuffix: 'selected'
})
}
const renderConflictReviewInlineFile = (entry: GitStatusEntry): React.JSX.Element => {
const contentFile = createConflictReviewContentFile(entry)
return renderConflictReviewEditorContent({
contentFile,
entry,
className: 'flex min-h-[120px] flex-col border-b border-border last:border-b-0',
viewStateKeySuffix: `overview:${entry.path}`,
readOnly: true,
autoHeight: true
})
}
const renderConflictReviewAllContent = (): React.JSX.Element => {
const snapshotEntries = activeFile.conflictReview?.entries ?? []
const liveEntriesByPath = new Map(worktreeEntries.map((entry) => [entry.path, entry]))
const unresolvedEntries = snapshotEntries.flatMap((entry) => {
const liveEntry = liveEntriesByPath.get(entry.path)
return liveEntry?.conflictStatus === 'unresolved' && liveEntry.conflictKind ? [liveEntry] : []
})
return (
<div className="min-h-0 flex-1 overflow-y-auto bg-editor-surface scrollbar-sleek">
{unresolvedEntries.map(renderConflictReviewInlineFile)}
</div>
)
}
if (activeFile.mode === 'conflict-review') {
return (
<ConflictReviewPanel
file={activeFile}
liveEntries={worktreeEntries}
onOpenEntry={(entry) =>
openConflictReviewFile(
activeFile.id,
activeFile.worktreeId,
activeFile.filePath,
entry,
detectLanguage(entry.path)
)
}
onOpenEntry={openConflictEntry}
selectedFile={selectedConflictReviewFile}
selectedContent={
selectedConflictReviewFile
? renderConflictReviewSelectedContent(selectedConflictReviewFile)
: null
: renderConflictReviewAllContent()
}
onDismiss={() => closeFile(activeFile.id)}
onRefreshSnapshot={() =>
@@ -540,7 +715,16 @@ export function EditorContent({
}
return (
<div className="flex flex-1 min-h-0 flex-col">
{activeFile.conflict && <ConflictBanner file={activeFile} entry={activeConflictEntry} />}
{activeFile.conflict && (
<ConflictBanner
file={activeFile}
entry={activeConflictEntry}
conflictNavigation={getConflictNavigation(
activeFile,
editBuffers[activeFile.id] ?? fc.content
)}
/>
)}
<div className="min-h-0 flex-1 relative">
{isMarkdown ? (
renderMarkdownContent(fc)
@@ -174,24 +174,6 @@ function EditorPanelInner({
)
useEditorCmdSaveRequest({ activeFile, openFiles, fileContents, handleSave })
const handleEditorToggleChange = useCallback(
(next: EditorToggleValue): void => {
const fileId = activeFile?.id
if (!fileId) {
return
}
if (next === 'changes') {
setEditorViewMode(fileId, 'changes')
return
}
setEditorViewMode(fileId, 'edit')
if (next !== 'edit') {
setMarkdownViewMode(fileId, next)
}
},
[activeFile?.id, setEditorViewMode, setMarkdownViewMode]
)
const handleCopyPath = useCallback(async (): Promise<void> => {
if (!activeFile) {
return
@@ -234,7 +216,7 @@ function EditorPanelInner({
sourceGroupId
})
}
const handleOpenDiffTargetFile = (): void => {
const handleOpenDiffTargetFile = (preferredMarkdownViewMode?: 'rich'): void => {
if (!model.openFileState.canOpen) {
return
}
@@ -246,6 +228,25 @@ function EditorPanelInner({
language: detectLanguage(activeFile.relativePath),
mode: 'edit'
})
if (preferredMarkdownViewMode) {
setEditorViewMode(activeFile.filePath, 'edit')
setMarkdownViewMode(activeFile.filePath, preferredMarkdownViewMode)
}
}
const handleEditorToggleChange = (next: EditorToggleValue): void => {
const fileId = activeFile.id
if (activeFile.mode === 'diff' && model.isMarkdown && next === 'rich') {
handleOpenDiffTargetFile('rich')
return
}
if (next === 'changes') {
setEditorViewMode(fileId, 'changes')
return
}
setEditorViewMode(fileId, 'edit')
if (next !== 'edit') {
setMarkdownViewMode(fileId, next)
}
}
const handleOpenMarkdownPreview = (): void => {
openMarkdownPreview({
@@ -61,7 +61,7 @@ type EditorPanelHeaderProps = {
sideBySide: boolean
openFileState: EditorHeaderOpenFileState
onCopyPath: () => void
onOpenDiffTargetFile: () => void
onOpenDiffTargetFile: (preferredMarkdownViewMode?: 'rich') => void
onOpenPreviewToSide: () => void
onOpenMarkdownPreview: () => void
onOpenContainingFolder: () => void
@@ -187,7 +187,7 @@ export function EditorPanelHeader({
<button
type="button"
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground transition-colors flex-shrink-0 disabled:opacity-50 disabled:hover:bg-transparent disabled:hover:text-muted-foreground"
onClick={onOpenDiffTargetFile}
onClick={() => onOpenDiffTargetFile(isMarkdown ? 'rich' : undefined)}
aria-label="Open file"
disabled={!openFileState.canOpen}
>
@@ -29,7 +29,7 @@ type EditorPanelShellProps = {
renameError: string | null
disableRenameBrowse: boolean
onCopyPath: () => void
onOpenDiffTargetFile: () => void
onOpenDiffTargetFile: (preferredMarkdownViewMode?: 'rich') => void
onOpenPreviewToSide: () => void
onOpenMarkdownPreview: () => void
onOpenContainingFolder: () => void
@@ -53,6 +53,8 @@ type MonacoEditorProps = {
worktreeId?: string
markdownAnnotationsEnabled?: boolean
conflictDecorationsEnabled?: boolean
readOnly?: boolean
autoHeight?: boolean
}
export default function MonacoEditor({
@@ -69,11 +71,14 @@ export default function MonacoEditor({
markdownDocuments,
worktreeId,
markdownAnnotationsEnabled = false,
conflictDecorationsEnabled = false
conflictDecorationsEnabled = false,
readOnly = false,
autoHeight = false
}: MonacoEditorProps): React.JSX.Element {
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null)
const editorContainerRef = useRef<HTMLDivElement | null>(null)
const [mountedEditor, setMountedEditor] = useState<editor.IStandaloneCodeEditor | null>(null)
const [autoHeightContentHeight, setAutoHeightContentHeight] = useState<number | null>(null)
const modelKeyRef = useRef<string | null>(null)
const languageRef = useRef(language)
languageRef.current = language
@@ -113,6 +118,16 @@ export default function MonacoEditor({
settings?.terminalFontSize ?? 13,
editorFontZoomLevel
)
const estimatedAutoHeight = useMemo(() => {
if (!autoHeight) {
return null
}
const lineHeight = Math.ceil(editorFontSize * 1.45)
return Math.max(80, content.split(/\r?\n/).length * lineHeight + 18)
}, [autoHeight, content, editorFontSize])
const renderedEditorHeight = autoHeight
? (autoHeightContentHeight ?? estimatedAutoHeight ?? 80)
: null
// Why: `keepCurrentModel` retains Monaco models across unmounts, and
// @monaco-editor/react skips its value→model sync on the first render after
// a remount. Without explicit sync, external file changes that arrived
@@ -279,6 +294,24 @@ export default function MonacoEditor({
(editorInstance, monaco) => {
editorRef.current = editorInstance
setMountedEditor(editorInstance)
let autoHeightSub: { dispose: () => void } | null = null
let autoHeightFrame: number | null = null
const updateAutoHeight = (): void => {
if (!autoHeight) {
return
}
if (autoHeightFrame !== null) {
return
}
autoHeightFrame = window.requestAnimationFrame(() => {
autoHeightFrame = null
setAutoHeightContentHeight(Math.ceil(editorInstance.getContentHeight()) + 1)
})
}
if (autoHeight) {
updateAutoHeight()
autoHeightSub = editorInstance.onDidContentSizeChange(updateAutoHeight)
}
markdownDocLinkDecorationsRef.current = createMarkdownDocLinkDecorationController(
editorInstance,
() => languageRef.current
@@ -337,6 +370,11 @@ export default function MonacoEditor({
})
editorInstance.onDidDispose(() => {
autoHeightSub?.dispose()
if (autoHeightFrame !== null) {
window.cancelAnimationFrame(autoHeightFrame)
autoHeightFrame = null
}
conflictDecorationsRef.current?.clear()
conflictDecorationsRef.current = null
editorRef.current = null
@@ -413,7 +451,8 @@ export default function MonacoEditor({
filePath,
setEditorCursorLine,
updateMarkdownCompletionDocuments,
viewStateKey
viewStateKey,
autoHeight
]
)
@@ -601,7 +640,11 @@ export default function MonacoEditor({
}, [queueReveal, revealLine, revealColumn, revealMatchLength, setPendingEditorReveal])
return (
<div ref={editorContainerRef} className="relative h-full">
<div
ref={editorContainerRef}
className={autoHeight ? 'relative' : 'relative h-full'}
style={renderedEditorHeight === null ? undefined : { height: renderedEditorHeight }}
>
{commentPopover && shouldShowMarkdownAnnotations && (
<DiffCommentPopover
key={commentPopover.lineNumber}
@@ -614,7 +657,7 @@ export default function MonacoEditor({
/>
)}
<Editor
height="100%"
height={renderedEditorHeight === null ? '100%' : `${renderedEditorHeight}px`}
language={language}
value={content}
theme={isDark ? 'vs-dark' : 'vs'}
@@ -634,6 +677,8 @@ export default function MonacoEditor({
renderLineHighlight: 'line',
automaticLayout: true,
tabSize: 2,
readOnly,
scrollbar: autoHeight ? { vertical: 'hidden', handleMouseWheel: false } : undefined,
smoothScrolling: true,
cursorSmoothCaretAnimation: 'off',
padding: { top: 0 },
@@ -18,14 +18,14 @@ describe('getMarkdownViewModes', () => {
).toEqual(['source', 'rich'])
})
it('offers source and preview for single-file markdown diffs', () => {
it('offers source and rich for single-file markdown diffs', () => {
expect(
getMarkdownViewModes({
language: 'markdown',
mode: 'diff',
diffSource: 'unstaged'
})
).toEqual(['source', 'preview'])
).toEqual(['source', 'rich'])
})
it('does not offer preview for mermaid edit tabs', () => {
@@ -6,10 +6,7 @@ type MarkdownPreviewTarget = Pick<OpenFile, 'mode' | 'diffSource'> & {
}
const MARKDOWN_EDIT_VIEW_MODES = ['source', 'rich'] as const satisfies readonly MarkdownViewMode[]
const MARKDOWN_DIFF_VIEW_MODES = [
'source',
'preview'
] as const satisfies readonly MarkdownViewMode[]
const MARKDOWN_DIFF_VIEW_MODES = ['source', 'rich'] as const satisfies readonly MarkdownViewMode[]
const MERMAID_VIEW_MODES = ['source', 'rich'] as const satisfies readonly MarkdownViewMode[]
const CSV_VIEW_MODES = ['source', 'rich'] as const satisfies readonly MarkdownViewMode[]
const NOTEBOOK_VIEW_MODES = ['source', 'rich'] as const satisfies readonly MarkdownViewMode[]
@@ -70,6 +67,9 @@ export function getMarkdownViewModes(target: MarkdownPreviewTarget): readonly Ma
}
export function getDefaultMarkdownViewMode(target: MarkdownPreviewTarget): MarkdownViewMode {
if (target.language === 'markdown' && target.mode === 'diff') {
return 'source'
}
const modes = getMarkdownViewModes(target)
return modes.includes('rich') ? 'rich' : 'source'
}
@@ -4,6 +4,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { OpenFile } from '@/store/slices/editor'
import { getConnectionId } from '@/lib/connection-context'
import { joinPath } from '@/lib/path'
import { useAppStore } from '@/store'
import { getRuntimeFileReadScope, readRuntimeFileContent } from '@/runtime/runtime-file-client'
import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client'
@@ -81,7 +82,12 @@ export function useEditorPanelContentState({
: null
const loadFileContent = useCallback(
async (filePath: string, id: string, worktreeId?: string): Promise<void> => {
async (
filePath: string,
id: string,
worktreeId?: string,
relativePath?: string
): Promise<void> => {
try {
const connectionId = getConnectionId(worktreeId ?? null) ?? undefined
const restoredOpenFile = openFilesRef.current.find((file) => file.id === id)
@@ -108,7 +114,7 @@ export function useEditorPanelContentState({
pending = readRuntimeFileContent({
settings: readSettings,
filePath,
relativePath: restoredOpenFile?.relativePath,
relativePath: restoredOpenFile?.relativePath ?? relativePath,
worktreeId,
connectionId
}) as Promise<FileContent>
@@ -246,12 +252,38 @@ export function useEditorPanelContentState({
delete next[file.id]
return next
})
void loadFileContent(file.filePath, file.id, file.worktreeId)
void loadFileContent(file.filePath, file.id, file.worktreeId, file.relativePath)
},
[loadFileContent]
)
useEffect(() => {
if (activeFile?.mode === 'conflict-review' && !selectedConflictReviewFile) {
const snapshotEntries = activeFile.conflictReview?.entries ?? []
if (snapshotEntries.length === 0) {
return
}
const snapshotPaths = new Set(snapshotEntries.map((entry) => entry.path))
const liveEntries = gitStatusByWorktree[activeFile.worktreeId] ?? []
for (const entry of liveEntries) {
if (
!snapshotPaths.has(entry.path) ||
entry.conflictStatus !== 'unresolved' ||
!entry.conflictKind ||
entry.status === 'deleted'
) {
continue
}
const absolutePath = joinPath(activeFile.filePath, entry.path)
if (!fileContents[absolutePath]) {
void loadFileContent(absolutePath, absolutePath, activeFile.worktreeId, entry.path)
}
}
return
}
const fileToLoad = selectedConflictReviewFile ?? activeFile
if (!fileToLoad || (activeFile?.mode === 'conflict-review' && !selectedConflictReviewFile)) {
return
@@ -281,8 +313,10 @@ export function useEditorPanelContentState({
activeFile?.id,
activeFile?.mode,
activeFile?.conflictReview?.selectedFileId,
activeFile?.conflictReview?.snapshotTimestamp,
selectedConflictReviewFile?.id,
isChangesMode
isChangesMode,
gitStatusByWorktree
])
useEditorPanelFileLoadRetry({
@@ -0,0 +1,127 @@
import { describe, expect, it } from 'vitest'
import type { GitHubWorkItem } from '../../../shared/types'
import {
appendGitHubPRRequestedReviewers,
getGitHubPRPrimaryReviewer,
getGitHubPRReviewerRows,
getGitHubPRReviewLabel,
normalizeGitHubReviewerLogins
} from './github-pr-reviewer-display'
function item(patch: Partial<GitHubWorkItem>): GitHubWorkItem {
return patch as GitHubWorkItem
}
describe('GitHub PR reviewer display', () => {
it('shows the requested reviewer instead of a request count', () => {
expect(
getGitHubPRReviewLabel(
item({
reviewRequests: [{ login: 'ExampleReviewer', name: null, avatarUrl: '' }]
})
)
).toBe('ExampleReviewer')
})
it('keeps multiple reviewers compact while still naming the first reviewer', () => {
expect(
getGitHubPRReviewLabel(
item({
reviewRequests: [
{ login: 'ExampleReviewer', name: null, avatarUrl: '' },
{ login: 'agent-slack', name: null, avatarUrl: '' },
{ login: 'stably', name: null, avatarUrl: '' }
]
})
)
).toBe('ExampleReviewer +2')
})
it('preserves stronger review decision labels', () => {
expect(
getGitHubPRReviewLabel(
item({
reviewDecision: 'APPROVED',
reviewRequests: [{ login: 'ExampleReviewer', name: null, avatarUrl: '' }]
})
)
).toBe('Approved')
})
it('falls back to reviewed users and empty metadata labels', () => {
expect(getGitHubPRReviewLabel(item({ latestReviews: [{ login: 'reviewer' }] }))).toBe(
'reviewer'
)
expect(getGitHubPRReviewLabel(item({ reviewRequests: [] }))).toBe('No reviewers')
expect(getGitHubPRReviewLabel(item({}))).toBe('Reviewers')
})
it('returns the primary reviewer avatar without requiring another lookup', () => {
expect(
getGitHubPRPrimaryReviewer(
item({
reviewRequests: [
{
login: 'ExampleReviewer',
name: null,
avatarUrl: 'https://avatars.githubusercontent.com/u/1?v=4'
}
]
})
)
).toEqual({
login: 'ExampleReviewer',
name: null,
avatarUrl: 'https://avatars.githubusercontent.com/u/1?v=4'
})
})
it('builds reviewer rows for requested and reviewed users', () => {
expect(
getGitHubPRReviewerRows(
item({
reviewRequests: [{ login: 'ExampleReviewer', name: null, avatarUrl: 'avatar-1' }],
latestReviews: [
{ login: 'reviewer', state: 'APPROVED', avatarUrl: 'avatar-2' },
{ login: 'ExampleReviewer', state: 'COMMENTED', avatarUrl: 'avatar-1b' }
]
})
)
).toEqual([
{
login: 'ExampleReviewer',
name: null,
avatarUrl: 'avatar-1',
stateLabel: 'Requested'
},
{
login: 'reviewer',
name: null,
avatarUrl: 'avatar-2',
stateLabel: 'Approved'
}
])
})
it('appends requested reviewers without duplicating existing logins', () => {
expect(
appendGitHubPRRequestedReviewers(
[{ login: 'ExampleReviewer', name: null, avatarUrl: 'avatar-1' }],
['examplereviewer', '@new-reviewer']
)
).toEqual([
{ login: 'ExampleReviewer', name: null, avatarUrl: 'avatar-1' },
{ login: 'new-reviewer', name: null, avatarUrl: '' }
])
})
it('normalizes reviewer input before sending it to GitHub', () => {
expect(
normalizeGitHubReviewerLogins(
[' @ExampleReviewer ', 'examplereviewer', '@new-reviewer'],
new Set(['existing'])
)
).toEqual(['ExampleReviewer', 'new-reviewer'])
})
})
@@ -0,0 +1,164 @@
import type { GitHubAssignableUser, GitHubWorkItem } from '../../../shared/types'
type ReviewDisplayItem = Pick<GitHubWorkItem, 'reviewDecision' | 'reviewRequests' | 'latestReviews'>
export type GitHubPRPrimaryReviewer = Pick<GitHubAssignableUser, 'login' | 'avatarUrl'> & {
name?: string | null
}
export type GitHubPRReviewerRow = GitHubPRPrimaryReviewer & {
stateLabel: string
}
function uniqueLogins(logins: readonly (string | null | undefined)[]): string[] {
const seen = new Set<string>()
const result: string[] = []
for (const login of logins) {
const trimmed = login?.trim()
if (!trimmed) {
continue
}
const key = trimmed.toLowerCase()
if (seen.has(key)) {
continue
}
seen.add(key)
result.push(trimmed)
}
return result
}
export function normalizeGitHubReviewerLogins(
logins: readonly string[],
excludedLogins: ReadonlySet<string> = new Set()
): string[] {
return uniqueLogins(logins.map((login) => login.trim().replace(/^@/, ''))).filter(
(login) => !excludedLogins.has(login.toLowerCase())
)
}
function formatReviewerLogins(logins: readonly string[]): string | null {
if (logins.length === 0) {
return null
}
if (logins.length === 1) {
return logins[0]
}
return `${logins[0]} +${logins.length - 1}`
}
function formatReviewState(state: string | null | undefined): string {
switch (state) {
case 'APPROVED':
return 'Approved'
case 'CHANGES_REQUESTED':
return 'Changes requested'
case 'COMMENTED':
return 'Commented'
case 'DISMISSED':
return 'Dismissed'
case 'PENDING':
return 'Pending'
default:
return 'Reviewed'
}
}
export function getGitHubPRReviewLabel(item: ReviewDisplayItem): string {
if (
item.reviewDecision === undefined &&
item.reviewRequests === undefined &&
item.latestReviews === undefined
) {
return 'Reviewers'
}
if (item.reviewDecision === 'APPROVED') {
return 'Approved'
}
if (item.reviewDecision === 'CHANGES_REQUESTED') {
return 'Changes requested'
}
const requestedLabel = formatReviewerLogins(
uniqueLogins((item.reviewRequests ?? []).map((user) => user.login))
)
if (requestedLabel) {
return requestedLabel
}
const reviewedLabel = formatReviewerLogins(
uniqueLogins((item.latestReviews ?? []).map((review) => review.login))
)
if (reviewedLabel) {
return reviewedLabel
}
return 'No reviewers'
}
export function getGitHubPRPrimaryReviewer(
item: ReviewDisplayItem
): GitHubPRPrimaryReviewer | null {
const requested = (item.reviewRequests ?? []).find((user) => user.login.trim())
if (requested) {
return requested
}
const reviewed = (item.latestReviews ?? []).find((review) => review.login.trim())
if (reviewed) {
return {
login: reviewed.login,
avatarUrl: reviewed.avatarUrl ?? '',
name: null
}
}
return null
}
export function getGitHubPRReviewerRows(item: ReviewDisplayItem): GitHubPRReviewerRow[] {
const byLogin = new Map<string, GitHubPRReviewerRow>()
for (const user of item.reviewRequests ?? []) {
const login = user.login.trim()
if (!login) {
continue
}
byLogin.set(login.toLowerCase(), {
login,
name: user.name,
avatarUrl: user.avatarUrl,
stateLabel: 'Requested'
})
}
for (const review of item.latestReviews ?? []) {
const login = review.login.trim()
const key = login.toLowerCase()
if (!login || byLogin.has(key)) {
continue
}
byLogin.set(key, {
login,
name: null,
avatarUrl: review.avatarUrl ?? '',
stateLabel: formatReviewState(review.state)
})
}
return Array.from(byLogin.values())
}
export function appendGitHubPRRequestedReviewers(
current: readonly GitHubAssignableUser[],
logins: readonly string[]
): GitHubAssignableUser[] {
const byLogin = new Map<string, GitHubAssignableUser>()
for (const user of current) {
const login = user.login.trim()
if (login) {
byLogin.set(login.toLowerCase(), user)
}
}
for (const rawLogin of logins) {
const login = rawLogin.trim().replace(/^@/, '')
if (!login) {
continue
}
const key = login.toLowerCase()
if (!byLogin.has(key)) {
byLogin.set(key, { login, name: null, avatarUrl: '' })
}
}
return Array.from(byLogin.values())
}
@@ -13,6 +13,11 @@ import type {
GitHubProjectRow as GitHubProjectRowType
} from '../../../../shared/github-project-types'
const PROJECT_FROZEN_COLUMN_SURFACE_CLASS =
'[background:color-mix(in_srgb,var(--muted)_50%,var(--background))]'
const PROJECT_FROZEN_COLUMN_HOVER_SURFACE_CLASS =
'group-hover/project-row:[background:color-mix(in_srgb,var(--accent)_60%,var(--background))]'
type Props = {
row: GitHubProjectRowType
fields: GitHubProjectField[]
@@ -55,15 +60,32 @@ export default function ProjectRow({
const rowInner = (
<div
className={cn(
'group grid min-h-10 items-stretch gap-3 border-b border-border/30 px-3 hover:bg-accent/60',
'group group/project-row grid min-h-10 items-stretch gap-3 border-b border-border/30 px-3 hover:bg-accent/60',
disabled && 'opacity-60'
)}
style={{ gridTemplateColumns: gridTemplate }}
>
{fields.map((f, idx) => {
const next = fields[idx + 1]
const frozen = idx < 2
return (
<div key={f.id} className="relative flex min-w-0 items-stretch overflow-hidden">
<div
key={f.id}
className={cn(
'flex min-w-0 items-stretch overflow-hidden',
!frozen && 'relative',
frozen &&
cn(
'relative z-10 before:absolute before:-left-3 before:top-0 before:bottom-0 before:w-3 before:bg-inherit',
PROJECT_FROZEN_COLUMN_SURFACE_CLASS,
PROJECT_FROZEN_COLUMN_HOVER_SURFACE_CLASS
),
idx === 1 && 'border-r border-border/40'
)}
style={
frozen ? { transform: 'translateX(var(--project-scroll-left, 0px))' } : undefined
}
>
<div className="flex min-w-0 flex-1 items-stretch overflow-hidden">
<ProjectCell
row={row}
@@ -8,7 +8,7 @@ import ProjectRow from './ProjectRow'
import { groupRows, sortRows } from './group-sort'
import { getAvailableColumns, loadHiddenColumns, saveHiddenColumns } from './columns'
import {
buildGridTemplate,
ACTION_COLUMN_WIDTH,
loadColumnWidths,
MIN_COLUMN_WIDTH,
resolveWidth,
@@ -25,6 +25,24 @@ import type {
type SortOverride = { fieldId: string; direction: GitHubProjectSortDirection }
const PROJECT_FROZEN_COLUMN_HEADER_SURFACE_CLASS =
'[background:color-mix(in_srgb,var(--background)_95%,var(--muted))]'
function buildProjectGridTemplate(
fields: GitHubProjectField[],
widths: Readonly<Record<string, number>>
): string {
// Why: the first two columns are frozen during horizontal scroll, so their
// actual widths must be deterministic for the second sticky offset.
const cols = fields.map((field, index) =>
index < 2
? `${resolveWidth(field, widths)}px`
: `minmax(${MIN_COLUMN_WIDTH}px, ${resolveWidth(field, widths)}fr)`
)
cols.push(`${ACTION_COLUMN_WIDTH}px`)
return cols.join(' ')
}
type Props = {
table: GitHubProjectTable
onOpenDialog?: (row: GitHubProjectRow) => void
@@ -94,7 +112,16 @@ export default function ProjectViewList({
[scopeKey]
)
const gridTemplate = useMemo(() => buildGridTemplate(fields, widths), [fields, widths])
const gridTemplate = useMemo(() => buildProjectGridTemplate(fields, widths), [fields, widths])
const handleListScroll = useCallback((event: React.UIEvent<HTMLDivElement>): void => {
// Why: frozen columns need the horizontal offset, but piping every scroll
// tick through React state rerenders the entire project row set.
event.currentTarget.style.setProperty(
'--project-scroll-left',
`${event.currentTarget.scrollLeft}px`
)
}, [])
const toggleColumn = (fieldId: string): void => {
setHidden((prev) => {
@@ -167,7 +194,11 @@ export default function ProjectViewList({
: null
return (
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-y-auto">
<div
className="flex min-h-0 min-w-0 flex-1 flex-col overflow-auto scrollbar-sleek"
style={{ '--project-scroll-left': '0px' } as React.CSSProperties}
onScroll={handleListScroll}
>
<ProjectHeaderRow
fields={fields}
availableFields={availableFields}
@@ -265,8 +296,24 @@ function ProjectHeaderRow({
// user-resizable pair set), so omit its handle to keep the total
// table width invariant.
const next = fields[idx + 1]
const frozen = idx < 2
return (
<div key={f.id} className="relative flex min-w-0 items-center">
<div
key={f.id}
className={cn(
'flex min-w-0 items-center',
!frozen && 'relative',
frozen &&
cn(
'relative z-20 backdrop-blur before:absolute before:-left-3 before:top-0 before:bottom-0 before:w-3 before:bg-inherit',
PROJECT_FROZEN_COLUMN_HEADER_SURFACE_CLASS
),
idx === 1 && 'border-r border-border/50'
)}
style={
frozen ? { transform: 'translateX(var(--project-scroll-left, 0px))' } : undefined
}
>
<button
type="button"
onClick={() => onSortClick(f.id)}
@@ -84,6 +84,7 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
)
const [loading, setLoading] = useState(false)
const fetchRunIdRef = useRef(0)
const [error, setError] = useState<{
error: GitHubProjectViewError
totalCount?: number
@@ -111,6 +112,8 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
const doFetch = useCallback(
async (selection: ResolvedProjectSelection, force = false, queryOverride?: string) => {
const runId = fetchRunIdRef.current + 1
fetchRunIdRef.current = runId
setLoading(true)
setError(null)
try {
@@ -128,7 +131,11 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
setError({ error: res.error, totalCount: res.totalCount })
}
} finally {
setLoading(false)
// Why: a manual refresh can overlap with a tab/search fetch; an older
// request finishing first must not clear the newer refresh indicator.
if (fetchRunIdRef.current === runId) {
setLoading(false)
}
}
},
[fetchProjectViewTable]
@@ -582,7 +589,7 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
return (
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<div className="flex flex-none items-center gap-2 border-b border-border/50 bg-muted/30 px-3 py-2">
<div className="flex min-w-0 flex-none flex-wrap items-center gap-2 border-b border-border/50 bg-muted/30 px-3 py-2">
<ProjectPicker
activeProject={
activeProject && table
@@ -665,7 +672,7 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
<Button
variant="outline"
size="icon"
className="h-7 w-7"
className="h-7 w-7 cursor-pointer disabled:pointer-events-auto disabled:cursor-wait"
onClick={() => {
if (!activeProject || !currentCacheKey) {
return
@@ -686,7 +693,10 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
currentAppliedOverride
)
}}
aria-label="Refresh"
disabled={loading}
aria-busy={loading}
aria-label={loading ? 'Refreshing' : 'Refresh'}
title={loading ? 'Refreshing' : 'Refresh'}
>
<RefreshCw className={cn('size-3.5', loading && 'animate-spin')} />
</Button>
@@ -899,7 +909,7 @@ function ProjectSearchInput({
}, [])
return (
<div className="relative min-w-[280px] flex-1 max-w-xl">
<div className="relative min-w-0 max-w-xl flex-1 basis-64">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
ref={inputRef}
@@ -252,14 +252,14 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R
Claude
</h3>
<p className="text-xs text-muted-foreground">
Add and switch Claude Code accounts without moving chat sessions to account-specific
config directories.
Optional. Orca can use your normal Claude login; add accounts only if you want quick
switching without moving chat sessions.
</p>
</div>
<SearchableSetting
title="Claude Accounts"
description="Manage which Claude account Orca materializes into the shared Claude auth files."
description="Optional account switcher for the shared Claude auth files."
keywords={['claude', 'account', 'rate limit', 'status bar', 'quota']}
className="space-y-3 px-1 py-2"
>
@@ -419,7 +419,8 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R
Codex
</h3>
<p className="text-xs text-muted-foreground">
Add and switch between Codex accounts in Orca.
Optional. Orca can use your normal Codex login; add accounts only if you want quick
switching in Orca.
</p>
<p className="text-xs text-muted-foreground">
Each account keeps its own local sign-in context in Orca. Account auth stays on this
@@ -278,33 +278,33 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea
breaks that toast action even though this pane still renders fine. */}
<div id="general-skip-delete-worktree-confirm" className="scroll-mt-6">
<SearchableSetting
title="Skip Delete Worktree Confirmation"
description="Delete worktrees from the context menu without a confirmation dialog."
title="Ask Before Deleting Worktrees"
description="Show a confirmation dialog before deleting a worktree."
keywords={['delete', 'worktree', 'confirm', 'dialog', 'skip', 'prompt']}
className="flex items-center justify-between gap-4 px-1 py-2"
>
<div className="space-y-0.5">
<Label>Skip Delete Worktree Confirmation</Label>
<Label>Ask Before Deleting Worktrees</Label>
<p className="text-xs text-muted-foreground">
Delete worktrees from the context menu without a confirmation dialog. Errors still
surface as a toast with a Force Delete fallback.
Show a confirmation before deleting a worktree from the context menu. Failed deletes
still surface a Force Delete fallback.
</p>
</div>
<button
role="switch"
aria-checked={settings.skipDeleteWorktreeConfirm}
aria-checked={!settings.skipDeleteWorktreeConfirm}
onClick={() =>
updateSettings({
skipDeleteWorktreeConfirm: !settings.skipDeleteWorktreeConfirm
})
}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
settings.skipDeleteWorktreeConfirm ? 'bg-foreground' : 'bg-muted-foreground/30'
!settings.skipDeleteWorktreeConfirm ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
settings.skipDeleteWorktreeConfirm ? 'translate-x-4' : 'translate-x-0.5'
!settings.skipDeleteWorktreeConfirm ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
@@ -313,32 +313,32 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea
<div id="general-skip-delete-automation-confirm" className="scroll-mt-6">
<SearchableSetting
title="Skip Delete Automation Confirmation"
description="Delete automations without a confirmation dialog."
title="Ask Before Deleting Automations"
description="Show a confirmation dialog before deleting an automation and its run history."
keywords={['delete', 'automation', 'confirm', 'dialog', 'skip', 'prompt']}
className="flex items-center justify-between gap-4 px-1 py-2"
>
<div className="space-y-0.5">
<Label>Skip Delete Automation Confirmation</Label>
<Label>Ask Before Deleting Automations</Label>
<p className="text-xs text-muted-foreground">
Delete automations and their run history without a confirmation dialog.
Show a confirmation before deleting automations and their run history.
</p>
</div>
<button
role="switch"
aria-checked={settings.skipDeleteAutomationConfirm}
aria-checked={!settings.skipDeleteAutomationConfirm}
onClick={() =>
updateSettings({
skipDeleteAutomationConfirm: !settings.skipDeleteAutomationConfirm
})
}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
settings.skipDeleteAutomationConfirm ? 'bg-foreground' : 'bg-muted-foreground/30'
!settings.skipDeleteAutomationConfirm ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
settings.skipDeleteAutomationConfirm ? 'translate-x-4' : 'translate-x-0.5'
!settings.skipDeleteAutomationConfirm ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
@@ -47,6 +47,25 @@ type NotificationsPaneProps = {
updateSettings: (updates: Partial<GlobalSettings>) => void
}
function getRendererNotificationPermission(): NotificationPermission | null {
if (typeof window.Notification === 'undefined') {
return null
}
return window.Notification.permission
}
function showNotificationPermissionDeniedToast(): void {
toast.error('Notifications are blocked in macOS', {
description: 'Enable notifications for this Orca app in System Settings.',
action: {
label: 'Open Settings',
onClick: () => {
void window.api.notifications.openSystemSettings()
}
}
})
}
export function NotificationsPane({
settings,
updateSettings
@@ -64,6 +83,20 @@ export function NotificationsPane({
}
const handleSendTestNotification = async (): Promise<void> => {
// Why: Electron main cannot reliably read macOS notification authorization,
// but the renderer exposes it. Without this check, dev builds can report
// "sent" while macOS silently drops the notification.
if (getRendererNotificationPermission() === 'denied') {
showNotificationPermissionDeniedToast()
return
}
const permissionStatus = await window.api.notifications.getPermissionStatus()
if (!permissionStatus.supported) {
toast.error('Notifications are not supported on this system')
return
}
const result = await window.api.notifications.dispatch({ source: 'test' })
if (result.delivered) {
// Why: the Test button must always play through, even if the user clicks
@@ -77,7 +110,19 @@ export function NotificationsPane({
return
}
toast.success('Test notification sent')
return
}
if (getRendererNotificationPermission() === 'denied') {
showNotificationPermissionDeniedToast()
return
}
toast.error(
result.reason === 'disabled'
? 'Notifications are disabled'
: 'Test notification was not delivered'
)
}
const handleChooseSound = async (): Promise<void> => {
@@ -6,6 +6,7 @@ import { Button } from '../ui/button'
import { Input } from '../ui/input'
import { Label } from '../ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
import { RuntimeAccessGrantList } from './RuntimeAccessGrantList'
const LOOPBACK_ADDRESS = '127.0.0.1'
@@ -105,9 +106,11 @@ export function RuntimePairingUrlGenerator({
)
const [runtimeAccessGrants, setRuntimeAccessGrants] = useState<RuntimeAccessGrant[]>([])
const [isLoadingAccessGrants, setIsLoadingAccessGrants] = useState(false)
const [refreshingNetworkInterfaces, setRefreshingNetworkInterfaces] = useState(false)
const [revokingGrantId, setRevokingGrantId] = useState<string | null>(null)
const [copiedTarget, setCopiedTarget] = useState<'web' | 'pairing' | null>(null)
const [isGeneratingPairing, setIsGeneratingPairing] = useState(false)
const networkInterfaceLoadIdRef = useRef(0)
const accessGrantLoadIdRef = useRef(0)
const loadRuntimeAccessGrants = useCallback(
@@ -135,26 +138,35 @@ export function RuntimePairingUrlGenerator({
[]
)
useEffect(() => {
let stale = false
const loadNetworkInterfaces = async (): Promise<void> => {
const loadNetworkInterfaces = useCallback(
async (options: { showToastOnError?: boolean } = {}): Promise<void> => {
const loadId = networkInterfaceLoadIdRef.current + 1
networkInterfaceLoadIdRef.current = loadId
setRefreshingNetworkInterfaces(true)
try {
const result = await window.api.mobile.listNetworkInterfaces()
if (!stale) {
if (loadId === networkInterfaceLoadIdRef.current) {
setNetworkInterfaces(result.interfaces)
}
} catch {
// Keep the loopback option available even if interface enumeration fails.
if (loadId === networkInterfaceLoadIdRef.current && options.showToastOnError) {
toast.error('Failed to refresh network interfaces.')
}
} finally {
if (loadId === networkInterfaceLoadIdRef.current) {
setRefreshingNetworkInterfaces(false)
}
}
}
},
[]
)
useEffect(() => {
void loadNetworkInterfaces()
return () => {
stale = true
networkInterfaceLoadIdRef.current += 1
}
}, [])
}, [loadNetworkInterfaces])
useEffect(() => {
void loadRuntimeAccessGrants()
@@ -269,29 +281,51 @@ export function RuntimePairingUrlGenerator({
<Label id="runtime-pairing-address-label" htmlFor="runtime-pairing-address">
Connection address
</Label>
<Select value={selectedAddress} onValueChange={updateSelectedAddress}>
<SelectTrigger
id="runtime-pairing-address"
size="sm"
className="min-w-[220px]"
aria-labelledby="runtime-pairing-address-label"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={LOOPBACK_ADDRESS}>
This computer ({LOOPBACK_ADDRESS})
</SelectItem>
{networkInterfaces.map((networkInterface, index) => (
<SelectItem
key={`${networkInterface.name}:${networkInterface.address}:${index}`}
value={networkInterface.address}
>
{networkInterface.name} ({networkInterface.address})
<div className="flex items-center gap-2">
<Select value={selectedAddress} onValueChange={updateSelectedAddress}>
<SelectTrigger
id="runtime-pairing-address"
size="sm"
className="min-w-[220px]"
aria-labelledby="runtime-pairing-address-label"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={LOOPBACK_ADDRESS}>
This computer ({LOOPBACK_ADDRESS})
</SelectItem>
))}
</SelectContent>
</Select>
{networkInterfaces.map((networkInterface, index) => (
<SelectItem
key={`${networkInterface.name}:${networkInterface.address}:${index}`}
value={networkInterface.address}
>
{networkInterface.name} ({networkInterface.address})
</SelectItem>
))}
</SelectContent>
</Select>
{/* Why: server sharing uses the same interface list as Mobile,
and VPN/tailnet addresses can appear after Settings opens. */}
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => void loadNetworkInterfaces({ showToastOnError: true })}
disabled={refreshingNetworkInterfaces}
aria-label="Refresh connection addresses"
className="text-muted-foreground"
>
<RefreshCw className={refreshingNetworkInterfaces ? 'animate-spin' : ''} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
Refresh connection addresses
</TooltipContent>
</Tooltip>
</div>
</div>
<div className="min-w-0 space-y-1">
<Label htmlFor="runtime-pairing-custom-address">Custom address</Label>
+222 -178
View File
@@ -116,9 +116,26 @@ type SettingsNavSection = {
description: string
icon: typeof SlidersHorizontal
searchEntries: SettingsSearchEntry[]
group: string
badge?: string
}
type SettingsNavGroup = {
id: string
title: string
sections: SettingsNavSection[]
}
const SETTINGS_NAV_GROUPS = [
{ id: 'setup', title: 'Set Up' },
{ id: 'workflows', title: 'Workflows' },
{ id: 'interface', title: 'Interface' },
{ id: 'capabilities', title: 'AI Capabilities' },
{ id: 'remote', title: 'Remote Access' },
{ id: 'safety', title: 'Safety' },
{ id: 'experimental', title: 'Experimental' }
] as const
function getSettingsSectionId(pane: SettingsNavTarget, repoId: string | null): string {
if (pane === 'repo' && repoId) {
return `repo-${repoId}`
@@ -416,61 +433,78 @@ function Settings(): React.JSX.Element {
{
id: 'general',
title: 'General',
description: 'Workspace, editor, and updates.',
description: 'Workspace defaults, app setup, and maintenance.',
icon: SlidersHorizontal,
searchEntries: GENERAL_PANE_SEARCH_ENTRIES
searchEntries: GENERAL_PANE_SEARCH_ENTRIES,
group: 'setup'
},
{
id: 'agents',
title: 'Agents',
description: 'Manage AI agents, set a default, and customize commands.',
icon: Bot,
searchEntries: AGENTS_PANE_SEARCH_ENTRIES
searchEntries: AGENTS_PANE_SEARCH_ENTRIES,
group: 'setup'
},
{
id: 'accounts',
title: 'Agent Accounts',
description: 'Sign in and switch between Claude, Codex, Gemini, and OpenCode Go accounts.',
title: 'AI Provider Accounts',
description: 'Optional account switching for Claude, Codex, Gemini, and OpenCode Go.',
icon: UserCog,
searchEntries: ACCOUNTS_PANE_SEARCH_ENTRIES
searchEntries: ACCOUNTS_PANE_SEARCH_ENTRIES,
group: 'setup',
badge: 'Optional'
},
{
id: 'integrations',
title: 'Integrations',
description: 'Connect GitHub, GitLab, Linear, and source-hosting services.',
icon: Blocks,
searchEntries: INTEGRATIONS_PANE_SEARCH_ENTRIES,
group: 'setup'
},
{
id: 'git',
title: 'Git',
description: 'Branch naming, local ref behavior, and AI commit messages.',
title: 'Git & Source Control',
description: 'Branch naming, base refs, attribution, and AI commit messages.',
icon: GitBranch,
// Why: the AI commit messages pane is rendered inside the Git section,
// so its search entries belong to Git too — that way a query like
// "claude" or "thinking" still surfaces the section.
searchEntries: [...GIT_PANE_SEARCH_ENTRIES, ...COMMIT_MESSAGE_AI_PANE_SEARCH_ENTRIES]
searchEntries: [...GIT_PANE_SEARCH_ENTRIES, ...COMMIT_MESSAGE_AI_PANE_SEARCH_ENTRIES],
group: 'workflows'
},
{
id: 'tasks',
title: 'Tasks',
title: 'Task Sources',
description: 'Choose which task providers appear in the Tasks page and sidebar.',
icon: ListChecks,
searchEntries: TASKS_PANE_SEARCH_ENTRIES
searchEntries: TASKS_PANE_SEARCH_ENTRIES,
group: 'workflows'
},
{
id: 'appearance',
title: 'Appearance',
description: 'Theme and UI scaling.',
description: 'Theme, zoom, app font, sidebars, and status bar.',
icon: Palette,
searchEntries: APPEARANCE_PANE_SEARCH_ENTRIES
searchEntries: APPEARANCE_PANE_SEARCH_ENTRIES,
group: 'interface'
},
{
id: 'input',
title: 'Input & Editing',
description: 'Selection and editing behavior.',
icon: TextCursorInput,
searchEntries: INPUT_PANE_SEARCH_ENTRIES
searchEntries: INPUT_PANE_SEARCH_ENTRIES,
group: 'interface'
},
{
id: 'terminal',
title: 'Terminal',
description: 'Terminal appearance, previews, and defaults for new panes.',
description: 'Shells, terminal appearance, quick commands, and pane behavior.',
icon: SquareTerminal,
searchEntries: terminalPaneSearchEntries
searchEntries: terminalPaneSearchEntries,
group: 'workflows'
},
...(showDesktopOnlySettings
? [
@@ -479,14 +513,16 @@ function Settings(): React.JSX.Element {
title: 'Browser',
description: 'Home page, link routing, and session cookies.',
icon: Globe,
searchEntries: BROWSER_PANE_SEARCH_ENTRIES
searchEntries: BROWSER_PANE_SEARCH_ENTRIES,
group: 'workflows'
},
{
id: 'notifications' as const,
title: 'Notifications',
description: 'Native desktop notifications for agent and terminal events.',
icon: Bell,
searchEntries: NOTIFICATIONS_PANE_SEARCH_ENTRIES
searchEntries: NOTIFICATIONS_PANE_SEARCH_ENTRIES,
group: 'interface'
}
]
: []),
@@ -495,26 +531,37 @@ function Settings(): React.JSX.Element {
title: 'Orchestration',
description: 'Coordinate multiple coding agents through Orca.',
icon: Network,
searchEntries: ORCHESTRATION_PANE_SEARCH_ENTRIES
searchEntries: ORCHESTRATION_PANE_SEARCH_ENTRIES,
group: 'capabilities'
},
{
id: 'servers',
title: 'Servers',
title: 'Remote Orca Servers',
description: isWebClient
? 'Connect this browser to a saved Orca server.'
: 'Run this client locally or through a remote Orca server.',
: 'Switch between local desktop mode and paired remote Orca runtimes.',
icon: Server,
searchEntries: [runtimeEnvironmentsSearchEntry],
group: 'remote',
badge: 'Beta'
},
...(showDesktopOnlySettings
? [
{
id: 'ssh' as const,
title: 'SSH Hosts',
description: 'Remote SSH hosts for files, terminals, and git.',
icon: Cable,
searchEntries: SSH_PANE_SEARCH_ENTRIES,
group: 'remote'
},
{
id: 'mobile' as const,
title: 'Mobile',
description: 'Control terminals and agents from your phone.',
icon: Smartphone,
searchEntries: MOBILE_SETTINGS_PANE_SEARCH_ENTRIES,
group: 'remote',
badge: 'Beta'
},
{
@@ -523,6 +570,7 @@ function Settings(): React.JSX.Element {
description: 'Enable agents to control any app on your computer.',
icon: MousePointerClick,
searchEntries: COMPUTER_USE_PANE_SEARCH_ENTRIES,
group: 'capabilities',
badge: 'Beta'
},
{
@@ -531,6 +579,7 @@ function Settings(): React.JSX.Element {
description: 'Local speech-to-text dictation with on-device models.',
icon: Mic,
searchEntries: VOICE_PANE_SEARCH_ENTRIES,
group: 'capabilities',
badge: 'Beta'
}
]
@@ -539,10 +588,11 @@ function Settings(): React.JSX.Element {
? [
{
id: 'developer-permissions' as const,
title: 'Permissions',
title: 'macOS Permissions',
description: 'macOS privacy access for terminal-launched developer tools.',
icon: ShieldCheck,
searchEntries: DEVELOPER_PERMISSIONS_PANE_SEARCH_ENTRIES
searchEntries: DEVELOPER_PERMISSIONS_PANE_SEARCH_ENTRIES,
group: 'safety'
}
]
: []),
@@ -551,53 +601,40 @@ function Settings(): React.JSX.Element {
title: 'Privacy & Telemetry',
description: 'Anonymous usage data and telemetry controls.',
icon: Lock,
searchEntries: PRIVACY_PANE_SEARCH_ENTRIES
searchEntries: PRIVACY_PANE_SEARCH_ENTRIES,
group: 'safety'
},
{
id: 'shortcuts',
title: 'Shortcuts',
description: 'Keyboard shortcuts for common actions.',
icon: Keyboard,
searchEntries: SHORTCUTS_PANE_SEARCH_ENTRIES
},
{
id: 'integrations',
title: 'Integrations',
description: 'GitHub, Linear, and other service connections.',
icon: Blocks,
searchEntries: INTEGRATIONS_PANE_SEARCH_ENTRIES
searchEntries: SHORTCUTS_PANE_SEARCH_ENTRIES,
group: 'interface'
},
{
id: 'stats',
title: 'Stats & Usage',
description: 'Orca stats plus Claude, Codex, and OpenCode usage analytics.',
icon: BarChart3,
searchEntries: STATS_PANE_SEARCH_ENTRIES
searchEntries: STATS_PANE_SEARCH_ENTRIES,
group: 'interface'
},
...(showDesktopOnlySettings
? [
{
id: 'ssh' as const,
title: 'SSH',
description: 'Remote SSH connections.',
icon: Cable,
searchEntries: SSH_PANE_SEARCH_ENTRIES
}
]
: []),
{
id: 'experimental',
title: 'Experimental',
description: 'New features that are still taking shape. Give them a try.',
icon: FlaskConical,
searchEntries: EXPERIMENTAL_PANE_SEARCH_ENTRIES
searchEntries: EXPERIMENTAL_PANE_SEARCH_ENTRIES,
group: 'experimental'
},
...repos.map((repo) => ({
id: `repo-${repo.id}`,
title: repo.displayName,
description: `${getRepoKindLabel(repo)} • ${repo.path}`,
icon: SlidersHorizontal,
searchEntries: getRepositoryPaneSearchEntries(repo)
searchEntries: getRepositoryPaneSearchEntries(repo),
group: 'repositories'
}))
],
[
@@ -958,6 +995,10 @@ function Settings(): React.JSX.Element {
}
const generalNavSections = visibleNavSections.filter((section) => !section.id.startsWith('repo-'))
const generalNavGroups: SettingsNavGroup[] = SETTINGS_NAV_GROUPS.map((group) => ({
...group,
sections: generalNavSections.filter((section) => section.group === group.id)
})).filter((group) => group.sections.length > 0)
const repoNavSections = visibleNavSections
.filter((section) => section.id.startsWith('repo-'))
.map((section) => {
@@ -970,7 +1011,7 @@ function Settings(): React.JSX.Element {
<div className="settings-view-shell flex min-h-0 flex-1 overflow-hidden bg-background">
<SettingsSidebar
activeSectionId={activeSectionId}
generalSections={generalNavSections}
generalGroups={generalNavGroups}
repoSections={repoNavSections}
hasRepos={repos.length > 0}
searchQuery={settingsSearchInputQuery}
@@ -992,7 +1033,7 @@ function Settings(): React.JSX.Element {
<SettingsSection
id="general"
title="General"
description="Workspace, editor, and updates."
description="Workspace defaults, app setup, and maintenance."
searchEntries={GENERAL_PANE_SEARCH_ENTRIES}
>
{isSectionMounted('general') ? (
@@ -1000,15 +1041,6 @@ function Settings(): React.JSX.Element {
) : null}
</SettingsSection>
<SettingsSection
id="integrations"
title="Integrations"
description="GitHub, Linear, and other service connections."
searchEntries={INTEGRATIONS_PANE_SEARCH_ENTRIES}
>
{isSectionMounted('integrations') ? <IntegrationsPane /> : null}
</SettingsSection>
<SettingsSection
id="agents"
title="Agents"
@@ -1022,8 +1054,9 @@ function Settings(): React.JSX.Element {
<SettingsSection
id="accounts"
title="Agent Accounts"
description="Sign in and switch between Claude, Codex, Gemini, and OpenCode Go accounts."
title="AI Provider Accounts"
description="Optional. Orca works with your existing provider logins; add accounts only if you want Orca to help switch between them."
badge="Optional"
searchEntries={ACCOUNTS_PANE_SEARCH_ENTRIES}
>
{isSectionMounted('accounts') ? (
@@ -1031,10 +1064,19 @@ function Settings(): React.JSX.Element {
) : null}
</SettingsSection>
<SettingsSection
id="integrations"
title="Integrations"
description="Connect GitHub, GitLab, Linear, and source-hosting services."
searchEntries={INTEGRATIONS_PANE_SEARCH_ENTRIES}
>
{isSectionMounted('integrations') ? <IntegrationsPane /> : null}
</SettingsSection>
<SettingsSection
id="git"
title="Git"
description="Branch naming, local ref behavior, and AI commit messages."
title="Git & Source Control"
description="Branch naming, base refs, attribution, and AI commit messages."
searchEntries={[
...GIT_PANE_SEARCH_ENTRIES,
...COMMIT_MESSAGE_AI_PANE_SEARCH_ENTRIES
@@ -1060,7 +1102,7 @@ function Settings(): React.JSX.Element {
<SettingsSection
id="tasks"
title="Tasks"
title="Task Sources"
description="Choose which task providers appear in the Tasks page and sidebar."
searchEntries={TASKS_PANE_SEARCH_ENTRIES}
>
@@ -1069,35 +1111,10 @@ function Settings(): React.JSX.Element {
) : null}
</SettingsSection>
<SettingsSection
id="appearance"
title="Appearance"
description="Theme and UI scaling."
searchEntries={APPEARANCE_PANE_SEARCH_ENTRIES}
>
{isSectionMounted('appearance') ? (
<AppearancePane
settings={settings}
updateSettings={updateSettings}
applyTheme={applyTheme}
fontSuggestions={fontSuggestions}
/>
) : null}
</SettingsSection>
<SettingsSection
id="input"
title="Input & Editing"
description="Selection and editing behavior."
searchEntries={INPUT_PANE_SEARCH_ENTRIES}
>
<InputPane settings={settings} updateSettings={updateSettings} />
</SettingsSection>
<SettingsSection
id="terminal"
title="Terminal"
description="Terminal appearance, previews, and defaults for new panes."
description="Shells, terminal appearance, quick commands, and pane behavior."
searchEntries={terminalPaneSearchEntries}
headerAction={
<Button
@@ -1129,35 +1146,78 @@ function Settings(): React.JSX.Element {
</SettingsSection>
{showDesktopOnlySettings ? (
<>
<SettingsSection
id="browser"
title="Browser"
description="Home page, link routing, and session cookies."
searchEntries={BROWSER_PANE_SEARCH_ENTRIES}
>
{isSectionMounted('browser') ? (
<BrowserPane
settings={settings}
updateSettings={updateSettings}
onOpenComputerUse={openComputerUseFromBrowser}
/>
) : null}
</SettingsSection>
<SettingsSection
id="notifications"
title="Notifications"
description="Native desktop notifications for agent activity and terminal events."
searchEntries={NOTIFICATIONS_PANE_SEARCH_ENTRIES}
>
{isSectionMounted('notifications') ? (
<NotificationsPane settings={settings} updateSettings={updateSettings} />
) : null}
</SettingsSection>
</>
<SettingsSection
id="browser"
title="Browser"
description="Home page, link routing, and session cookies."
searchEntries={BROWSER_PANE_SEARCH_ENTRIES}
>
{isSectionMounted('browser') ? (
<BrowserPane
settings={settings}
updateSettings={updateSettings}
onOpenComputerUse={openComputerUseFromBrowser}
/>
) : null}
</SettingsSection>
) : null}
<SettingsSection
id="appearance"
title="Appearance"
description="Theme, zoom, app font, sidebars, and status bar."
searchEntries={APPEARANCE_PANE_SEARCH_ENTRIES}
>
{isSectionMounted('appearance') ? (
<AppearancePane
settings={settings}
updateSettings={updateSettings}
applyTheme={applyTheme}
fontSuggestions={fontSuggestions}
/>
) : null}
</SettingsSection>
<SettingsSection
id="input"
title="Input & Editing"
description="Selection and editing behavior."
searchEntries={INPUT_PANE_SEARCH_ENTRIES}
>
<InputPane settings={settings} updateSettings={updateSettings} />
</SettingsSection>
{showDesktopOnlySettings ? (
<SettingsSection
id="notifications"
title="Notifications"
description="Native desktop notifications for agent activity and terminal events."
searchEntries={NOTIFICATIONS_PANE_SEARCH_ENTRIES}
>
{isSectionMounted('notifications') ? (
<NotificationsPane settings={settings} updateSettings={updateSettings} />
) : null}
</SettingsSection>
) : null}
<SettingsSection
id="shortcuts"
title="Shortcuts"
description="Keyboard shortcuts for common actions."
searchEntries={SHORTCUTS_PANE_SEARCH_ENTRIES}
>
{isSectionMounted('shortcuts') ? <ShortcutsPane /> : null}
</SettingsSection>
<SettingsSection
id="stats"
title="Stats & Usage"
description="Orca stats plus Claude, Codex, and OpenCode usage analytics."
searchEntries={STATS_PANE_SEARCH_ENTRIES}
>
{isSectionMounted('stats') ? <StatsPane /> : null}
</SettingsSection>
<SettingsSection
id="orchestration"
title="Orchestration"
@@ -1167,41 +1227,8 @@ function Settings(): React.JSX.Element {
{isSectionMounted('orchestration') ? <OrchestrationPane /> : null}
</SettingsSection>
<SettingsSection
id="servers"
title="Servers"
badge="Beta"
description={
isWebClient
? 'Connect this browser to a saved Orca server.'
: 'Run this desktop client locally or through a remote Orca server.'
}
searchEntries={[runtimeEnvironmentsSearchEntry]}
>
{isSectionMounted('servers') ? (
<RuntimeEnvironmentsPane
settings={settings}
switchRuntimeEnvironment={switchRuntimeEnvironment}
canGeneratePairingUrl={!isWebClient}
allowLocalRuntime={!isWebClient}
/>
) : null}
</SettingsSection>
{showDesktopOnlySettings ? (
<>
<SettingsSection
id="mobile"
title="Mobile"
badge="Beta"
description="Control terminals and agents from your phone."
searchEntries={MOBILE_SETTINGS_PANE_SEARCH_ENTRIES}
>
{isSectionMounted('mobile') ? (
<MobileSettingsPane settings={settings} updateSettings={updateSettings} />
) : null}
</SettingsSection>
<SettingsSection
id="computer-use"
title="Computer Use"
@@ -1249,10 +1276,56 @@ function Settings(): React.JSX.Element {
</>
) : null}
<SettingsSection
id="servers"
title="Remote Orca Servers"
badge="Beta"
description={
isWebClient
? 'Connect this browser to a saved Orca server.'
: 'Switch between local desktop mode and paired remote Orca runtimes.'
}
searchEntries={[runtimeEnvironmentsSearchEntry]}
>
{isSectionMounted('servers') ? (
<RuntimeEnvironmentsPane
settings={settings}
switchRuntimeEnvironment={switchRuntimeEnvironment}
canGeneratePairingUrl={!isWebClient}
allowLocalRuntime={!isWebClient}
/>
) : null}
</SettingsSection>
{showDesktopOnlySettings ? (
<>
<SettingsSection
id="ssh"
title="SSH Hosts"
description="Remote SSH hosts for files, terminals, and git."
searchEntries={SSH_PANE_SEARCH_ENTRIES}
>
{isSectionMounted('ssh') ? <SshPane /> : null}
</SettingsSection>
<SettingsSection
id="mobile"
title="Mobile"
badge="Beta"
description="Control terminals and agents from your phone."
searchEntries={MOBILE_SETTINGS_PANE_SEARCH_ENTRIES}
>
{isSectionMounted('mobile') ? (
<MobileSettingsPane settings={settings} updateSettings={updateSettings} />
) : null}
</SettingsSection>
</>
) : null}
{showDesktopOnlySettings && isMac ? (
<SettingsSection
id="developer-permissions"
title="Permissions"
title="macOS Permissions"
description="macOS privacy access for terminal-launched developer tools."
searchEntries={DEVELOPER_PERMISSIONS_PANE_SEARCH_ENTRIES}
>
@@ -1271,35 +1344,6 @@ function Settings(): React.JSX.Element {
{isSectionMounted('privacy') ? <PrivacyPane settings={settings} /> : null}
</SettingsSection>
<SettingsSection
id="shortcuts"
title="Shortcuts"
description="Keyboard shortcuts for common actions."
searchEntries={SHORTCUTS_PANE_SEARCH_ENTRIES}
>
{isSectionMounted('shortcuts') ? <ShortcutsPane /> : null}
</SettingsSection>
<SettingsSection
id="stats"
title="Stats"
description="How much Orca has helped you."
searchEntries={STATS_PANE_SEARCH_ENTRIES}
>
{isSectionMounted('stats') ? <StatsPane /> : null}
</SettingsSection>
{showDesktopOnlySettings ? (
<SettingsSection
id="ssh"
title="SSH"
description="Manage remote SSH connections. Connect to remote servers to browse files, run terminals, and use git."
searchEntries={SSH_PANE_SEARCH_ENTRIES}
>
{isSectionMounted('ssh') ? <SshPane /> : null}
</SettingsSection>
) : null}
<SettingsSection
id="experimental"
title="Experimental"
@@ -41,16 +41,14 @@ export function SettingsSection({
id={id}
data-settings-section={id}
className={
// Why: these sections already contain many internal borders and cards, so a lone divider
// line gets lost in the visual noise. Giving each section its own padded surface creates a
// clear outer silhouette that still works when the inner content changes.
className ??
'scroll-mt-6 space-y-8 rounded-2xl border border-border/60 bg-card/35 px-6 py-6 shadow-sm'
// Why: each pane already owns internal cards and borders. A stronger unframed section
// break keeps top-level settings pages distinct without nesting everything in cards.
className ?? 'scroll-mt-6 space-y-6 border-b-2 border-foreground/20 pb-10 last:border-b-0'
}
>
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<h2 className="flex items-center gap-2 text-xl font-semibold">
<h2 className="flex items-center gap-2 text-lg font-semibold">
{title}
{badge ? (
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
@@ -13,6 +13,12 @@ type NavSection = {
badge?: string
}
type NavGroup = {
id: string
title: string
sections: NavSection[]
}
type RepoNavSection = NavSection & {
badgeColor?: string
isRemote?: boolean
@@ -20,7 +26,7 @@ type RepoNavSection = NavSection & {
type SettingsSidebarProps = {
activeSectionId: string
generalSections: NavSection[]
generalGroups: NavGroup[]
repoSections: RepoNavSection[]
hasRepos: boolean
searchQuery: string
@@ -35,7 +41,7 @@ type SettingsSidebarProps = {
export function SettingsSidebar({
activeSectionId,
generalSections,
generalGroups,
repoSections,
hasRepos,
searchQuery,
@@ -78,39 +84,46 @@ export function SettingsSidebar({
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-sleek px-3 py-4">
<div className="space-y-5">
<div className="space-y-1">
{generalSections.map((section) => {
const Icon = section.icon
const isActive = activeSectionId === section.id
{generalGroups.map((group) => (
<div key={group.id} className="space-y-2">
<p className="px-3 text-[11px] font-medium uppercase tracking-[0.18em] text-muted-foreground">
{group.title}
</p>
<div className="space-y-1">
{group.sections.map((section) => {
const Icon = section.icon
const isActive = activeSectionId === section.id
return (
<button
key={section.id}
onClick={(event) =>
onSelectSection(section.id, {
metaKey: event.metaKey,
ctrlKey: event.ctrlKey,
shiftKey: event.shiftKey,
altKey: event.altKey
})
}
className={`flex w-full items-center rounded-lg px-3 py-2 text-left text-sm transition-colors ${
isActive
? 'bg-accent font-medium text-accent-foreground'
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
}`}
>
<Icon className="mr-2 size-4" />
{section.title}
{section.badge ? (
<span className="ml-auto rounded-full bg-muted px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-wider text-muted-foreground">
{section.badge}
</span>
) : null}
</button>
)
})}
</div>
return (
<button
key={section.id}
onClick={(event) =>
onSelectSection(section.id, {
metaKey: event.metaKey,
ctrlKey: event.ctrlKey,
shiftKey: event.shiftKey,
altKey: event.altKey
})
}
className={`flex w-full items-center rounded-lg px-3 py-2 text-left text-sm transition-colors ${
isActive
? 'bg-accent font-medium text-accent-foreground'
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
}`}
>
<Icon className="mr-2 size-4" />
<span className="truncate">{section.title}</span>
{section.badge ? (
<span className="ml-auto rounded-full bg-muted px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-wider text-muted-foreground">
{section.badge}
</span>
) : null}
</button>
)
})}
</div>
</div>
))}
<div className="space-y-2">
<p className="px-3 text-[11px] font-medium uppercase tracking-[0.18em] text-muted-foreground">
@@ -3,21 +3,21 @@ import type { SettingsSearchEntry } from './settings-search'
export const ACCOUNTS_CLAUDE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
{
title: 'Claude Accounts',
description: 'Manage which Claude account Orca uses while preserving shared chat context.',
keywords: ['claude', 'account', 'switch', 'active', 'status bar', 'quota']
description: 'Optional account switching for Claude while preserving shared chat context.',
keywords: ['claude', 'account', 'switch', 'active', 'status bar', 'quota', 'optional']
}
]
export const ACCOUNTS_CODEX_SEARCH_ENTRIES: SettingsSearchEntry[] = [
{
title: 'Codex Accounts',
description: 'Manage which Codex account Orca uses for live rate limit fetching.',
keywords: ['codex', 'account', 'rate limit', 'status bar', 'quota']
description: 'Optional account switching for Codex and live rate limit fetching.',
keywords: ['codex', 'account', 'rate limit', 'status bar', 'quota', 'optional']
},
{
title: 'Active Codex Account',
description: 'Choose which saved Codex account powers live quota reads.',
keywords: ['codex', 'account', 'switch', 'active', 'status bar']
description: 'Choose which optional saved Codex account powers live quota reads.',
keywords: ['codex', 'account', 'switch', 'active', 'status bar', 'optional']
}
]
@@ -12,13 +12,13 @@ export const GENERAL_WORKSPACE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
keywords: ['nested', 'subfolder', 'directory']
},
{
title: 'Skip Delete Worktree Confirmation',
description: 'Delete worktrees from the context menu without a confirmation dialog.',
title: 'Ask Before Deleting Worktrees',
description: 'Show a confirmation dialog before deleting a worktree.',
keywords: ['delete', 'worktree', 'confirm', 'dialog', 'skip', 'prompt']
},
{
title: 'Skip Delete Automation Confirmation',
description: 'Delete automations without a confirmation dialog.',
title: 'Ask Before Deleting Automations',
description: 'Show a confirmation dialog before deleting an automation and its run history.',
keywords: ['delete', 'automation', 'confirm', 'dialog', 'skip', 'prompt']
},
{
@@ -0,0 +1,25 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import CommentMarkdown from './CommentMarkdown'
describe('CommentMarkdown', () => {
it('contains long PR body markdown inside its available width', () => {
const markup = renderToStaticMarkup(
<CommentMarkdown
variant="document"
content={[
'`src/main/hooks.ts:289 getEffectiveHookScript with policy=shared-only returns yamlScript?.trim() only; localScript is ignored`',
'',
'```',
'const veryLongLine = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";',
'```'
].join('\n')}
/>
)
expect(markup).toContain('min-w-0')
expect(markup).toContain('max-w-full')
expect(markup).toContain('[overflow-wrap:anywhere]')
expect(markup).toContain('overflow-x-auto')
})
})
@@ -38,11 +38,13 @@ const compactComponents: Components = {
// more reliable than checking `className` — which is only set when
// the fenced block specifies a language (```js), not for bare ```.
code: ({ children }) => (
<code className="rounded bg-accent px-1 py-px text-[10px] font-mono">{children}</code>
<code className="rounded bg-accent px-1 py-px text-[10px] font-mono [overflow-wrap:anywhere]">
{children}
</code>
),
// Compact pre blocks — no syntax highlighting needed for short comments
pre: ({ children }) => (
<pre className="my-1 rounded bg-accent p-1.5 text-[10px] font-mono overflow-x-auto max-h-32">
<pre className="my-1 max-h-32 max-w-full overflow-x-auto rounded bg-accent p-1.5 text-[10px] font-mono">
{children}
</pre>
),
@@ -90,7 +92,7 @@ const compactComponents: Components = {
// overflow container keeps the card layout stable while still letting the
// user scroll to see the full table.
table: ({ children }) => (
<div className="my-1 overflow-x-auto">
<div className="my-1 max-w-full overflow-x-auto">
<table className="text-[10px] border-collapse [&_td]:border [&_td]:border-border/40 [&_td]:px-1 [&_td]:py-0.5 [&_th]:border [&_th]:border-border/40 [&_th]:px-1 [&_th]:py-0.5 [&_th]:font-semibold [&_th]:text-left">
{children}
</table>
@@ -112,10 +114,12 @@ const documentComponents: Components = {
</a>
),
code: ({ children }) => (
<code className="rounded bg-accent px-1.5 py-0.5 font-mono text-[0.92em]">{children}</code>
<code className="rounded bg-accent px-1.5 py-0.5 font-mono text-[0.92em] [overflow-wrap:anywhere]">
{children}
</code>
),
pre: ({ children }) => (
<pre className="my-3 max-h-80 overflow-x-auto rounded-md bg-accent p-3 font-mono text-[12px]">
<pre className="my-3 max-h-80 max-w-full overflow-x-auto rounded-md bg-accent p-3 font-mono text-[12px]">
{children}
</pre>
),
@@ -207,6 +211,7 @@ const CommentMarkdown = React.memo(
// The descendant selector (pre code) has higher specificity than the
// direct utility classes on <code>, so these overrides win reliably.
'[&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:rounded-none',
'min-w-0 max-w-full [overflow-wrap:anywhere]',
className
)}
{...rest}
@@ -150,6 +150,7 @@ describe('WorktreeCard linked PR display', () => {
expect(markup).not.toContain('Loading issue')
expect(markup).not.toContain('Loading PR')
expect(markup).not.toContain('Reviewer handoff note')
expect(markup.indexOf('Workspace notes')).toBeLessThan(markup.indexOf('Linked issue #123'))
})
it('does not render the standalone CI badge and colors a failing linked PR icon red', async () => {
@@ -607,12 +607,30 @@ const WorktreeCard = React.memo(function WorktreeCard({
)}
</div>
<WorktreeCardMetaBadges
issue={metaIssue}
linearIssue={metaLinearIssue}
review={metaReview}
comment={metaComment}
/>
{hasDetails ? (
<WorktreeCardDetailsHover
issue={metaIssue}
linearIssue={metaLinearIssue}
review={metaReview}
comment={metaComment}
onEditIssue={handleEditIssue}
onEditComment={handleEditComment}
>
<WorktreeCardMetaBadges
issue={metaIssue}
linearIssue={metaLinearIssue}
review={metaReview}
comment={metaComment}
/>
</WorktreeCardDetailsHover>
) : (
<WorktreeCardMetaBadges
issue={metaIssue}
linearIssue={metaLinearIssue}
review={metaReview}
comment={metaComment}
/>
)}
</div>
{remoteBranchConflict && (
@@ -676,20 +694,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
selectedWorktrees={selectedWorktrees}
onContextMenuSelect={onContextMenuSelect}
>
{hasDetails ? (
<WorktreeCardDetailsHover
issue={metaIssue}
linearIssue={metaLinearIssue}
review={metaReview}
comment={metaComment}
onEditIssue={handleEditIssue}
onEditComment={handleEditComment}
>
{cardBody}
</WorktreeCardDetailsHover>
) : (
cardBody
)}
{cardBody}
</WorktreeContextMenu>
{repo?.connectionId && (
@@ -42,6 +42,9 @@ type WorktreeCardMetaBadgesProps = {
comment: string | null
}
type WorktreeCardMetaBadgesRootProps = WorktreeCardMetaBadgesProps &
React.HTMLAttributes<HTMLDivElement>
type WorktreeCardDetailsHoverProps = WorktreeCardMetaBadgesProps & {
children: React.ReactElement
onEditIssue: (event: React.MouseEvent) => void
@@ -200,23 +203,31 @@ function MetadataActionIcon({
)
}
export function WorktreeCardMetaBadges({
issue,
linearIssue,
review,
comment
}: WorktreeCardMetaBadgesProps): React.JSX.Element | null {
export const WorktreeCardMetaBadges = React.forwardRef<
HTMLDivElement,
WorktreeCardMetaBadgesRootProps
>(function WorktreeCardMetaBadges(
{ issue, linearIssue, review, comment, className, ...props },
ref
): React.JSX.Element | null {
if (!hasWorktreeCardDetails({ issue, linearIssue, review, comment })) {
return null
}
return (
// Why: inline agent rows reserve this same right inset for their dismiss X,
// so the card metadata icons align on the same visual rail.
// Why: Radix HoverCardTrigger uses `asChild`, so this group must forward
// trigger props/ref to the actual DOM node for attachment-only hover.
<div
className="ml-auto flex shrink-0 items-center gap-1 pr-1.5"
ref={ref}
{...props}
className={cn('ml-auto flex shrink-0 items-center gap-1 pr-1.5', className)}
aria-label="Workspace metadata"
>
{hasComment(comment) && (
<MetaIconBadge label="Workspace notes">
<StickyNote className="text-muted-foreground" />
</MetaIconBadge>
)}
{issue && (
<MetaIconBadge label={`Linked issue #${issue.number}`}>
<CircleDot className="text-muted-foreground" />
@@ -232,14 +243,9 @@ export function WorktreeCardMetaBadges({
<ReviewIcon review={review} />
</MetaIconBadge>
)}
{hasComment(comment) && (
<MetaIconBadge label="Workspace notes">
<StickyNote className="text-muted-foreground" />
</MetaIconBadge>
)}
</div>
)
}
})
export function WorktreeCardDetailsHover({
issue,
@@ -300,7 +300,7 @@ export function SshStatusSegment({
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => {
openSettingsTarget({ pane: 'general', repoId: null, sectionId: 'ssh' })
openSettingsTarget({ pane: 'ssh', repoId: null, sectionId: 'ssh' })
setActiveView('settings')
}}
>
@@ -7,6 +7,7 @@ import {
buildTaskPageRepoSourceState,
findTaskPageDialogWorkItem,
findTaskPageLinearDrawerIssue,
reconcileTaskPagePagesWithWorkItemsCache,
selectTaskPageWorkItemsCacheEntries
} from './task-page-cache-selectors'
@@ -73,6 +74,26 @@ describe('task page cache selectors', () => {
expect(findTaskPageDialogWorkItem(cache, { id: 'issue-1', repoId: 'repo-2' })).toBeNull()
})
it('reconciles paged table rows with patched work-item cache entries', () => {
const stale = {
...workItem('pr-1', 'repo-1'),
reviewRequests: []
}
const patched = {
...stale,
reviewRequests: [{ login: 'AmethystLiang', name: null, avatarUrl: '' }]
}
const otherRepoSameId = workItem('pr-1', 'repo-2')
const pages = [[stale, otherRepoSameId]]
const nextPages = reconcileTaskPagePagesWithWorkItemsCache(pages, [
entry<GitHubWorkItem[]>([patched])
])
expect(nextPages[0][0]).toBe(patched)
expect(nextPages[0][1]).toBe(otherRepoSameId)
})
it('returns null while the Linear drawer is closed and finds open issues by stable reference', () => {
const issue = linearIssue('LIN-1')
const searchIssue = linearIssue('LIN-2')
@@ -51,6 +51,39 @@ export function buildTaskPageRepoSourceState(
})
}
function taskPageWorkItemCacheKey(item: GitHubWorkItem): string {
return `${item.repoId}\u0000${item.id}`
}
export function reconcileTaskPagePagesWithWorkItemsCache(
pages: readonly GitHubWorkItem[][],
entries: readonly (CacheEntry<GitHubWorkItem[]> | undefined)[]
): GitHubWorkItem[][] {
const cachedItems = new Map<string, GitHubWorkItem>()
for (const entry of entries) {
for (const item of entry?.data ?? []) {
cachedItems.set(taskPageWorkItemCacheKey(item), item)
}
}
let changed = false
const nextPages = pages.map((page) => {
let pageChanged = false
const nextPage = page.map((item) => {
const cached = cachedItems.get(taskPageWorkItemCacheKey(item))
if (!cached || cached === item) {
return item
}
pageChanged = true
changed = true
return cached
})
return pageChanged ? nextPage : page
})
return changed ? nextPages : (pages as GitHubWorkItem[][])
}
export function findTaskPageDialogWorkItem(
workItemsCache: WorkItemsCache,
dialogWorkItemKey: TaskPageDialogWorkItemKey
@@ -53,6 +53,7 @@ import {
getRemoteRuntimePtyEnvironmentId,
getRemoteRuntimeTerminalHandle
} from '@/runtime/runtime-terminal-stream'
import { closeWebRuntimeTerminal } from '@/runtime/web-runtime-session'
import { isPrimarySelectionEnabled, readPrimarySelectionText } from '@/lib/primary-selection'
import { WORKSPACE_FILE_PATH_MIME } from '@/lib/workspace-file-drag'
import { isTerminalSessionStateSaveFailure } from '../../../../shared/terminal-session-state-save-failure'
@@ -60,6 +61,7 @@ import {
isSyntheticSinglePaneTitle,
sanitizeTerminalLayoutPaneTitles
} from '@/lib/terminal-pane-title-sanitization'
import { planTerminalLiveLayoutInsertions } from './terminal-live-layout-reconciliation'
import type { TerminalQuickCommand, TerminalQuickCommandScope } from '../../../../shared/types'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id'
@@ -587,6 +589,8 @@ export default function TerminalPane({
// so the sidebar doesn't show a stale countdown for a pane that no
// longer exists. The closeTab path handles bulk cleanup, but closing
// a single split pane doesn't go through closeTab.
const ptyId = paneTransportsRef.current.get(paneId)?.getPtyId() ?? null
closeWebRuntimeTerminal(ptyId)
const leafId = manager.getLeafId(paneId)
if (leafId) {
useAppStore.getState().setCacheTimerStartedAt(makePaneKey(tabId, leafId), null)
@@ -699,6 +703,69 @@ export default function TerminalPane({
setPaneCount
})
useEffect(() => {
if (!(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__) {
return
}
const manager = managerRef.current
if (!manager || !restoredLayout.root) {
return
}
const insertions = planTerminalLiveLayoutInsertions(
restoredLayout.root,
manager.getPanes().map((pane) => pane.leafId)
)
if (insertions.length === 0) {
return
}
let appliedInsertion = false
for (const insertion of insertions) {
const ptyId = restoredLayout.ptyIdsByLeafId?.[insertion.newLeafId]
const sourcePaneId = manager.getNumericIdForLeaf(insertion.sourceLeafId)
if (!ptyId || sourcePaneId === null || manager.getNumericIdForLeaf(insertion.newLeafId)) {
continue
}
// Why: paired web terminals receive host split-pane snapshots after the
// pane manager is already mounted. Adopt the host leaf + PTY instead of
// spawning a local-only web pane.
// Before-placement swaps [source, new] after splitPane, so invert the
// host first-child ratio before applying it to the temporary order.
const splitRatio =
insertion.ratio === undefined
? undefined
: insertion.placement === 'before'
? 1 - insertion.ratio
: insertion.ratio
const createdPane = manager.splitPaneAroundLeafIds(
insertion.sourceLeafIds,
sourcePaneId,
insertion.direction,
{
...(splitRatio !== undefined && { ratio: splitRatio }),
leafId: insertion.newLeafId,
ptyId,
placement: insertion.placement
}
)
if (!createdPane) {
continue
}
appliedInsertion = true
}
if (appliedInsertion) {
persistLayoutSnapshot()
}
if (restoredLayout.activeLeafId) {
const activePaneId = manager.getNumericIdForLeaf(restoredLayout.activeLeafId)
if (activePaneId !== null) {
manager.setActivePane(activePaneId, { focus: isActive })
}
}
}, [isActive, paneCount, persistLayoutSnapshot, restoredLayout])
// Why (Activity-only pane isolation): when this TerminalPane is being
// portaled into the Activity page for a specific agent pane, hide the
// other split siblings so the user only sees that agent's pane. Uses
@@ -0,0 +1,25 @@
import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-types'
import type { GlobalSettings } from '../../../../shared/types'
import type { RuntimeTerminalProcessInspection } from '@/runtime/runtime-terminal-inspection'
export type AgentCompletionCoordinatorOptions = {
paneKey: string
getPtyId: () => string | null
getSettings: () => Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
inspectProcess: (
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined,
ptyId: string
) => Promise<RuntimeTerminalProcessInspection>
dispatchCompletion: (title: string) => void
isLive: () => boolean
}
export type AgentCompletionCoordinator = {
observeTitle: (title: string) => void
observeClassifiedTitleCompletion: (title: string) => void
observeTitleWorking: () => void
observeHookStatus: (payload: ParsedAgentStatusPayload) => void
startProcessTracking: () => void
resetCompletionState: (options?: { requireFreshWorking?: boolean }) => void
dispose: () => void
}
@@ -0,0 +1,410 @@
/* oxlint-disable max-lines */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createAgentCompletionCoordinator } from './agent-completion-coordinator'
import { resetAgentProcessInspectionQueueForTests } from './agent-process-inspection-queue'
import type { RuntimeTerminalProcessInspection } from '@/runtime/runtime-terminal-inspection'
async function flushAsyncTicks(count = 4): Promise<void> {
for (let i = 0; i < count; i++) {
await Promise.resolve()
}
}
function processResult(foregroundProcess: string | null): RuntimeTerminalProcessInspection {
return { foregroundProcess, hasChildProcesses: foregroundProcess !== null }
}
function createDeferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolveDeferred!: (value: T) => void
const promise = new Promise<T>((resolve) => {
resolveDeferred = resolve
})
return { promise, resolve: resolveDeferred }
}
describe('agent completion coordinator', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.spyOn(Math, 'random').mockReturnValue(0.5)
})
afterEach(() => {
resetAgentProcessInspectionQueueForTests()
vi.useRealTimers()
vi.restoreAllMocks()
})
it('clears process evidence after agent exit so later non-agent spinner titles do not notify', async () => {
let foregroundProcess: string | null = 'codex'
const dispatchCompletion = vi.fn()
const coordinator = createAgentCompletionCoordinator({
paneKey: 'tab-1:leaf-1',
getPtyId: () => 'pty-1',
getSettings: () => null,
inspectProcess: vi.fn(async () => processResult(foregroundProcess)),
dispatchCompletion,
isLive: () => true
})
coordinator.startProcessTracking()
vi.advanceTimersByTime(2_000)
await flushAsyncTicks()
coordinator.observeTitle('⠋ codex')
coordinator.observeTitle('codex done')
expect(dispatchCompletion).toHaveBeenCalledTimes(1)
foregroundProcess = 'zsh'
vi.advanceTimersByTime(750)
await flushAsyncTicks()
expect(dispatchCompletion).toHaveBeenCalledTimes(1)
dispatchCompletion.mockClear()
coordinator.observeTitle('⠋ experimental-agent-observability')
coordinator.observeTitle('experimental-agent-observability')
await flushAsyncTicks()
expect(dispatchCompletion).not.toHaveBeenCalled()
})
it('suppresses process-exit backstop after a title completion already notified the turn', async () => {
let foregroundProcess: string | null = 'codex'
const dispatchCompletion = vi.fn()
const coordinator = createAgentCompletionCoordinator({
paneKey: 'tab-1:leaf-1',
getPtyId: () => 'pty-1',
getSettings: () => null,
inspectProcess: vi.fn(async () => processResult(foregroundProcess)),
dispatchCompletion,
isLive: () => true
})
coordinator.startProcessTracking()
vi.advanceTimersByTime(2_000)
await flushAsyncTicks()
coordinator.observeTitle('⠋ codex')
coordinator.observeTitle('codex done')
foregroundProcess = null
vi.advanceTimersByTime(750)
await flushAsyncTicks()
expect(dispatchCompletion).toHaveBeenCalledTimes(1)
expect(dispatchCompletion).toHaveBeenCalledWith('codex done')
})
it('suppresses same-turn title completion after a hook completion already notified', () => {
const dispatchCompletion = vi.fn()
const coordinator = createAgentCompletionCoordinator({
paneKey: 'tab-1:leaf-1',
getPtyId: () => 'pty-1',
getSettings: () => null,
inspectProcess: vi.fn(),
dispatchCompletion,
isLive: () => true
})
coordinator.observeHookStatus({
state: 'working',
prompt: '',
agentType: 'codex'
})
coordinator.observeHookStatus({
state: 'done',
prompt: '',
agentType: 'codex'
})
coordinator.observeClassifiedTitleCompletion('codex done')
expect(dispatchCompletion).toHaveBeenCalledTimes(1)
expect(dispatchCompletion).toHaveBeenCalledWith('codex')
})
it('ignores stale working title state after a hook completion already notified', () => {
const dispatchCompletion = vi.fn()
const coordinator = createAgentCompletionCoordinator({
paneKey: 'tab-1:leaf-1',
getPtyId: () => 'pty-1',
getSettings: () => null,
inspectProcess: vi.fn(),
dispatchCompletion,
isLive: () => true
})
coordinator.observeHookStatus({
state: 'done',
prompt: '',
agentType: 'codex'
})
coordinator.observeTitle('⠋ codex')
coordinator.observeTitle('codex done')
expect(dispatchCompletion).toHaveBeenCalledTimes(1)
expect(dispatchCompletion).toHaveBeenCalledWith('codex')
})
it('suppresses delayed title completion after process inspection changes sessions', async () => {
const dispatchCompletion = vi.fn()
const coordinator = createAgentCompletionCoordinator({
paneKey: 'tab-1:leaf-1',
getPtyId: () => 'pty-1',
getSettings: () => null,
inspectProcess: vi.fn(async () => processResult('codex')),
dispatchCompletion,
isLive: () => true
})
coordinator.observeHookStatus({
state: 'done',
prompt: '',
agentType: 'codex'
})
coordinator.startProcessTracking()
vi.advanceTimersByTime(2_000)
await flushAsyncTicks()
coordinator.observeClassifiedTitleCompletion('codex done')
expect(dispatchCompletion).toHaveBeenCalledTimes(1)
expect(dispatchCompletion).toHaveBeenCalledWith('codex')
})
it('suppresses late process-exit backstop after process inspection follows hook completion', async () => {
let foregroundProcess: string | null = 'codex'
const dispatchCompletion = vi.fn()
const coordinator = createAgentCompletionCoordinator({
paneKey: 'tab-1:leaf-1',
getPtyId: () => 'pty-1',
getSettings: () => null,
inspectProcess: vi.fn(async () => processResult(foregroundProcess)),
dispatchCompletion,
isLive: () => true
})
coordinator.observeHookStatus({
state: 'done',
prompt: '',
agentType: 'codex'
})
coordinator.startProcessTracking()
vi.advanceTimersByTime(2_000)
await flushAsyncTicks()
foregroundProcess = null
vi.advanceTimersByTime(750)
await flushAsyncTicks()
expect(dispatchCompletion).toHaveBeenCalledTimes(1)
expect(dispatchCompletion).toHaveBeenCalledWith('codex')
})
it('keeps duplicate done-only hooks inside replay guard suppressed after process inspection', async () => {
const inspection = createDeferred<RuntimeTerminalProcessInspection>()
const dispatchCompletion = vi.fn()
const coordinator = createAgentCompletionCoordinator({
paneKey: 'tab-1:leaf-1',
getPtyId: () => 'pty-1',
getSettings: () => null,
inspectProcess: vi.fn(() => inspection.promise),
dispatchCompletion,
isLive: () => true
})
coordinator.startProcessTracking()
vi.advanceTimersByTime(2_000)
await flushAsyncTicks()
coordinator.observeHookStatus({
state: 'done',
prompt: '',
agentType: 'codex'
})
inspection.resolve(processResult('codex'))
await flushAsyncTicks()
coordinator.observeHookStatus({
state: 'done',
prompt: '',
agentType: 'codex'
})
expect(dispatchCompletion).toHaveBeenCalledTimes(1)
})
it('can require a fresh working signal after completion state reset', () => {
const dispatchCompletion = vi.fn()
const coordinator = createAgentCompletionCoordinator({
paneKey: 'tab-1:leaf-1',
getPtyId: () => 'pty-1',
getSettings: () => null,
inspectProcess: vi.fn(),
dispatchCompletion,
isLive: () => true
})
coordinator.observeHookStatus({
state: 'done',
prompt: '',
agentType: 'codex'
})
coordinator.resetCompletionState({ requireFreshWorking: true })
coordinator.observeClassifiedTitleCompletion('codex done')
coordinator.observeHookStatus({
state: 'done',
prompt: '',
agentType: 'codex'
})
expect(dispatchCompletion).toHaveBeenCalledTimes(1)
coordinator.observeHookStatus({
state: 'working',
prompt: '',
agentType: 'codex'
})
coordinator.observeHookStatus({
state: 'done',
prompt: '',
agentType: 'codex'
})
expect(dispatchCompletion).toHaveBeenCalledTimes(2)
})
it('ignores process inspections that resolve after completion state reset', async () => {
const inspection = createDeferred<RuntimeTerminalProcessInspection>()
const dispatchCompletion = vi.fn()
const coordinator = createAgentCompletionCoordinator({
paneKey: 'tab-1:leaf-1',
getPtyId: () => 'pty-1',
getSettings: () => null,
inspectProcess: vi.fn(() => inspection.promise),
dispatchCompletion,
isLive: () => true
})
coordinator.startProcessTracking()
vi.advanceTimersByTime(2_000)
coordinator.resetCompletionState({ requireFreshWorking: true })
inspection.resolve(processResult('codex'))
await flushAsyncTicks()
coordinator.observeTitle('⠋ experimental-agent-observability')
coordinator.observeTitle('experimental-agent-observability')
expect(dispatchCompletion).not.toHaveBeenCalled()
})
it('starts a fresh pending-title inspection after stale inspection resolves', async () => {
const firstInspection = createDeferred<RuntimeTerminalProcessInspection>()
const secondInspection = createDeferred<RuntimeTerminalProcessInspection>()
const inspectProcess = vi
.fn()
.mockReturnValueOnce(firstInspection.promise)
.mockReturnValueOnce(secondInspection.promise)
const dispatchCompletion = vi.fn()
const coordinator = createAgentCompletionCoordinator({
paneKey: 'tab-1:leaf-1',
getPtyId: () => 'pty-1',
getSettings: () => null,
inspectProcess,
dispatchCompletion,
isLive: () => true
})
coordinator.startProcessTracking()
vi.advanceTimersByTime(2_000)
await flushAsyncTicks()
coordinator.resetCompletionState({ requireFreshWorking: true })
coordinator.observeTitle('⠋ experimental-agent-observability')
coordinator.observeTitle('experimental-agent-observability')
firstInspection.resolve(processResult('codex'))
await flushAsyncTicks()
vi.advanceTimersByTime(2_000)
await flushAsyncTicks()
secondInspection.resolve(processResult('codex'))
await flushAsyncTicks()
expect(inspectProcess).toHaveBeenCalledTimes(2)
expect(dispatchCompletion).toHaveBeenCalledWith('experimental-agent-observability')
})
it('allows later done-only hook completions from the same long-lived process', () => {
const dispatchCompletion = vi.fn()
const coordinator = createAgentCompletionCoordinator({
paneKey: 'tab-1:leaf-1',
getPtyId: () => 'pty-1',
getSettings: () => null,
inspectProcess: vi.fn(),
dispatchCompletion,
isLive: () => true
})
coordinator.observeHookStatus({
state: 'done',
prompt: 'first task',
agentType: 'codex'
})
coordinator.observeHookStatus({
state: 'done',
prompt: 'first task',
agentType: 'codex'
})
expect(dispatchCompletion).toHaveBeenCalledTimes(1)
vi.advanceTimersByTime(1_000)
coordinator.observeHookStatus({
state: 'done',
prompt: 'second task',
agentType: 'codex'
})
expect(dispatchCompletion).toHaveBeenCalledTimes(2)
})
it.each([
'claude',
'codex',
'gemini',
'opencode',
'cursor',
'pi',
'droid',
'grok',
'copilot',
'hermes'
])('recognizes %s hook agent ids even when the binary name differs', (agentType) => {
const dispatchCompletion = vi.fn()
const coordinator = createAgentCompletionCoordinator({
paneKey: 'tab-1:leaf-1',
getPtyId: () => 'pty-1',
getSettings: () => null,
inspectProcess: vi.fn(),
dispatchCompletion,
isLive: () => true
})
coordinator.observeHookStatus({
state: 'done',
prompt: '',
agentType
})
expect(dispatchCompletion).toHaveBeenCalledWith(agentType)
})
it('keeps a generic title completion pending long enough for the first remote inspection', async () => {
const inspection = createDeferred<RuntimeTerminalProcessInspection>()
const dispatchCompletion = vi.fn()
const coordinator = createAgentCompletionCoordinator({
paneKey: 'tab-1:leaf-1',
getPtyId: () => 'remote:terminal-1',
getSettings: () => ({ activeRuntimeEnvironmentId: 'env-1' }),
inspectProcess: vi.fn(() => inspection.promise),
dispatchCompletion,
isLive: () => true
})
coordinator.observeTitle('⠋ experimental-agent-observability')
coordinator.observeTitle('experimental-agent-observability')
vi.advanceTimersByTime(10_500)
inspection.resolve(processResult('codex'))
await flushAsyncTicks()
expect(dispatchCompletion).toHaveBeenCalledWith('experimental-agent-observability')
})
})
@@ -0,0 +1,388 @@
/* oxlint-disable max-lines */
import { detectAgentStatusFromTitle, type AgentStatus } from '../../../../shared/agent-detection'
import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-types'
import {
isRecognizedAgentType,
recognizeAgentProcess,
type RecognizedAgentProcess
} from '../../../../shared/agent-process-recognition'
import {
enqueueAgentProcessInspection,
type InspectionPriority
} from './agent-process-inspection-queue'
import type {
AgentCompletionCoordinator,
AgentCompletionCoordinatorOptions
} from './agent-completion-coordinator-types'
import type { RuntimeTerminalProcessInspection } from '@/runtime/runtime-terminal-inspection'
import {
titleHasExplicitAgentIdentity,
titleIsInconclusiveNativeDroidTitle
} from './title-agent-identity'
type CompletionSource = 'hook' | 'title' | 'process-exit'
const IDLE_POLL_INTERVAL_MS = 2_000
const ACTIVE_POLL_INTERVAL_MS = 750
const INSPECTION_TIMEOUT_MS = 15_000
const PENDING_TITLE_TTL_MS = Math.max(2_000, INSPECTION_TIMEOUT_MS + 500)
const PENDING_TITLE_MAX_TTL_MS = Math.max(30_000, PENDING_TITLE_TTL_MS)
const COMPLETION_REPLAY_GUARD_MS = 1_000
function isCompletionHookState(state: ParsedAgentStatusPayload['state']): boolean {
return state === 'done' || state === 'waiting' || state === 'blocked'
}
export function createAgentCompletionCoordinator(
options: AgentCompletionCoordinatorOptions
): AgentCompletionCoordinator {
let disposed = false
let agentIdentityEstablished = false
let hasAgentRunEvidence = false
let workingStatusObserved = false
let lastTitleStatus: AgentStatus | null = null
let currentTurn = 0
let processSession = 0
let lastCompletionToken: string | null = null
let lastCompletionAt = 0
let lastCompletedTurn: number | null = null
let lastCompletionSource: CompletionSource | null = null
let lastForegroundAgent: RecognizedAgentProcess | null = null
let requiresFreshWorking = false
let pollTimer: ReturnType<typeof setTimeout> | null = null
let pendingTitleTimer: ReturnType<typeof setTimeout> | null = null
let pendingTitle: {
title: string
expiresAt: number
maxExpiresAt: number
firstInspectionFinished: boolean
} | null = null
let inspectionInFlight = false
let inspectionGeneration = 0
let consecutiveInspectionErrors = 0
function clearPollTimer(): void {
if (pollTimer === null) {
return
}
clearTimeout(pollTimer)
pollTimer = null
}
function clearPendingTitleTimer(): void {
if (pendingTitleTimer === null) {
return
}
clearTimeout(pendingTitleTimer)
pendingTitleTimer = null
}
function establishAgentEvidence(): void {
agentIdentityEstablished = true
hasAgentRunEvidence = true
dispatchPendingTitleIfEligible()
}
function clearAgentRunEvidence(): void {
agentIdentityEstablished = false
hasAgentRunEvidence = false
workingStatusObserved = false
dropPendingTitle()
}
function completionToken(source: CompletionSource): string {
if (workingStatusObserved) {
return `turn:${currentTurn}`
}
if (lastForegroundAgent) {
return `process:${processSession}`
}
return `${source}:${currentTurn}:${processSession}`
}
function dispatchCompletion(source: CompletionSource, title: string): void {
if (requiresFreshWorking || lastCompletedTurn === currentTurn) {
return
}
if (!options.isLive() || !hasAgentRunEvidence) {
return
}
const now = Date.now()
const token = completionToken(source)
if (token === lastCompletionToken && now - lastCompletionAt < COMPLETION_REPLAY_GUARD_MS) {
return
}
lastCompletionToken = token
lastCompletionAt = now
lastCompletedTurn = currentTurn
lastCompletionSource = source
workingStatusObserved = false
options.dispatchCompletion(title)
}
function dropPendingTitle(): void {
clearPendingTitleTimer()
pendingTitle = null
}
function dispatchPendingTitleIfEligible(): void {
if (!pendingTitle || !agentIdentityEstablished || !hasAgentRunEvidence) {
return
}
const title = pendingTitle.title
dropPendingTitle()
dispatchCompletion('title', title)
}
function schedulePendingTitleExpiry(): void {
clearPendingTitleTimer()
const pending = pendingTitle
if (!pending) {
return
}
const remaining = pending.expiresAt - Date.now()
if (remaining <= 0) {
pendingTitle = null
scheduleNextPoll()
return
}
pendingTitleTimer = setTimeout(() => {
pendingTitleTimer = null
if (!pendingTitle) {
return
}
if (!pendingTitle.firstInspectionFinished && Date.now() < pendingTitle.maxExpiresAt) {
pendingTitle.expiresAt = Math.min(Date.now() + 500, pendingTitle.maxExpiresAt)
schedulePendingTitleExpiry()
return
}
pendingTitle = null
scheduleNextPoll()
}, remaining)
}
function holdTitleCompletionPending(title: string): void {
const now = Date.now()
// Why: generic spinner titles can be just "⠋ cwd"; hold the completion
// only long enough for one foreground-process probe to prove an agent owns it.
pendingTitle = {
title,
expiresAt: Math.min(now + PENDING_TITLE_TTL_MS, now + PENDING_TITLE_MAX_TTL_MS),
maxExpiresAt: now + PENDING_TITLE_MAX_TTL_MS,
firstInspectionFinished: false
}
schedulePendingTitleExpiry()
requestInspection('pending-title')
}
function handleRecognizedProcess(process: RecognizedAgentProcess): void {
if (lastForegroundAgent?.agent !== process.agent) {
if (lastForegroundAgent && hasAgentRunEvidence) {
dispatchCompletion('process-exit', lastForegroundAgent.processName)
}
processSession += 1
}
lastForegroundAgent = process
establishAgentEvidence()
}
function handleProcessInspectionResult(result: RuntimeTerminalProcessInspection): void {
consecutiveInspectionErrors = 0
const recognized = recognizeAgentProcess(result.foregroundProcess)
if (recognized) {
handleRecognizedProcess(recognized)
} else if (lastForegroundAgent && hasAgentRunEvidence) {
const exited = lastForegroundAgent
dispatchCompletion('process-exit', exited.processName)
lastForegroundAgent = null
clearAgentRunEvidence()
} else {
lastForegroundAgent = null
clearAgentRunEvidence()
}
}
function requestInspection(priority: InspectionPriority): void {
if (disposed || inspectionInFlight || !options.isLive()) {
return
}
const ptyId = options.getPtyId()
if (!ptyId) {
return
}
inspectionInFlight = true
const generationAtRequest = inspectionGeneration
enqueueAgentProcessInspection({
priority,
run: async () => {
try {
const result = await options.inspectProcess(options.getSettings(), ptyId)
if (!disposed && generationAtRequest === inspectionGeneration) {
handleProcessInspectionResult(result)
}
} catch {
consecutiveInspectionErrors += 1
} finally {
inspectionInFlight = false
if (generationAtRequest !== inspectionGeneration) {
if (pendingTitle) {
requestInspection('pending-title')
} else {
scheduleNextPoll()
}
} else {
if (pendingTitle) {
pendingTitle.firstInspectionFinished = true
dispatchPendingTitleIfEligible()
schedulePendingTitleExpiry()
}
scheduleNextPoll()
}
}
}
})
}
function nextPollInterval(): number {
const base = lastForegroundAgent ? ACTIVE_POLL_INTERVAL_MS : IDLE_POLL_INTERVAL_MS
const backoff =
consecutiveInspectionErrors > 0
? Math.min(10_000, base * 2 ** consecutiveInspectionErrors)
: base
const jitter = 1 + (Math.random() * 0.2 - 0.1)
return Math.round(backoff * jitter)
}
function scheduleNextPoll(): void {
if (disposed || !options.isLive() || pollTimer !== null || pendingTitle) {
return
}
const ptyId = options.getPtyId()
if (!ptyId) {
return
}
pollTimer = setTimeout(() => {
pollTimer = null
requestInspection('cadence')
}, nextPollInterval())
}
function recordTitleWorking(): boolean {
if (
lastCompletionSource === 'hook' &&
Date.now() - lastCompletionAt < COMPLETION_REPLAY_GUARD_MS
) {
return false
}
workingStatusObserved = true
requiresFreshWorking = false
currentTurn += 1
dropPendingTitle()
return true
}
function observeTitleWorking(): void {
recordTitleWorking()
}
function observeTitle(title: string): void {
const status = detectAgentStatusFromTitle(title)
const isInconclusiveNativeDroidTitle = titleIsInconclusiveNativeDroidTitle(title)
if (titleHasExplicitAgentIdentity(title) && !isInconclusiveNativeDroidTitle) {
establishAgentEvidence()
}
if (status === 'working') {
if (!recordTitleWorking()) {
return
}
} else if (lastTitleStatus === 'working') {
if (isInconclusiveNativeDroidTitle) {
lastTitleStatus = status
return
}
if (agentIdentityEstablished && hasAgentRunEvidence) {
dispatchCompletion('title', title)
} else {
holdTitleCompletionPending(title)
}
}
lastTitleStatus = status
}
function observeClassifiedTitleCompletion(title: string): void {
if (titleHasExplicitAgentIdentity(title)) {
establishAgentEvidence()
}
if (agentIdentityEstablished && hasAgentRunEvidence) {
dispatchCompletion('title', title)
} else {
holdTitleCompletionPending(title)
}
}
function observeHookStatus(payload: ParsedAgentStatusPayload): void {
if (isRecognizedAgentType(payload.agentType)) {
establishAgentEvidence()
}
if (payload.state === 'working') {
workingStatusObserved = true
requiresFreshWorking = false
currentTurn += 1
dropPendingTitle()
return
}
if (isCompletionHookState(payload.state)) {
if (isRecognizedAgentType(payload.agentType)) {
establishAgentEvidence()
}
if (
!workingStatusObserved &&
lastCompletionSource === 'hook' &&
lastCompletedTurn === currentTurn &&
Date.now() - lastCompletionAt >= COMPLETION_REPLAY_GUARD_MS
) {
// Why: some hook producers only emit terminal states. Treat later
// done-only hook completions as new turns without letting title/process
// backstops duplicate the same completion.
currentTurn += 1
}
dispatchCompletion('hook', payload.agentType ?? options.paneKey)
}
}
function startProcessTracking(): void {
scheduleNextPoll()
}
function resetCompletionState(options: { requireFreshWorking?: boolean } = {}): void {
dropPendingTitle()
agentIdentityEstablished = false
hasAgentRunEvidence = false
workingStatusObserved = false
lastTitleStatus = null
lastCompletionToken = null
lastCompletionAt = 0
lastCompletedTurn = null
lastCompletionSource = null
lastForegroundAgent = null
requiresFreshWorking = options.requireFreshWorking ?? false
inspectionGeneration += 1
}
function dispose(): void {
disposed = true
clearPollTimer()
dropPendingTitle()
}
return {
observeTitle,
observeClassifiedTitleCompletion,
observeTitleWorking,
observeHookStatus,
startProcessTracking,
resetCompletionState,
dispose
}
}
@@ -0,0 +1,80 @@
export type InspectionPriority = 'cadence' | 'pending-title'
type InspectionTask = {
priority: InspectionPriority
run: () => Promise<void>
}
const MAX_CONCURRENT_INSPECTIONS = 4
const MAX_INSPECTION_STARTS_PER_SECOND = 8
let activeInspections = 0
let inspectionPumpTimer: ReturnType<typeof setTimeout> | null = null
const inspectionStarts: number[] = []
const inspectionQueue: InspectionTask[] = []
function canStartInspection(now: number): boolean {
if (inspectionStarts.length > 0 && now < inspectionStarts[0]!) {
inspectionStarts.length = 0
}
while (inspectionStarts.length > 0 && now - inspectionStarts[0]! >= 1_000) {
inspectionStarts.shift()
}
return (
activeInspections < MAX_CONCURRENT_INSPECTIONS &&
inspectionStarts.length < MAX_INSPECTION_STARTS_PER_SECOND
)
}
function scheduleInspectionPump(delayMs = 0): void {
if (inspectionPumpTimer !== null) {
return
}
inspectionPumpTimer = setTimeout(() => {
inspectionPumpTimer = null
pumpInspectionQueue()
}, delayMs)
}
function pumpInspectionQueue(): void {
const now = Date.now()
if (!canStartInspection(now)) {
scheduleInspectionPump(100)
return
}
const priorityIndex = inspectionQueue.findIndex((task) => task.priority === 'pending-title')
const next =
priorityIndex >= 0 ? inspectionQueue.splice(priorityIndex, 1)[0] : inspectionQueue.shift()
if (!next) {
return
}
activeInspections += 1
inspectionStarts.push(now)
void next.run().finally(() => {
activeInspections = Math.max(0, activeInspections - 1)
if (inspectionQueue.length > 0) {
scheduleInspectionPump()
}
})
if (inspectionQueue.length > 0) {
scheduleInspectionPump()
}
}
export function enqueueAgentProcessInspection(task: InspectionTask): void {
inspectionQueue.push(task)
pumpInspectionQueue()
}
export function resetAgentProcessInspectionQueueForTests(): void {
if (inspectionPumpTimer !== null) {
clearTimeout(inspectionPumpTimer)
inspectionPumpTimer = null
}
activeInspections = 0
inspectionStarts.length = 0
inspectionQueue.length = 0
}
@@ -64,6 +64,7 @@ type StoreState = {
consumePendingSnapshot: ReturnType<typeof vi.fn>
runtimePaneTitlesByTabId: Record<string, Record<number, string>>
agentStatusByPaneKey: Record<string, unknown>
setAgentStatus: ReturnType<typeof vi.fn>
removeAgentStatus: ReturnType<typeof vi.fn>
dropAgentStatus: ReturnType<typeof vi.fn>
}
@@ -369,6 +370,15 @@ describe('connectPanePty', () => {
consumePendingSnapshot: vi.fn(() => null),
runtimePaneTitlesByTabId: {},
agentStatusByPaneKey: {},
setAgentStatus: vi.fn((paneKey: string, payload: Record<string, unknown>) => {
mockStoreState.agentStatusByPaneKey[paneKey] = {
...payload,
paneKey,
updatedAt: Date.now(),
stateStartedAt: Date.now(),
stateHistory: []
}
}),
removeAgentStatus: vi.fn(),
dropAgentStatus: vi.fn()
} as StoreState
@@ -380,6 +390,8 @@ describe('connectPanePty', () => {
},
pty: {
signal: vi.fn(),
getForegroundProcess: vi.fn().mockResolvedValue(null),
hasChildProcesses: vi.fn().mockResolvedValue(false),
ackColdRestore: vi.fn(),
onClearBufferRequest: vi.fn(() => vi.fn()),
onSerializeBufferRequest: vi.fn(() => vi.fn()),
@@ -440,6 +452,26 @@ describe('connectPanePty', () => {
logSpy.mockRestore()
})
it('keeps the surviving split pane mounted when an intentional pane-close PTY exit arrives', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-pane-2')
transportFactoryQueue.push(transport)
const manager = createManager(1)
const deps = createDeps({
consumeSuppressedPtyExit: vi.fn(() => true)
})
connectPanePty(createPane(2) as never, manager as never, deps as never)
const onPtyExit = createdTransportOptions[0]?.onPtyExit as ((ptyId: string) => void) | undefined
expect(onPtyExit).toBeTypeOf('function')
onPtyExit?.('pty-pane-2')
expect(deps.consumeSuppressedPtyExit).toHaveBeenCalledWith('pty-pane-2')
expect(deps.onPtyExitRef.current).not.toHaveBeenCalled()
expect(manager.closePane).not.toHaveBeenCalled()
})
it('does not send startup command via sendInput for local connections', async () => {
// Why: the local PTY provider already writes the command via
// writeStartupCommandWhenShellReady — sending it again from the renderer
@@ -2332,6 +2364,480 @@ describe('connectPanePty', () => {
)
})
it('does not dispatch generic title completions when agent-complete notifications are disabled', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-codex')
transportFactoryQueue.push(transport)
vi.useFakeTimers()
mockStoreState.settings = {
...mockStoreState.settings,
notifications: {
enabled: true,
agentTaskComplete: false,
terminalBell: true,
suppressWhenFocused: false,
customSoundPath: null
}
}
const api = (
globalThis as unknown as {
window: { api: { pty: { getForegroundProcess: ReturnType<typeof vi.fn> } } }
}
).window.api
api.pty.getForegroundProcess.mockResolvedValue('codex')
const pane = createPane(1)
const manager = createManager(1)
const deps = createDeps()
connectPanePty(pane as never, manager as never, deps as never)
const titleHandler = createdTransportOptions[0]?.onTitleChange as
| ((title: string, rawTitle: string) => void)
| undefined
if (!titleHandler) {
throw new Error('Expected onTitleChange to be registered')
}
titleHandler('⠋ experimental-agent-observability', '⠋ experimental-agent-observability')
titleHandler('experimental-agent-observability', 'experimental-agent-observability')
await flushAsyncTicks()
vi.advanceTimersByTime(1_000)
expect(deps.dispatchNotification).not.toHaveBeenCalledWith(
expect.objectContaining({ source: 'agent-task-complete' })
)
})
it('does not replay disabled generic title completions after notifications are re-enabled', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-codex')
transportFactoryQueue.push(transport)
vi.useFakeTimers()
mockStoreState.settings = {
...mockStoreState.settings,
notifications: {
enabled: true,
agentTaskComplete: false,
terminalBell: true,
suppressWhenFocused: false,
customSoundPath: null
}
}
const inspection = createDeferred<string | null>()
const api = (
globalThis as unknown as {
window: { api: { pty: { getForegroundProcess: ReturnType<typeof vi.fn> } } }
}
).window.api
api.pty.getForegroundProcess.mockReturnValue(inspection.promise)
const pane = createPane(1)
const manager = createManager(1)
const deps = createDeps()
connectPanePty(pane as never, manager as never, deps as never)
const titleHandler = createdTransportOptions[0]?.onTitleChange as
| ((title: string, rawTitle: string) => void)
| undefined
if (!titleHandler) {
throw new Error('Expected onTitleChange to be registered')
}
titleHandler('⠋ experimental-agent-observability', '⠋ experimental-agent-observability')
titleHandler('experimental-agent-observability', 'experimental-agent-observability')
mockStoreState.settings = {
...mockStoreState.settings,
notifications: {
...mockStoreState.settings.notifications,
agentTaskComplete: true
}
}
inspection.resolve('codex')
await flushAsyncTicks()
vi.advanceTimersByTime(1_000)
expect(deps.dispatchNotification).not.toHaveBeenCalledWith(
expect.objectContaining({ source: 'agent-task-complete' })
)
})
it('clears title completion state when notifications are disabled', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-codex')
transportFactoryQueue.push(transport)
vi.useFakeTimers()
mockStoreState.settings = {
...mockStoreState.settings,
notifications: {
enabled: true,
agentTaskComplete: true,
terminalBell: true,
suppressWhenFocused: false,
customSoundPath: null
}
}
const pane = createPane(1)
const manager = createManager(1)
const deps = createDeps()
connectPanePty(pane as never, manager as never, deps as never)
const titleHandler = createdTransportOptions[0]?.onTitleChange as
| ((title: string, rawTitle: string) => void)
| undefined
if (!titleHandler) {
throw new Error('Expected onTitleChange to be registered')
}
titleHandler('Claude working', 'Claude working')
mockStoreState.settings = {
...mockStoreState.settings,
notifications: {
...mockStoreState.settings.notifications,
agentTaskComplete: false
}
}
notifyStoreSubscribers()
titleHandler('Claude done', 'Claude done')
mockStoreState.settings = {
...mockStoreState.settings,
notifications: {
...mockStoreState.settings.notifications,
agentTaskComplete: true
}
}
notifyStoreSubscribers()
titleHandler('Claude done', 'Claude done')
vi.advanceTimersByTime(1_000)
expect(deps.dispatchNotification).not.toHaveBeenCalledWith(
expect.objectContaining({ source: 'agent-task-complete' })
)
})
it('cancels scheduled agent completion when notifications are disabled before dispatch', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-codex')
transportFactoryQueue.push(transport)
vi.useFakeTimers()
mockStoreState.settings = {
...mockStoreState.settings,
notifications: {
enabled: true,
agentTaskComplete: true,
terminalBell: true,
suppressWhenFocused: false,
customSoundPath: null
}
}
const pane = createPane(1)
const manager = createManager(1)
const deps = createDeps()
connectPanePty(pane as never, manager as never, deps as never)
const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as
| ((title: string) => void)
| undefined
if (!idleHandler) {
throw new Error('Expected onAgentBecameIdle to be registered')
}
idleHandler('* Codex done')
mockStoreState.settings = {
...mockStoreState.settings,
notifications: {
...mockStoreState.settings.notifications,
agentTaskComplete: false
}
}
notifyStoreSubscribers()
mockStoreState.settings = {
...mockStoreState.settings,
notifications: {
...mockStoreState.settings.notifications,
agentTaskComplete: true
}
}
notifyStoreSubscribers()
vi.advanceTimersByTime(1_000)
expect(deps.dispatchNotification).not.toHaveBeenCalledWith(
expect.objectContaining({ source: 'agent-task-complete' })
)
})
it('restores a suppressed terminal bell when disabling pending agent completion', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-codex')
transportFactoryQueue.push(transport)
vi.useFakeTimers()
mockStoreState.settings = {
...mockStoreState.settings,
notifications: {
enabled: true,
agentTaskComplete: true,
terminalBell: true,
suppressWhenFocused: false,
customSoundPath: null
}
}
const pane = createPane(1)
const manager = createManager(1)
const deps = createDeps()
connectPanePty(pane as never, manager as never, deps as never)
const bellHandler = createdTransportOptions[0]?.onBell as (() => void) | undefined
const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as
| ((title: string) => void)
| undefined
if (!bellHandler || !idleHandler) {
throw new Error('Expected bell and idle handlers to be registered')
}
bellHandler()
idleHandler('* Codex done')
vi.advanceTimersByTime(250)
expect(deps.dispatchNotification).not.toHaveBeenCalledWith({ source: 'terminal-bell' })
mockStoreState.settings = {
...mockStoreState.settings,
notifications: {
...mockStoreState.settings.notifications,
agentTaskComplete: false
}
}
notifyStoreSubscribers()
vi.advanceTimersByTime(250)
expect(deps.dispatchNotification).toHaveBeenCalledWith({ source: 'terminal-bell' })
})
it('requires fresh working evidence after notifications are disabled', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-codex')
transportFactoryQueue.push(transport)
vi.useFakeTimers()
mockStoreState.settings = {
...mockStoreState.settings,
notifications: {
enabled: true,
agentTaskComplete: true,
terminalBell: true,
suppressWhenFocused: false,
customSoundPath: null
}
}
const pane = createPane(1)
const manager = createManager(1)
const deps = createDeps()
connectPanePty(pane as never, manager as never, deps as never)
const workingHandler = createdTransportOptions[0]?.onAgentBecameWorking as
| (() => void)
| undefined
const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as
| ((title: string) => void)
| undefined
if (!workingHandler || !idleHandler) {
throw new Error('Expected working and idle handlers to be registered')
}
workingHandler()
mockStoreState.settings = {
...mockStoreState.settings,
notifications: {
...mockStoreState.settings.notifications,
agentTaskComplete: false
}
}
notifyStoreSubscribers()
mockStoreState.settings = {
...mockStoreState.settings,
notifications: {
...mockStoreState.settings.notifications,
agentTaskComplete: true
}
}
notifyStoreSubscribers()
idleHandler('* Codex done')
vi.advanceTimersByTime(1_000)
expect(deps.dispatchNotification).not.toHaveBeenCalledWith(
expect.objectContaining({ source: 'agent-task-complete' })
)
})
it('requires fresh working evidence when notifications start disabled then re-enable', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-codex')
transportFactoryQueue.push(transport)
vi.useFakeTimers()
mockStoreState.settings = {
...mockStoreState.settings,
notifications: {
enabled: true,
agentTaskComplete: false,
terminalBell: true,
suppressWhenFocused: false,
customSoundPath: null
}
}
const pane = createPane(1)
const manager = createManager(1)
const deps = createDeps()
connectPanePty(pane as never, manager as never, deps as never)
const workingHandler = createdTransportOptions[0]?.onAgentBecameWorking as
| (() => void)
| undefined
const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as
| ((title: string) => void)
| undefined
if (!workingHandler || !idleHandler) {
throw new Error('Expected working and idle handlers to be registered')
}
workingHandler()
mockStoreState.settings = {
...mockStoreState.settings,
notifications: {
...mockStoreState.settings.notifications,
agentTaskComplete: true
}
}
notifyStoreSubscribers()
idleHandler('* Codex done')
vi.advanceTimersByTime(1_000)
expect(deps.dispatchNotification).not.toHaveBeenCalledWith(
expect.objectContaining({ source: 'agent-task-complete' })
)
})
it('dispatches agent-task-complete for generic Codex spinner titles after process identity is confirmed', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-codex')
transportFactoryQueue.push(transport)
vi.useFakeTimers()
const api = (
globalThis as unknown as {
window: { api: { pty: { getForegroundProcess: ReturnType<typeof vi.fn> } } }
}
).window.api
api.pty.getForegroundProcess.mockResolvedValue('codex')
const pane = createPane(1)
const manager = createManager(1)
const deps = createDeps()
connectPanePty(pane as never, manager as never, deps as never)
const titleHandler = createdTransportOptions[0]?.onTitleChange as
| ((title: string, rawTitle: string) => void)
| undefined
if (!titleHandler) {
throw new Error('Expected onTitleChange to be registered')
}
titleHandler('⠋ experimental-agent-observability', '⠋ experimental-agent-observability')
titleHandler('experimental-agent-observability', 'experimental-agent-observability')
await flushAsyncTicks()
vi.advanceTimersByTime(1000)
expect(deps.dispatchNotification).toHaveBeenCalledWith({
source: 'agent-task-complete',
terminalTitle: 'experimental-agent-observability',
paneKey: makePaneKey('tab-1', LEAF_1)
})
})
it('does not dispatch generic spinner completions when process inspection finds no agent', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-shell')
transportFactoryQueue.push(transport)
vi.useFakeTimers()
const api = (
globalThis as unknown as {
window: { api: { pty: { getForegroundProcess: ReturnType<typeof vi.fn> } } }
}
).window.api
api.pty.getForegroundProcess.mockResolvedValue('zsh')
const pane = createPane(1)
const manager = createManager(1)
const deps = createDeps()
connectPanePty(pane as never, manager as never, deps as never)
const titleHandler = createdTransportOptions[0]?.onTitleChange as
| ((title: string, rawTitle: string) => void)
| undefined
if (!titleHandler) {
throw new Error('Expected onTitleChange to be registered')
}
titleHandler('⠋ experimental-agent-observability', '⠋ experimental-agent-observability')
titleHandler('experimental-agent-observability', 'experimental-agent-observability')
await flushAsyncTicks()
vi.advanceTimersByTime(16_000)
expect(deps.dispatchNotification).not.toHaveBeenCalledWith(
expect.objectContaining({ source: 'agent-task-complete' })
)
})
it('dispatches agent-task-complete from recognized hook completion events', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-hook')
transportFactoryQueue.push(transport)
vi.useFakeTimers()
const pane = createPane(1)
const manager = createManager(1)
const deps = createDeps()
connectPanePty(pane as never, manager as never, deps as never)
const statusHandler = createdTransportOptions[0]?.onAgentStatus as
| ((payload: {
state: 'done'
prompt: string
agentType: 'codex'
lastAssistantMessage: string
}) => void)
| undefined
if (!statusHandler) {
throw new Error('Expected onAgentStatus to be registered')
}
statusHandler({
state: 'done',
prompt: 'finish the implementation',
agentType: 'codex',
lastAssistantMessage: 'Done.'
})
vi.advanceTimersByTime(250)
expect(deps.dispatchNotification).toHaveBeenCalledWith({
source: 'agent-task-complete',
terminalTitle: 'codex',
paneKey: makePaneKey('tab-1', LEAF_1)
})
})
it('restores a suppressed terminal bell when the pending agent completion is canceled', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport()
@@ -22,6 +22,7 @@ import { POST_REPLAY_MODE_RESET, POST_REPLAY_FOCUS_REPORTING_RESET } from './lay
import { warnTerminalLifecycleAnomaly } from './terminal-lifecycle-diagnostics'
import { registerPtySerializer, registerPtyTitleSource } from './pty-buffer-serializer'
import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream'
import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection'
import {
discardTerminalOutput,
flushTerminalOutput,
@@ -42,6 +43,7 @@ import {
AGENT_INTERRUPT_SETTLE_MS,
type AgentInterruptInputIntent
} from '../../../../shared/agent-interrupt-intent'
import { createAgentCompletionCoordinator } from './agent-completion-coordinator'
const pendingSpawnByPaneKey = new Map<string, Promise<string | null>>()
const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED'
@@ -180,6 +182,9 @@ export function connectPanePty(
let agentTaskCompleteNotificationGraceTimer: ReturnType<typeof setTimeout> | null = null
let agentTaskCompleteNotificationMaxTimer: ReturnType<typeof setTimeout> | null = null
let agentTaskCompleteStatusUnsubscribe: (() => void) | null = null
let agentTaskCompleteSettingsUnsubscribe: (() => void) | null = null
let agentTaskCompleteNotificationGeneration = 0
let wasAgentTaskCompleteNotificationEnabled = isAgentTaskCompleteNotificationEnabled()
let terminalBellNotificationTimer: ReturnType<typeof setTimeout> | null = null
let pendingTerminalBellNotification = false
// Why: passphrase-gate waits register a teardown here so dispose() can
@@ -430,7 +435,25 @@ export function connectPanePty(
terminalKeyTarget.addEventListener('keydown', onTerminalKeyDown, { capture: true })
}
const agentCompletionCoordinator = createAgentCompletionCoordinator({
paneKey: cacheKey,
getPtyId: () => transport.getPtyId(),
getSettings: () => useAppStore.getState().settings,
inspectProcess: inspectRuntimeTerminalProcess,
dispatchCompletion: (title) => scheduleAgentTaskCompleteNotification(title),
isLive: () => {
if (disposed) {
return false
}
if (transport.getPtyId()) {
return true
}
return (useAppStore.getState().ptyIdsByTabId[deps.tabId] ?? []).length > 0
}
})
const onExit = (ptyId: string): void => {
agentCompletionCoordinator.dispose()
deps.syncPanePtyLayoutBinding(pane.id, null)
deps.clearRuntimePaneTitle(deps.tabId, pane.id)
deps.clearTabPtyId(deps.tabId, ptyId)
@@ -473,6 +496,9 @@ export function connectPanePty(
const onTitleChange = (title: string, rawTitle: string): void => {
manager.setPaneGpuRendering(pane.id, !isGeminiTerminalTitle(rawTitle))
deps.setRuntimePaneTitle(deps.tabId, pane.id, title)
if (syncAgentTaskCompleteNotificationEnabled()) {
agentCompletionCoordinator.observeTitle(rawTitle)
}
// Why: only the focused pane should drive the tab title — otherwise two
// agents in split panes cause rapid title flickering as each emits OSC
// sequences. Only the active split's title propagates to the tab. When
@@ -506,6 +532,7 @@ export function connectPanePty(
// Spawn completion is when a pane gains a concrete PTY ID. The initial
// frame-level sync often runs before that async result arrives.
scheduleRuntimeGraphSync()
agentCompletionCoordinator.startProcessTracking()
}
// ─── Attention signal: BEL ────────────────────────────────────────────
//
@@ -585,12 +612,43 @@ export function connectPanePty(
}
}
const syncAgentTaskCompleteNotificationEnabled = (): boolean => {
const enabled = isAgentTaskCompleteNotificationEnabled()
if (!enabled && wasAgentTaskCompleteNotificationEnabled) {
// Why: disabling notifications is an event-time boundary. Drop pending
// timers and coordinator state so completions observed while off cannot
// replay if the user turns the setting back on.
agentTaskCompleteNotificationGeneration += 1
clearPendingAgentTaskCompleteNotification()
agentCompletionCoordinator.resetCompletionState({ requireFreshWorking: true })
if (pendingTerminalBellNotification) {
scheduleTerminalBellNotification()
}
} else if (enabled && !wasAgentTaskCompleteNotificationEnabled) {
// Why: a pane may have observed work while agent-complete was disabled.
// Re-enabling should not let the next idle event notify for that old task.
agentCompletionCoordinator.resetCompletionState({ requireFreshWorking: true })
}
wasAgentTaskCompleteNotificationEnabled = enabled
return enabled
}
const scheduleAgentTaskCompleteNotification = (title: string): void => {
if (!syncAgentTaskCompleteNotificationEnabled()) {
return
}
clearPendingAgentTaskCompleteNotification()
let graceElapsed = false
const generationAtSchedule = agentTaskCompleteNotificationGeneration
const dispatch = (): void => {
clearPendingAgentTaskCompleteNotification()
if (
generationAtSchedule !== agentTaskCompleteNotificationGeneration ||
!syncAgentTaskCompleteNotificationEnabled()
) {
return
}
pendingTerminalBellNotification = false
clearTerminalBellNotificationTimer()
if (disposed) {
@@ -626,6 +684,9 @@ export function connectPanePty(
AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS
)
}
agentTaskCompleteSettingsUnsubscribe = useAppStore.subscribe(() => {
syncAgentTaskCompleteNotificationEnabled()
})
// ─── Agent task-complete: OS notification, not tab attention ──────────
//
@@ -657,18 +718,14 @@ export function connectPanePty(
if (isClaudeAgent(title) && (settings === null || settings.promptCacheTimerEnabled)) {
deps.setCacheTimerStartedAt(cacheKey, Date.now())
}
// Why: this is the sole producer of 'agent-task-complete' in the renderer;
// removing it (as #944 did) leaves the user-facing Settings toggle with no
// events to fire. Dispatch is gated per-source in main; the main-process
// dedupe also collapses concurrent BEL + task-complete for the same
// worktree into a single notification.
// Why: title idle can beat the final hook status update by one event-loop
// turn; delay slightly so the notification can snapshot the richer status.
if (isAgentTaskCompleteNotificationEnabled()) {
scheduleAgentTaskCompleteNotification(title)
if (syncAgentTaskCompleteNotificationEnabled()) {
agentCompletionCoordinator.observeClassifiedTitleCompletion(title)
}
}
const onAgentBecameWorking = (): void => {
if (syncAgentTaskCompleteNotificationEnabled()) {
agentCompletionCoordinator.observeTitleWorking()
}
// Why: a new API call refreshes the prompt-cache TTL, so clear any running
// countdown. The timer will restart when the agent becomes idle again.
deps.setCacheTimerStartedAt(cacheKey, null)
@@ -751,6 +808,9 @@ export function connectPanePty(
const currentState = useAppStore.getState()
const title = currentState.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id]
currentState.setAgentStatus(cacheKey, payload, title)
if (syncAgentTaskCompleteNotificationEnabled()) {
agentCompletionCoordinator.observeHookStatus(payload)
}
}
}
const transport = runtimeEnvironmentId
@@ -1143,6 +1203,7 @@ export function connectPanePty(
pane.container.dataset.ptyId = ptyId
deps.syncPanePtyLayoutBinding(pane.id, ptyId)
deps.updateTabPtyId(deps.tabId, ptyId)
agentCompletionCoordinator.startProcessTracking()
// Why: mobile terminal streaming needs the exact screen state from
// xterm.js. The shared helper installs both the SerializeAddon-backed
@@ -1616,6 +1677,7 @@ export function connectPanePty(
})
deps.syncPanePtyLayoutBinding(pane.id, attachPtyId)
deps.updateTabPtyId(deps.tabId, attachPtyId)
agentCompletionCoordinator.startProcessTracking()
} catch (err) {
reportError(err instanceof Error ? err.message : String(err))
deps.clearTabPtyId(deps.tabId, attachPtyId)
@@ -1663,6 +1725,9 @@ export function connectPanePty(
onError: reportError
}
})
// Why: attach sets the transport's PTY id; starting process
// tracking before this point no-ops because getPtyId() is empty.
agentCompletionCoordinator.startProcessTracking()
})
.catch((err) => {
reportError(err instanceof Error ? err.message : String(err))
@@ -1700,6 +1765,10 @@ export function connectPanePty(
pendingTerminalBellNotification = false
clearTerminalBellNotificationTimer()
discardTerminalOutput(pane.terminal)
if (agentTaskCompleteSettingsUnsubscribe !== null) {
agentTaskCompleteSettingsUnsubscribe()
agentTaskCompleteSettingsUnsubscribe = null
}
if (connectFrame !== null) {
// Why: StrictMode and split-group remounts can dispose a pane binding
// before its deferred PTY attach/spawn work runs. Cancel that queued
@@ -1716,6 +1785,7 @@ export function connectPanePty(
pendingGeometryReportRaf = null
}
commandLifecycle.dispose()
agentCompletionCoordinator.dispose()
}
}
}
@@ -766,7 +766,7 @@ describe('createRemoteRuntimePtyTransport', () => {
await expect(transport.sendInputAccepted?.('\x03')).resolves.toBe(false)
})
it('normalizes bare LF input to carriage returns before writing to the remote PTY', async () => {
it('preserves literal LF input when sending remote PTY binary frames', async () => {
vi.useFakeTimers()
try {
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
@@ -778,15 +778,18 @@ describe('createRemoteRuntimePtyTransport', () => {
await transport.connect({ url: '', callbacks: {} })
const { streamId } = latestSubscribePayload()
runtimeCall.mockClear()
subscriptionSendBinary.mockClear()
expect(transport.sendInput('echo one\necho two\r\n')).toBe(true)
await vi.runOnlyPendingTimersAsync()
expect(runtimeCall).not.toHaveBeenCalled()
expect(subscriptionSendBinary).toHaveBeenCalledTimes(1)
const frame = decodeTerminalStreamFrame(subscriptionSendBinary.mock.calls[0][0])
expect(frame?.opcode).toBe(TerminalStreamOpcode.Input)
expect(frame?.streamId).toBe(streamId)
expect(frame ? decodeTerminalStreamText(frame.payload) : '').toBe('echo one\recho two\r')
expect(frame ? decodeTerminalStreamText(frame.payload) : '').toBe('echo one\necho two\r\n')
} finally {
vi.useRealTimers()
}
@@ -31,12 +31,6 @@ const REMOTE_TERMINAL_VIEWPORT_FLUSH_MS = 33
const HOST_SESSION_ATTACH_POLL_MS = 150
const HOST_SESSION_ATTACH_TIMEOUT_MS = 15_000
function normalizeRemoteTerminalInput(data: string): string {
// Why: PTYs expect the Enter key as carriage return. Some browser/mobile
// input paths can emit bare LF, which zsh renders with PROMPT_SP `%` marks.
return data.replace(/\r\n/g, '\r').replace(/\n/g, '\r')
}
function isRemoteTerminalGoneMessage(message: string): boolean {
return (
message.includes('terminal_handle_stale') ||
@@ -218,11 +212,10 @@ export function createRemoteRuntimePtyTransport(
if (!connected || !targetHandle) {
return false
}
const normalized = normalizeRemoteTerminalInput(data)
if (!normalized) {
if (!data) {
return true
}
const text = `${inputBatcher.takePending()}${normalized}`
const text = `${inputBatcher.takePending()}${data}`
try {
const result = await callRuntime<{ send: RuntimeTerminalSend }>('terminal.send', {
terminal: targetHandle,
@@ -479,13 +472,12 @@ export function createRemoteRuntimePtyTransport(
if (!connected || !handle) {
return false
}
const normalized = normalizeRemoteTerminalInput(data)
if (!normalized) {
if (!data) {
return true
}
// Why: remote terminal input currently crosses the runtime RPC boundary;
// coalescing same-frame key bursts avoids a per-keystroke remote round-trip.
inputBatcher.push(normalized)
// Why: callers use \r or terminal.send's enter flag for semantic Enter;
// literal LF bytes from paste/programmatic input must survive the stream.
inputBatcher.push(data)
return true
},
@@ -0,0 +1,199 @@
import { describe, expect, it } from 'vitest'
import { planTerminalLiveLayoutInsertions } from './terminal-live-layout-reconciliation'
import type { TerminalPaneLayoutNode } from '../../../../shared/types'
describe('planTerminalLiveLayoutInsertions', () => {
it('plans a host-added split leaf from an already-mounted source leaf', () => {
const layout: TerminalPaneLayoutNode = {
type: 'split',
direction: 'vertical',
first: { type: 'leaf', leafId: 'leaf-a' },
second: { type: 'leaf', leafId: 'leaf-b' }
}
expect(planTerminalLiveLayoutInsertions(layout, ['leaf-a'])).toEqual([
{
sourceLeafId: 'leaf-a',
sourceLeafIds: ['leaf-a'],
newLeafId: 'leaf-b',
direction: 'vertical',
placement: 'after'
}
])
})
it('plans nested missing leaves in the order splitPane can apply them', () => {
const layout: TerminalPaneLayoutNode = {
type: 'split',
direction: 'vertical',
first: { type: 'leaf', leafId: 'leaf-a' },
second: {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', leafId: 'leaf-b' },
second: { type: 'leaf', leafId: 'leaf-c' }
}
}
expect(planTerminalLiveLayoutInsertions(layout, ['leaf-a'])).toEqual([
{
sourceLeafId: 'leaf-a',
sourceLeafIds: ['leaf-a'],
newLeafId: 'leaf-b',
direction: 'vertical',
placement: 'after'
},
{
sourceLeafId: 'leaf-b',
sourceLeafIds: ['leaf-b'],
newLeafId: 'leaf-c',
direction: 'horizontal',
placement: 'after'
}
])
})
it('bridges a missing parent second subtree before filling the first subtree', () => {
const layout: TerminalPaneLayoutNode = {
type: 'split',
direction: 'vertical',
first: {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', leafId: 'leaf-a' },
second: { type: 'leaf', leafId: 'leaf-b' }
},
second: { type: 'leaf', leafId: 'leaf-c' }
}
expect(planTerminalLiveLayoutInsertions(layout, ['leaf-a'])).toEqual([
{
sourceLeafId: 'leaf-a',
sourceLeafIds: ['leaf-a'],
newLeafId: 'leaf-c',
direction: 'vertical',
placement: 'after'
},
{
sourceLeafId: 'leaf-a',
sourceLeafIds: ['leaf-a'],
newLeafId: 'leaf-b',
direction: 'horizontal',
placement: 'after'
}
])
})
it('plans a parent sibling after an already-mounted first-side split with host ratio', () => {
const layout: TerminalPaneLayoutNode = {
type: 'split',
direction: 'vertical',
ratio: 0.35,
first: {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', leafId: 'leaf-a' },
second: { type: 'leaf', leafId: 'leaf-b' }
},
second: { type: 'leaf', leafId: 'leaf-c' }
}
expect(planTerminalLiveLayoutInsertions(layout, ['leaf-a', 'leaf-b'])).toEqual([
{
sourceLeafId: 'leaf-b',
sourceLeafIds: ['leaf-a', 'leaf-b'],
newLeafId: 'leaf-c',
direction: 'vertical',
placement: 'after',
ratio: 0.35
}
])
})
it('plans a missing first subtree before an already-mounted second leaf', () => {
const layout: TerminalPaneLayoutNode = {
type: 'split',
direction: 'vertical',
first: { type: 'leaf', leafId: 'leaf-a' },
second: { type: 'leaf', leafId: 'leaf-b' }
}
expect(planTerminalLiveLayoutInsertions(layout, ['leaf-b'])).toEqual([
{
sourceLeafId: 'leaf-b',
sourceLeafIds: ['leaf-b'],
newLeafId: 'leaf-a',
direction: 'vertical',
placement: 'before'
}
])
})
it('plans nested missing first subtrees from an anchor in the second subtree', () => {
const layout: TerminalPaneLayoutNode = {
type: 'split',
direction: 'vertical',
first: { type: 'leaf', leafId: 'leaf-a' },
second: {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', leafId: 'leaf-b' },
second: { type: 'leaf', leafId: 'leaf-c' }
}
}
expect(planTerminalLiveLayoutInsertions(layout, ['leaf-c'])).toEqual([
{
sourceLeafId: 'leaf-c',
sourceLeafIds: ['leaf-c'],
newLeafId: 'leaf-a',
direction: 'vertical',
placement: 'before'
},
{
sourceLeafId: 'leaf-c',
sourceLeafIds: ['leaf-c'],
newLeafId: 'leaf-b',
direction: 'horizontal',
placement: 'before'
}
])
})
it('plans a parent sibling before an already-mounted second-side split with host ratio', () => {
const layout: TerminalPaneLayoutNode = {
type: 'split',
direction: 'vertical',
ratio: 0.25,
first: { type: 'leaf', leafId: 'leaf-a' },
second: {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', leafId: 'leaf-b' },
second: { type: 'leaf', leafId: 'leaf-c' }
}
}
expect(planTerminalLiveLayoutInsertions(layout, ['leaf-b', 'leaf-c'])).toEqual([
{
sourceLeafId: 'leaf-b',
sourceLeafIds: ['leaf-b', 'leaf-c'],
newLeafId: 'leaf-a',
direction: 'vertical',
placement: 'before',
ratio: 0.25
}
])
})
it('does not plan insertions when the layout has no mounted anchor leaf', () => {
const layout: TerminalPaneLayoutNode = {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', leafId: 'leaf-a' },
second: { type: 'leaf', leafId: 'leaf-b' }
}
expect(planTerminalLiveLayoutInsertions(layout, [])).toEqual([])
})
})
@@ -0,0 +1,135 @@
import type { TerminalPaneLayoutNode, TerminalPaneSplitDirection } from '../../../../shared/types'
export type TerminalLiveLayoutInsertion = {
sourceLeafId: string
sourceLeafIds: string[]
newLeafId: string
direction: TerminalPaneSplitDirection
placement: 'before' | 'after'
ratio?: number
}
function leftmostLeafId(node: TerminalPaneLayoutNode): string {
return node.type === 'leaf' ? node.leafId : leftmostLeafId(node.first)
}
function rightmostMountedLeafId(
node: TerminalPaneLayoutNode,
mountedLeafIds: ReadonlySet<string>
): string | null {
if (node.type === 'leaf') {
return mountedLeafIds.has(node.leafId) ? node.leafId : null
}
return (
rightmostMountedLeafId(node.second, mountedLeafIds) ??
rightmostMountedLeafId(node.first, mountedLeafIds)
)
}
function leftmostMountedLeafId(
node: TerminalPaneLayoutNode,
mountedLeafIds: ReadonlySet<string>
): string | null {
if (node.type === 'leaf') {
return mountedLeafIds.has(node.leafId) ? node.leafId : null
}
return (
leftmostMountedLeafId(node.first, mountedLeafIds) ??
leftmostMountedLeafId(node.second, mountedLeafIds)
)
}
function hasMountedLeaf(
node: TerminalPaneLayoutNode,
mountedLeafIds: ReadonlySet<string>
): boolean {
if (node.type === 'leaf') {
return mountedLeafIds.has(node.leafId)
}
return hasMountedLeaf(node.first, mountedLeafIds) || hasMountedLeaf(node.second, mountedLeafIds)
}
function mountedLeafIdsIn(
node: TerminalPaneLayoutNode,
mountedLeafIds: ReadonlySet<string>
): string[] {
if (node.type === 'leaf') {
return mountedLeafIds.has(node.leafId) ? [node.leafId] : []
}
return [
...mountedLeafIdsIn(node.first, mountedLeafIds),
...mountedLeafIdsIn(node.second, mountedLeafIds)
]
}
export function planTerminalLiveLayoutInsertions(
root: TerminalPaneLayoutNode | null | undefined,
currentLeafIds: Iterable<string>
): TerminalLiveLayoutInsertion[] {
if (!root) {
return []
}
const mountedLeafIds = new Set(currentLeafIds)
const insertions: TerminalLiveLayoutInsertion[] = []
const ensureSubtree = (node: TerminalPaneLayoutNode): boolean => {
if (node.type === 'leaf') {
return mountedLeafIds.has(node.leafId)
}
const firstHasMounted = hasMountedLeaf(node.first, mountedLeafIds)
const secondHasMounted = hasMountedLeaf(node.second, mountedLeafIds)
if (!firstHasMounted && !secondHasMounted) {
return false
}
// Why: bridge the current split before filling nested descendants; once a
// child subtree is split internally, PaneManager can no longer wrap it as
// this split's sibling using a leaf-only splitPane call.
if (firstHasMounted && !secondHasMounted) {
const sourceLeafId = rightmostMountedLeafId(node.first, mountedLeafIds)
const newLeafId = leftmostLeafId(node.second)
if (sourceLeafId && !mountedLeafIds.has(newLeafId)) {
insertions.push({
sourceLeafId,
sourceLeafIds: mountedLeafIdsIn(node.first, mountedLeafIds),
newLeafId,
direction: node.direction,
placement: 'after',
ratio: node.ratio
})
mountedLeafIds.add(newLeafId)
}
ensureSubtree(node.second)
ensureSubtree(node.first)
return true
}
if (!firstHasMounted && secondHasMounted) {
const sourceLeafId = leftmostMountedLeafId(node.second, mountedLeafIds)
const newLeafId = leftmostLeafId(node.first)
if (sourceLeafId && !mountedLeafIds.has(newLeafId)) {
insertions.push({
sourceLeafId,
sourceLeafIds: mountedLeafIdsIn(node.second, mountedLeafIds),
newLeafId,
direction: node.direction,
placement: 'before',
ratio: node.ratio
})
mountedLeafIds.add(newLeafId)
}
ensureSubtree(node.first)
ensureSubtree(node.second)
return true
}
ensureSubtree(node.first)
ensureSubtree(node.second)
return true
}
ensureSubtree(root)
return insertions
}
@@ -0,0 +1,28 @@
import {
detectAgentStatusFromTitle,
isGeminiTerminalTitle,
isPiTerminalTitle
} from '../../../../shared/agent-detection'
const TITLE_AGENT_TOKEN_RE =
/(?<![\w./\\-])(claude|codex|gemini|opencode|openclaw|aider|copilot|cursor-agent|cursor|droid|hermes|grok|pi)(?![\w./\\-])/i
export function titleHasExplicitAgentIdentity(title: string): boolean {
if (!title) {
return false
}
if (
title.startsWith('. ') ||
title.startsWith('* ') ||
title.startsWith('\u2733') ||
isGeminiTerminalTitle(title) ||
isPiTerminalTitle(title)
) {
return true
}
return TITLE_AGENT_TOKEN_RE.test(title)
}
export function titleIsInconclusiveNativeDroidTitle(title: string): boolean {
return /\bDroid\b/i.test(title) && detectAgentStatusFromTitle(title) === null
}
@@ -119,72 +119,77 @@ function countReposNeedingNotificationDisambiguation(
* creates a new array reference on every store update and triggers
* excessive re-renders of TerminalPane.
*/
export function dispatchTerminalNotification(
worktreeId: string,
event: TerminalNotificationEvent
): void {
const state = useAppStore.getState()
// Why: shutdownWorktreeTerminals clears ptyIdsByTabId synchronously
// before killing PTYs asynchronously. Any notification arriving after
// that point is stale — e.g. a staleTitleTimer that fires 3 s after
// shutdown, or an agent tracker transition from accumulated closure
// state. Checking for live PTYs at dispatch time catches ALL phantom
// notification sources regardless of which timer or callback produced
// them, rather than trying to cancel each one individually.
if (!hasLivePtyForWorktree(state, worktreeId)) {
return
}
// Why: prefer worktree.repoId over string-parsing the worktreeId. The
// `${repoId}::${path}` format is an implementation detail of id
// construction; coupling the notification dispatcher to it would silently
// drop the repo label if that format ever changes. The worktree object
// itself is the source of truth for its owning repo.
const worktree = getWorktreeMapFromState(state).get(worktreeId)
const repo = worktree ? getRepoMapFromState(state).get(worktree.repoId) : null
const customSoundPath = state.settings?.notifications?.customSoundPath ?? null
const agentStatus =
event.source === 'agent-task-complete' && event.paneKey
? state.agentStatusByPaneKey[event.paneKey]
: undefined
// Why: pane keys are reused across turns. A rich OS notification must not
// expose the previous turn's prompt if the current turn has no fresh hook snapshot yet.
const hasFreshAgentStatus =
agentStatus && Date.now() - agentStatus.updatedAt <= AGENT_NOTIFICATION_SNAPSHOT_MAX_AGE_MS
const agentSnapshot = hasFreshAgentStatus
? {
agentType: agentStatus.agentType,
agentState: agentStatus.state,
agentPrompt: agentStatus.prompt,
agentToolName: agentStatus.toolName,
agentToolInput: agentStatus.toolInput,
agentLastAssistantMessage: agentStatus.lastAssistantMessage,
agentInterrupted: agentStatus.interrupted
}
: {}
void window.api.notifications
.dispatch({
source: event.source,
worktreeId,
repoLabel: repo?.displayName,
worktreeLabel: worktree?.displayName || worktree?.branch || worktreeId,
hasMultipleActiveRepos: countReposNeedingNotificationDisambiguation(state) > 1,
terminalTitle: event.terminalTitle,
isActiveWorktree: state.activeWorktreeId === worktreeId,
...agentSnapshot
})
.then((result) => {
if (result.delivered) {
void playDesktopNotificationSound(customSoundPath)
}
})
.catch((err) => {
console.warn('Failed to dispatch notification:', err)
})
}
export function useNotificationDispatch(
worktreeId: string
): (event: TerminalNotificationEvent) => void {
return useCallback(
(event: TerminalNotificationEvent) => {
const state = useAppStore.getState()
// Why: shutdownWorktreeTerminals clears ptyIdsByTabId synchronously
// before killing PTYs asynchronously. Any notification arriving after
// that point is stale — e.g. a staleTitleTimer that fires 3 s after
// shutdown, or an agent tracker transition from accumulated closure
// state. Checking for live PTYs at dispatch time catches ALL phantom
// notification sources regardless of which timer or callback produced
// them, rather than trying to cancel each one individually.
if (!hasLivePtyForWorktree(state, worktreeId)) {
return
}
// Why: prefer worktree.repoId over string-parsing the worktreeId. The
// `${repoId}::${path}` format is an implementation detail of id
// construction; coupling the notification dispatcher to it would silently
// drop the repo label if that format ever changes. The worktree object
// itself is the source of truth for its owning repo.
const worktree = getWorktreeMapFromState(state).get(worktreeId)
const repo = worktree ? getRepoMapFromState(state).get(worktree.repoId) : null
const customSoundPath = state.settings?.notifications?.customSoundPath ?? null
const agentStatus =
event.source === 'agent-task-complete' && event.paneKey
? state.agentStatusByPaneKey[event.paneKey]
: undefined
// Why: pane keys are reused across turns. A rich OS notification must not
// expose the previous turn's prompt if the current turn has no fresh hook snapshot yet.
const hasFreshAgentStatus =
agentStatus && Date.now() - agentStatus.updatedAt <= AGENT_NOTIFICATION_SNAPSHOT_MAX_AGE_MS
const agentSnapshot = hasFreshAgentStatus
? {
agentType: agentStatus.agentType,
agentState: agentStatus.state,
agentPrompt: agentStatus.prompt,
agentToolName: agentStatus.toolName,
agentToolInput: agentStatus.toolInput,
agentLastAssistantMessage: agentStatus.lastAssistantMessage,
agentInterrupted: agentStatus.interrupted
}
: {}
void window.api.notifications
.dispatch({
source: event.source,
worktreeId,
repoLabel: repo?.displayName,
worktreeLabel: worktree?.displayName || worktree?.branch || worktreeId,
hasMultipleActiveRepos: countReposNeedingNotificationDisambiguation(state) > 1,
terminalTitle: event.terminalTitle,
isActiveWorktree: state.activeWorktreeId === worktreeId,
...agentSnapshot
})
.then((result) => {
if (result.delivered) {
void playDesktopNotificationSound(customSoundPath)
}
})
.catch((err) => {
console.warn('Failed to dispatch notification:', err)
})
},
(event: TerminalNotificationEvent) => dispatchTerminalNotification(worktreeId, event),
[worktreeId]
)
}
@@ -3,10 +3,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useTerminalPaneGlobalEffects } from './use-terminal-pane-global-effects'
const mocks = vi.hoisted(() => ({
captureScrollViewportPosition: vi.fn(),
fitAndFocusPanes: vi.fn(),
fitPanes: vi.fn(),
flushTerminalOutput: vi.fn(),
handleTerminalFileDrop: vi.fn()
handleTerminalFileDrop: vi.fn(),
restoreScrollViewportPosition: vi.fn()
}))
vi.mock('react', async (importOriginal) => {
@@ -29,6 +31,11 @@ vi.mock('@/lib/pane-manager/pane-terminal-output-scheduler', () => ({
flushTerminalOutput: mocks.flushTerminalOutput
}))
vi.mock('@/lib/pane-manager/pane-scroll', () => ({
captureScrollViewportPosition: mocks.captureScrollViewportPosition,
restoreScrollViewportPosition: mocks.restoreScrollViewportPosition
}))
vi.mock('./terminal-drop-handler', () => ({
handleTerminalFileDrop: mocks.handleTerminalFileDrop
}))
@@ -128,6 +135,13 @@ describe('useTerminalPaneGlobalEffects', () => {
mocks.flushTerminalOutput.mockImplementation((terminal: { name: string }) => {
order.push(`flush:${terminal.name}`)
})
mocks.captureScrollViewportPosition.mockImplementation((terminal: { name: string }) => {
order.push(`capture:${terminal.name}`)
return { terminalName: terminal.name }
})
mocks.restoreScrollViewportPosition.mockImplementation((terminal: { name: string }) => {
order.push(`restore:${terminal.name}`)
})
mocks.fitAndFocusPanes.mockImplementation(() => order.push('fit-focus'))
const isActiveRef = { current: false }
@@ -145,7 +159,16 @@ describe('useTerminalPaneGlobalEffects', () => {
toggleExpandPane: vi.fn()
})
expect(order).toEqual(['flush:terminal-a', 'flush:terminal-b', 'resume', 'fit-focus'])
expect(order).toEqual([
'capture:terminal-a',
'capture:terminal-b',
'flush:terminal-a',
'flush:terminal-b',
'resume',
'fit-focus',
'restore:terminal-a',
'restore:terminal-b'
])
expect(mocks.fitPanes).not.toHaveBeenCalled()
expect(isActiveRef.current).toBe(true)
expect(isVisibleRef.current).toBe(true)
@@ -15,6 +15,10 @@ import { flushTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-sch
import { handleFocusTerminalPaneDetail } from './focus-terminal-pane-event'
import { surfaceStaleAgentRow } from './stale-agent-row'
import { useAppStore } from '@/store'
import {
captureScrollViewportPosition,
restoreScrollViewportPosition
} from '@/lib/pane-manager/pane-scroll'
type UseTerminalPaneGlobalEffectsArgs = {
tabId: string
@@ -59,6 +63,15 @@ export function useTerminalPaneGlobalEffects({
return
}
if (isVisible) {
// Why: WebGL resume can disturb xterm's viewport bookkeeping before the
// post-resume fit runs. Capture numeric viewport positions first; the
// restore path avoids content matching so duplicate agent log lines do
// not jump to the wrong history entry.
const viewportPositions = new Map(
manager
.getPanes()
.map((pane) => [pane.id, captureScrollViewportPosition(pane.terminal)] as const)
)
// Why: background PTY output is throttled while a pane is not focused;
// flush it before fitting so newly visible terminals paint current state.
for (const pane of manager.getPanes()) {
@@ -77,6 +90,12 @@ export function useTerminalPaneGlobalEffects({
} else {
fitPanes(manager)
}
for (const pane of manager.getPanes()) {
const position = viewportPositions.get(pane.id)
if (position) {
restoreScrollViewportPosition(pane.terminal, position)
}
}
} else if (wasVisibleRef.current) {
// Suspend WebGL when going hidden. xterm.write() continues to land in
// the (now DOM-renderer-fallback or paused-canvas) terminal; the
@@ -1,7 +1,8 @@
import { describe, expect, it, vi } from 'vitest'
import {
shouldDetachPaneTransportOnUnmount,
splitPaneWithOneShotStartup
splitPaneWithOneShotStartup,
suppressIntentionalPaneCloseExit
} from './use-terminal-pane-lifecycle'
describe('splitPaneWithOneShotStartup', () => {
@@ -123,3 +124,25 @@ describe('shouldDetachPaneTransportOnUnmount', () => {
).toBe(false)
})
})
describe('suppressIntentionalPaneCloseExit', () => {
it('suppresses the pane PTY exit before intentional close teardown destroys the transport', () => {
const suppressPtyExit = vi.fn()
const transport = {
getPtyId: vi.fn(() => 'pty-pane-2')
}
expect(suppressIntentionalPaneCloseExit(transport, suppressPtyExit)).toBe('pty-pane-2')
expect(suppressPtyExit).toHaveBeenCalledWith('pty-pane-2')
})
it('does not suppress natural PTY exits that already cleared the transport id', () => {
const suppressPtyExit = vi.fn()
const transport = {
getPtyId: vi.fn(() => null)
}
expect(suppressIntentionalPaneCloseExit(transport, suppressPtyExit)).toBeNull()
expect(suppressPtyExit).not.toHaveBeenCalled()
})
})
@@ -141,6 +141,17 @@ type UseTerminalPaneLifecycleDeps = {
setPaneCount: React.Dispatch<React.SetStateAction<number>>
}
export function suppressIntentionalPaneCloseExit(
transport: Pick<PtyTransport, 'getPtyId'> | null | undefined,
suppressPtyExit: (ptyId: string) => void
): string | null {
const ptyId = transport?.getPtyId() ?? null
if (ptyId) {
suppressPtyExit(ptyId)
}
return ptyId
}
function terminalSelectionExceedsPrimaryLimit(terminal: Terminal): boolean {
const range = terminal.getSelectionPosition()
if (!range) {
@@ -681,8 +692,14 @@ export function useTerminalPaneLifecycle({
panePtyBindings.delete(paneId)
}
if (transport) {
const ptyId = transport.getPtyId()
const ptyId = suppressIntentionalPaneCloseExit(
transport,
useAppStore.getState().suppressPtyExit
)
if (ptyId) {
// Why: user/CLI pane closes intentionally tear down this PTY after
// PaneManager has already promoted the sibling. Suppress that exit
// so the last-surviving pane is not mistaken for an exited tab.
syncPanePtyLayoutBinding(paneId, null)
clearTabPtyId(tabId, ptyId)
}
@@ -27,7 +27,11 @@ vi.mock('@/runtime/web-runtime-session', () => ({
isWebRuntimeSessionActive: isWebRuntimeSessionActiveMock
}))
import { createNewTerminalTab } from './terminal-tab-actions'
import {
closeOtherTerminalTabs,
closeTerminalTabsToRight,
createNewTerminalTab
} from './terminal-tab-actions'
describe('createNewTerminalTab', () => {
beforeEach(() => {
@@ -83,3 +87,82 @@ describe('createNewTerminalTab', () => {
expect(setActiveTabType).not.toHaveBeenCalled()
})
})
describe('closeOtherTerminalTabs', () => {
beforeEach(() => {
vi.clearAllMocks()
isWebRuntimeSessionActiveMock.mockReturnValue(false)
})
it('delegates other terminal closes to the host runtime in paired web clients', () => {
const setActiveTab = vi.fn()
const closeTab = vi.fn()
isWebRuntimeSessionActiveMock.mockReturnValue(true)
getStateMock.mockReturnValue({
settings: { activeRuntimeEnvironmentId: 'web-runtime' },
tabsByWorktree: {
'wt-1': [{ id: 'keep' }, { id: 'close-a' }, { id: 'close-b' }]
},
setActiveTab,
closeTab
})
closeOtherTerminalTabs('keep', 'wt-1')
expect(setActiveTab).toHaveBeenCalledWith('keep')
expect(closeWebRuntimeSessionTabMock).toHaveBeenCalledTimes(2)
expect(closeWebRuntimeSessionTabMock).toHaveBeenCalledWith({
worktreeId: 'wt-1',
tabId: 'close-a',
environmentId: 'web-runtime'
})
expect(closeWebRuntimeSessionTabMock).toHaveBeenCalledWith({
worktreeId: 'wt-1',
tabId: 'close-b',
environmentId: 'web-runtime'
})
expect(closeTab).not.toHaveBeenCalled()
})
})
describe('closeTerminalTabsToRight', () => {
beforeEach(() => {
vi.clearAllMocks()
isWebRuntimeSessionActiveMock.mockReturnValue(false)
})
it('delegates terminal tabs to the host while still closing local editor tabs to the right', () => {
const closeTab = vi.fn()
const closeFile = vi.fn()
isWebRuntimeSessionActiveMock.mockReturnValue(true)
getStateMock
.mockReturnValueOnce({
settings: { activeRuntimeEnvironmentId: 'web-runtime' },
tabsByWorktree: {
'wt-1': [{ id: 'term-a' }, { id: 'term-b' }, { id: 'term-c' }]
},
openFiles: [{ id: 'file-b', worktreeId: 'wt-1' }],
tabBarOrderByWorktree: { 'wt-1': ['term-a', 'file-b', 'term-b', 'term-c'] },
closeTab
})
.mockReturnValue({
closeFile
})
closeTerminalTabsToRight('term-a', 'wt-1')
expect(closeWebRuntimeSessionTabMock).toHaveBeenCalledTimes(2)
expect(closeWebRuntimeSessionTabMock).toHaveBeenCalledWith({
worktreeId: 'wt-1',
tabId: 'term-b',
environmentId: 'web-runtime'
})
expect(closeWebRuntimeSessionTabMock).toHaveBeenCalledWith({
worktreeId: 'wt-1',
tabId: 'term-c',
environmentId: 'web-runtime'
})
expect(closeFile).toHaveBeenCalledWith('file-b')
expect(closeTab).not.toHaveBeenCalled()
})
})
@@ -109,9 +109,21 @@ export function closeOtherTerminalTabs(tabId: string, activeWorktreeId: string |
const state = useAppStore.getState()
const currentTabs = state.tabsByWorktree[activeWorktreeId] ?? []
state.setActiveTab(tabId)
const runtimeEnvironmentId = state.settings?.activeRuntimeEnvironmentId?.trim()
const closeHostTerminalTabs = isWebRuntimeSessionActive(runtimeEnvironmentId)
for (const tab of currentTabs) {
if (tab.id !== tabId) {
state.closeTab(tab.id)
if (closeHostTerminalTabs) {
// Why: paired web tabs are host-owned; local-only bulk close leaves
// the host to re-publish the supposedly closed terminal tabs.
void closeWebRuntimeSessionTab({
worktreeId: activeWorktreeId,
tabId: tab.id,
environmentId: runtimeEnvironmentId
})
} else {
state.closeTab(tab.id)
}
}
}
}
@@ -124,6 +136,8 @@ export function closeTerminalTabsToRight(tabId: string, activeWorktreeId: string
const state = useAppStore.getState()
const currentTerminalTabs = state.tabsByWorktree[activeWorktreeId] ?? []
const currentEditorFiles = state.openFiles.filter((f) => f.worktreeId === activeWorktreeId)
const runtimeEnvironmentId = state.settings?.activeRuntimeEnvironmentId?.trim()
const closeHostTerminalTabs = isWebRuntimeSessionActive(runtimeEnvironmentId)
const terminalIds = currentTerminalTabs.map((t) => t.id)
const terminalIdSet = new Set(terminalIds)
const orderedIds = reconcileTabOrder(
@@ -139,7 +153,17 @@ export function closeTerminalTabsToRight(tabId: string, activeWorktreeId: string
const rightIds = orderedIds.slice(index + 1)
for (const id of rightIds) {
if (terminalIdSet.has(id)) {
state.closeTab(id)
if (closeHostTerminalTabs) {
// Why: paired web tabs are host-owned; local-only bulk close leaves
// the host to re-publish the supposedly closed terminal tabs.
void closeWebRuntimeSessionTab({
worktreeId: activeWorktreeId,
tabId: id,
environmentId: runtimeEnvironmentId
})
} else {
state.closeTab(id)
}
} else {
useAppStore.getState().closeFile(id)
}

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