mirror of
https://github.com/stablyai/orca.git
synced 2026-10-29 16:17:51 +00:00
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:
Generated
+26
@@ -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
@@ -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",
|
||||
|
||||
Generated
+16
-5
@@ -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:
|
||||
|
||||
@@ -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
@@ -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) &&
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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']
|
||||
}
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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())
|
||||
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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 } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 () => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
|
||||
+199
@@ -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
Reference in New Issue
Block a user