Merge remote-tracking branch 'origin/main' into brennanb2025/fix-sidebar-sync

This commit is contained in:
brennanb2025
2026-06-25 01:40:00 -07:00
129 changed files with 10521 additions and 3526 deletions
+23 -12
View File
@@ -81,16 +81,26 @@ jobs:
fail-fast: false
matrix:
include:
- shard: '1/5'
shard_name: 1-of-5
- shard: '2/5'
shard_name: 2-of-5
- shard: '3/5'
shard_name: 3-of-5
- shard: '4/5'
shard_name: 4-of-5
- shard: '5/5'
shard_name: 5-of-5
- shard: '1/10'
shard_name: 1-of-10
- shard: '2/10'
shard_name: 2-of-10
- shard: '3/10'
shard_name: 3-of-10
- shard: '4/10'
shard_name: 4-of-10
- shard: '5/10'
shard_name: 5-of-10
- shard: '6/10'
shard_name: 6-of-10
- shard: '7/10'
shard_name: 7-of-10
- shard: '8/10'
shard_name: 8-of-10
- shard: '9/10'
shard_name: 9-of-10
- shard: '10/10'
shard_name: 10-of-10
steps:
- name: Checkout
@@ -142,8 +152,9 @@ jobs:
path: out/
# Why: the Electron suite is wall-clock constrained on OSS runners, but
# raising workers on one runner OOMs. Sharding keeps each VM at 4 workers
# while splitting the 91-test headless suite across separate runners.
# multiple Electron apps on one Xvfb VM contend on git/Chromium resources.
# Sharding keeps each VM at one Playwright worker while splitting the
# headless suite across separate runners.
# SKIP_BUILD makes Playwright globalSetup reuse the single build job's
# artifact instead of starting five concurrent electron-vite builds.
# ORCA_E2E_FORWARD_APP_LOGS keeps startup failures visible when Electron
+3 -2
View File
@@ -7,6 +7,7 @@
<a href="https://github.com/stablyai/orca/releases"><img src="docs/assets/readme-downloads.svg" alt="Total downloads across all releases" /></a>
<img src="https://badgen.net/github/license/stablyai/orca" alt="License" />
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white" alt="Join the Orca Discord" /></a>
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/X-000000?logo=x&logoColor=white" alt="Follow Orca on X" /></a>
<img src="https://img.shields.io/badge/macOS%20%7C%20Windows%20%7C%20Linux-4493F8?style=flat-square" alt="Supported platforms: macOS, Windows, and Linux" />
</p>
@@ -35,7 +36,7 @@
Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere.
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -228,7 +229,7 @@ yay -S stably-orca-bin
Pair with your desktop app to monitor and steer your agents from your phone.
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [Download the APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk)
- **Android:** [Download the APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk)
---
+3 -2
View File
@@ -7,6 +7,7 @@
<a href="https://github.com/stablyai/orca/releases"><img src="../assets/readme-downloads.svg" alt="Descargas totales en todas las versiones" /></a>
<img src="https://badgen.net/github/license/stablyai/orca" alt="Licencia" />
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white" alt="Únete al Discord de Orca" /></a>
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/X-000000?logo=x&logoColor=white" alt="Sigue a Orca en X" /></a>
<img src="https://img.shields.io/badge/macOS%20%7C%20Windows%20%7C%20Linux-4493F8?style=flat-square" alt="Plataformas compatibles: macOS, Windows y Linux" />
</p>
@@ -35,7 +36,7 @@
Supervisa y dirige a tus agentes desde el teléfono — recibe una notificación cuando un agente termine y envía instrucciones de seguimiento desde cualquier lugar.
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -226,7 +227,7 @@ yay -S stably-orca-bin
Vincúlala con tu app de escritorio para supervisar y dirigir a tus agentes desde el teléfono.
- **iOS:** [Descargar desde App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk)
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk)
---
+3 -2
View File
@@ -7,6 +7,7 @@
<a href="https://github.com/stablyai/orca/releases"><img src="../assets/readme-downloads.svg" alt="全リリースの合計ダウンロード数" /></a>
<img src="https://badgen.net/github/license/stablyai/orca" alt="ライセンス" />
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white" alt="Orca の Discord に参加" /></a>
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/X-000000?logo=x&logoColor=white" alt="X で Orca をフォロー" /></a>
<img src="https://img.shields.io/badge/macOS%20%7C%20Windows%20%7C%20Linux-4493F8?style=flat-square" alt="対応プラットフォーム: macOS、Windows、Linux" />
</p>
@@ -35,7 +36,7 @@
スマートフォンからエージェントを監視・操作 — エージェントの完了を通知で受け取り、どこからでもフォローアップを送信できます。
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -226,7 +227,7 @@ yay -S stably-orca-bin
デスクトップアプリとペアリングして、スマートフォンからエージェントを監視・操作できます。
- **iOS:** [App Store からダウンロード](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk)
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk)
---
+3 -2
View File
@@ -7,6 +7,7 @@
<a href="https://github.com/stablyai/orca/releases"><img src="../assets/readme-downloads.svg" alt="전체 릴리스 누적 다운로드 수" /></a>
<img src="https://badgen.net/github/license/stablyai/orca" alt="라이선스" />
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white" alt="Orca Discord 참여" /></a>
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/X-000000?logo=x&logoColor=white" alt="X에서 Orca 팔로우" /></a>
<img src="https://img.shields.io/badge/macOS%20%7C%20Windows%20%7C%20Linux-4493F8?style=flat-square" alt="지원 플랫폼: macOS, Windows, Linux" />
</p>
@@ -35,7 +36,7 @@
휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다.
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -226,7 +227,7 @@ yay -S stably-orca-bin
데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요.
- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk)
- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk)
---
+3 -2
View File
@@ -7,6 +7,7 @@
<a href="https://github.com/stablyai/orca/releases"><img src="../assets/readme-downloads.svg" alt="所有版本的总下载量" /></a>
<img src="https://badgen.net/github/license/stablyai/orca" alt="许可证" />
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white" alt="加入 Orca Discord" /></a>
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/X-000000?logo=x&logoColor=white" alt="在 X 上关注 Orca" /></a>
<img src="https://img.shields.io/badge/macOS%20%7C%20Windows%20%7C%20Linux-4493F8?style=flat-square" alt="支持的平台:macOS、Windows 和 Linux" />
</p>
@@ -35,7 +36,7 @@
用手机监控并指挥你的智能体 — 智能体完成时收到通知,随时随地发送后续指令。
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -226,7 +227,7 @@ yay -S stably-orca-bin
与桌面应用配对,用手机监控并指挥你的智能体。
- **iOS:** [从 App Store 下载](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk)
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk)
---
+329
View File
@@ -0,0 +1,329 @@
# Terminal Scroll Intent Architecture
## Problem
Terminal panes can jump or jitter when a user switches workspaces while a TUI
pane is scrolled near, but not exactly at, the bottom. The most visible case is a
Codex-style alternate-screen TUI in a split pane:
1. The user scrolls slightly up from the bottom.
2. The user switches to another workspace.
3. The user switches back.
4. The terminal sometimes jumps to the top, jumps to the bottom, flashes at a
wrong position, or restores an older viewport.
The current code tries to repair the viewport after layout, fit, split
reparenting, hidden-output replay, and visibility resume. Those repairs compete
with each other because none of them owns the user's scroll intent. A delayed
restore can replay an old state after the user has already scrolled, while a
follow-output path can force the bottom even when the user is reading scrollback.
## Reference Investigation Summary
The useful reference pattern is not a timer-based restore loop. It is an
explicit pinned-to-bottom model owned by the terminal frontend:
- The frontend stores bottom-following state separately from xterm's transient
`viewportY`.
- `xterm.onScroll` is not used as user intent. In xterm, that event can be
content-driven and can briefly report bottom during fast output or layout work.
- User wheel and keyboard scroll commands update the intent.
- `scrollToBottom()` is an explicit command that sets the intent back to
follow-output.
- Every write snapshots the intent before writing. If the terminal was following
output, it scrolls to bottom after the write. If the user was pinned to a
viewport, it preserves the prior viewport line.
- Fit/resize uses the same rule: follow bottom if explicitly pinned, otherwise
preserve the current viewport.
- Alternate-screen state is tracked from `buffer.active.type`, but it is not a
reason to infer user scroll intent from `onScroll`.
The reference implementation also patches private xterm internals to disable
xterm's implicit scroll-to-bottom behavior and call the original bottom-scroll
only from explicit owner paths. That gives a clean ownership boundary, but it is
an escalation point for Orca because private internals increase xterm upgrade
risk.
## Current Orca Risk Points
The current Orca branch already has several independent scroll actors:
- `src/renderer/src/lib/pane-manager/pane-scroll.ts` captures `viewportY`,
`baseY`, bottom state, and sometimes a marker, then restores using immediate,
rAF, and timeout paths.
- `src/renderer/src/lib/pane-manager/pane-tree-ops.ts` captures and restores
scroll around `safeFit()`.
- `src/renderer/src/lib/pane-manager/pane-split-scroll.ts` schedules split
reparent restores across rAF and timeout phases.
- `src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.ts`
listens to `terminal.onScroll` and stores snapshots while visible.
- `src/renderer/src/components/terminal-pane/pty-connection.ts` writes PTY output
through foreground, background, hidden-output skip, snapshot replay, and
restore paths.
- `src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts` can
write immediately, enqueue foreground writes, coalesce synchronized output, or
drain hidden/background chunks later.
The biggest architectural mismatch is the visibility memory hook's use of
`terminal.onScroll` as a snapshot trigger. That event is not a reliable user
scroll signal. During workspace switching, output parsing, hidden replay, or fit
can make it persist transient positions that later appear as "older position"
restores.
## Design Goal
Make terminal viewport movement a result of explicit scroll intent, not a side
effect of visibility, output, or layout timing.
The terminal should have one active scroll intent per pane:
- `followOutput`: the terminal is logically pinned to the live output bottom.
New output, explicit focus-follow requests, and fits may keep it at bottom.
- `pinnedViewport`: the user is reading a specific viewport. New output,
workspace switches, hidden-output replay, and fits must not move the viewport
except when scrollback pruning or buffer replacement makes the exact line
impossible.
## State Model
Add a focused module, for example:
`src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts`
Suggested state:
```ts
export type TerminalScrollIntentKind = 'followOutput' | 'pinnedViewport'
export type TerminalScrollIntent = {
kind: TerminalScrollIntentKind
bufferType: 'normal' | 'alternate'
viewportY: number
baseY: number
capturedAt: number
}
```
The state should be keyed by the live `Terminal` or by pane leaf identity where
it needs to survive pane replacement. For normal workspace switches that keep
the same xterm instance, the live terminal-keyed state should be authoritative.
## Intent Transitions
Only these events should change scroll intent:
- User wheel scrolls upward: set `pinnedViewport` immediately before xterm write
callbacks or rAF can pull the viewport back down.
- User wheel scrolls downward: after xterm applies the scroll, recompute whether
the viewport reached bottom. If yes, set `followOutput`; otherwise keep
`pinnedViewport`.
- User keyboard scroll commands: apply the same rules as wheel.
- Explicit `scrollToBottom()`, "follow output", or focus command with
follow-output semantics: set `followOutput`.
- Programmatic `scrollToTop()` or page/line scroll commands: set
`pinnedViewport`, unless the resulting viewport is bottom.
- Buffer change to alternate screen: record `bufferType`, but do not infer
follow-output from `onScroll`.
- Buffer return to normal screen: recompute from current viewport only if no
stronger user intent exists for the normal buffer.
- Scrollback prune: clamp a pinned viewport to the nearest valid line without
changing intent.
- Terminal remount/replay: restore from durable fallback only if the live xterm
instance was actually replaced.
Do not use xterm `onScroll` to set intent. It may still be useful as a passive
diagnostic signal, but it should not persist authoritative scroll state.
## Write Contract
All PTY output that reaches xterm should pass through one wrapper that enforces
intent around the actual `terminal.write` call.
Suggested owner:
`src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts`
Contract:
1. Capture intent and current `viewportY` before the write is scheduled.
2. Execute the write through the existing foreground/background/coalescing path.
3. After the write has parsed enough for xterm buffer state to be meaningful:
- If intent is `followOutput`, scroll to bottom.
- If intent is `pinnedViewport`, clamp and restore the saved viewport line.
4. Do not schedule multi-frame retries for normal output. A single post-write
enforcement point should be enough for standard writes.
For foreground synchronized-output holds, the enforcement point should run after
the held/coalesced frame is released, not for each partial cursor-hide chunk.
For background writes, preserve `pinnedViewport` if the pane is hidden or
inactive. Hidden output should not silently repin the terminal.
## Fit And Resize Contract
`safeFit()` should stop using generic delayed scroll restoration as its normal
behavior.
New rule:
1. If dimensions do not change, do nothing.
2. Before fit, capture current intent and viewport.
3. Fit.
4. If intent is `followOutput`, scroll to bottom.
5. If intent is `pinnedViewport`, restore the saved viewport line clamped to the
new `baseY`.
Avoid fitting hidden or zero-geometry panes. Existing geometry guards should
remain because fitting to transient workspace-switch geometry can send bad sizes
to the PTY and trigger TUI redraw churn.
## Visibility And Workspace Switch Contract
Workspace switching should not be a scroll operation.
On hide:
- Capture the current intent once.
- Do not run scroll restore.
- Do not update intent from `onScroll`.
- Continue hidden-output throttling/snapshot behavior as today.
On show:
- Reattach or refresh renderer resources as needed.
- Flush only the bounded amount of hidden output required for the active pane.
- Apply intent once after the visible output catch-up:
- `followOutput` goes to bottom.
- `pinnedViewport` stays pinned.
- Do not run repeated rAF/timeout scroll restores.
If the underlying terminal instance was replaced, use durable fallback state.
If the instance stayed alive, its live scroll intent is authoritative.
## Hidden Output And Snapshot Replay
Hidden-output snapshot replay is a true fallback path because it clears and
reconstructs xterm content. It needs scroll handling, but it should still obey
intent:
- Before snapshot replay, capture intent and viewport.
- Replay serialized content.
- Fit only if needed and only with valid visible geometry.
- Reapply the captured intent once:
- `followOutput`: bottom.
- `pinnedViewport`: previous viewport line, clamped.
- Do not let replay set follow-output just because the replayed buffer ends at
bottom.
If hidden output arrives while the user is pinned, background catch-up should not
move the visible viewport on activation.
## Split Reparenting
DOM reparenting can reset browser scroll state and WebGL resources. This is a
legitimate place for a fallback restore, but it should be scoped:
- Capture intent before reparent.
- Reparent.
- Reattach renderer if required.
- Apply intent once after DOM settles.
- Avoid restoring alternate-screen scrollback, because alternate screen has no
normal scrollback and a TUI owns its cursor.
The current split-specific rAF/timer code should become a local fallback for DOM
reparenting only, not a general model copied into workspace switching.
## Performance Constraints
The design must preserve Orca's terminal performance priorities:
- Do not keep all hidden terminals hot-rendering.
- Keep PTY/session/xterm state warm when feasible, but suspend hidden rendering
work and throttle hidden output as today.
- Avoid per-output layout reads. Intent enforcement should read xterm buffer
fields, not DOM geometry.
- Avoid multi-frame restore loops. They cause visible jitter and keep the
renderer busy after activation.
- Keep active-pane hidden-output catch-up bounded. Inactive visible split panes
can catch up over later frames.
- Do not send resize/SIGWINCH unless dimensions actually changed and the
renderer is the authoritative size owner.
The intended steady-state cost is one small buffer-state capture per xterm write
batch, not per byte and not per animation frame.
## Implementation Plan
1. Add `terminal-scroll-intent.ts`.
- Track `followOutput` vs `pinnedViewport`.
- Provide helpers for user scroll, explicit bottom, write capture, fit
capture, and intent enforcement.
2. Replace visibility-memory `onScroll` ownership.
- Remove authoritative snapshot updates from `terminal.onScroll`.
- Add capture-phase wheel listeners and keyboard scroll command hooks.
- Keep `onScroll` only for diagnostics if needed.
3. Route explicit scroll commands through intent helpers.
- `scrollToBottom()` sets `followOutput`.
- `scrollToTop()`, page up, and line up set `pinnedViewport`.
- Downward commands recompute after the command.
4. Wrap output writes.
- Add an intent-aware writer boundary in the output scheduler or in a narrow
wrapper used by the scheduler.
- Ensure foreground coalescing applies intent after the coalesced frame.
- Ensure background drains preserve pinned viewports.
5. Convert `safeFit()` to intent-aware fit.
- Capture intent before fit.
- Apply once after fit.
- Remove generic deferred scroll restore from normal fit.
6. Limit restore fallbacks.
- Keep fallback restore only for true remount/replay/reparent cases.
- Remove workspace-switch rAF/timeout restore loops once intent enforcement is
in place.
7. Add focused tests.
- User scroll up sets `pinnedViewport`.
- Output while pinned preserves viewport.
- Output while following scrolls to bottom.
- Fit while pinned preserves viewport.
- Fit while following stays at bottom.
- Hidden-output replay while pinned does not repin.
- Workspace hide/show does not change intent.
- `onScroll` does not mutate intent.
8. Add an E2E reproduction.
- Use the existing `scroll-primary` and `scroll-secondary` worktrees.
- Top pane in `scroll-primary` contains the TUI with enough scrollback.
- Scroll slightly above bottom, switch to `scroll-secondary`, then switch back.
- Assert the terminal remains within a small viewport tolerance and does not
visit top/bottom during the transition.
## Acceptance Criteria
- Switching away and back does not move a pinned TUI viewport.
- No visible flash to top or bottom during activation.
- Scrolling all the way to bottom re-enables follow-output and does not later
restore an older pinned position.
- Hidden output in inactive workspaces does not change visible scroll position
until the user explicitly follows output or reaches bottom.
- Alternate-screen TUIs do not receive extra restore/fill behavior that shifts
their cursor or scroll region.
- The active pane remains responsive during heavy hidden-output catch-up.
## Open Decisions
- Whether Orca should patch private xterm bottom-scroll internals. The reference
pattern does this for strict ownership, but Orca should first try public API
enforcement at write/fit boundaries and only escalate if xterm continues to
auto-follow independently.
- Whether scroll intent should be stored only by live `Terminal` instance or also
mirrored by leaf ID for remount fallback. The initial implementation should
use live instance state plus a leaf-keyed fallback for true replacement.
- Whether alternate screen needs a separate intent record from normal screen.
The first version can use one record with `bufferType`; a separate normal vs
alternate record is justified only if testing shows mode switches overwrite
user intent.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "orca",
"version": "1.4.97",
"version": "1.4.98-rc.0",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
@@ -192,7 +192,16 @@ describe('remote hook service installers', () => {
})
it('installs remote Codex hooks with matching trust entries', async () => {
const { sftp, fs } = createFakeSftp()
const { sftp, fs } = createFakeSftp({
'/home/dev/.codex/hooks.json': `${JSON.stringify({
hooks: {},
_managed: {
'external-manager': {
Stop: [0]
}
}
})}\n`
})
const status = await new CodexHookService().installRemote(sftp, '/home/dev/')
@@ -200,7 +209,9 @@ describe('remote hook service installers', () => {
expect(status.configPath).toBe('/home/dev/.codex/hooks.json')
const hooks = JSON.parse(fs.files.get('/home/dev/.codex/hooks.json')!) as {
hooks: Record<string, { hooks: { command: string }[] }[]>
_managed?: unknown
}
expect(hooks._managed).toEqual({ 'external-manager': { Stop: [0] } })
for (const eventName of [
'SessionStart',
'UserPromptSubmit',
@@ -0,0 +1,253 @@
import { createReadStream } from 'fs'
import { readdir, stat } from 'fs/promises'
import { createInterface } from 'readline'
import { extname, join } from 'path'
import {
isPathInsideOrEqual,
normalizeRuntimePathForComparison
} from '../../shared/cross-platform-path'
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
import { parseWslUncPath } from '../../shared/wsl-paths'
import type { FileWithMtime } from './session-scanner-types'
import { errorMessage, extractString, parseJsonObject } from './session-scanner-values'
// Reading a few lines of one transcript per project dir is enough to learn that
// dir's cwd; cap both so a giant or cwd-less transcript can't stall the scan.
const REPRESENTATIVE_CWD_LINE_LIMIT = 200
const REPRESENTATIVE_FILE_LIMIT = 3
const CLAUDE_EXTENSIONS = new Set(['.jsonl'])
/**
* Fully include the transcripts of Claude project directories whose cwd falls
* inside the active workspace/project paths.
*
* Why: Claude organizes `~/.claude/projects/<cwd-encoded>/` one directory per
* cwd. The global scan is recency-capped, so a project the user hasn't touched
* recently can drop off the list entirely even though `claude --resume` still
* finds it. For scoped panel views we resolve each project dir's cwd cheaply and
* bypass the cap for the ones that belong to the active scope.
*/
export async function discoverInScopeClaudeFiles(args: {
rootDirs: readonly string[]
scopePaths: readonly string[]
limit: number
excludedFilePaths: ReadonlySet<string>
issues: AiVaultScanIssue[]
}): Promise<FileWithMtime[]> {
if (args.scopePaths.length === 0 || args.limit <= 0) {
return []
}
const scopeProjectPrefixes = claudeProjectScopePrefixes(args.scopePaths)
const collected = new Map<string, FileWithMtime>()
for (const rootDir of args.rootDirs) {
for (const projectDir of await listProjectDirs(rootDir, scopeProjectPrefixes)) {
const cwd = await readProjectDirCwd(projectDir)
if (!cwd || !args.scopePaths.some((scopePath) => isCwdInsideScopePath(scopePath, cwd))) {
continue
}
await collectClaudeFiles({
projectDir,
issues: args.issues,
collected,
limit: args.limit,
excludedFilePaths: args.excludedFilePaths
})
}
}
return [...collected.values()].sort((left, right) => right.mtimeMs - left.mtimeMs)
}
function claudeProjectScopePrefixes(scopePaths: readonly string[]): Set<string> {
const prefixes = new Set<string>()
for (const scopePath of scopePaths) {
for (const candidate of scopePathCandidates(scopePath)) {
prefixes.add(encodeClaudeProjectPath(candidate))
}
}
return prefixes
}
function scopePathCandidates(scopePath: string): string[] {
const wslScopePath = parseWslUncPath(scopePath)
return wslScopePath ? [scopePath, wslScopePath.linuxPath] : [scopePath]
}
function encodeClaudeProjectPath(pathValue: string): string {
return normalizeRuntimePathForComparison(pathValue).replace(/[^a-zA-Z0-9]/g, '-')
}
function isClaudeProjectDirInScope(projectDirName: string, scopePrefixes: ReadonlySet<string>) {
for (const prefix of scopePrefixes) {
if (projectDirName === prefix || projectDirName.startsWith(`${prefix}-`)) {
return true
}
}
return false
}
function isCwdInsideScopePath(scopePath: string, cwd: string): boolean {
if (isPathInsideOrEqual(scopePath, cwd)) {
return true
}
const wslScopePath = parseWslUncPath(scopePath)
if (!wslScopePath) {
return false
}
// WSL transcripts record Linux cwd values even when the renderer sends the
// active worktree as a Windows UNC path.
return isPathInsideOrEqual(wslScopePath.linuxPath, cwd)
}
async function listProjectDirs(
rootDir: string,
scopeProjectPrefixes: ReadonlySet<string>
): Promise<string[]> {
let entries
try {
entries = await readdir(rootDir, { withFileTypes: true })
} catch {
return []
}
return entries
.filter(
(entry) => entry.isDirectory() && isClaudeProjectDirInScope(entry.name, scopeProjectPrefixes)
)
.map((entry) => join(rootDir, entry.name))
}
async function readProjectDirCwd(projectDir: string): Promise<string | null> {
const files = await newestClaudeFilesInDir(projectDir)
for (const file of files.slice(0, REPRESENTATIVE_FILE_LIMIT)) {
const cwd = await readFirstCwd(file)
if (cwd) {
return cwd
}
}
return null
}
async function newestClaudeFilesInDir(projectDir: string): Promise<string[]> {
let entries
try {
entries = await readdir(projectDir, { withFileTypes: true })
} catch {
return []
}
const newest: { path: string; mtimeMs: number }[] = []
for (const entry of entries) {
if (!entry.isFile() || !CLAUDE_EXTENSIONS.has(extname(entry.name).toLowerCase())) {
continue
}
const path = join(projectDir, entry.name)
try {
addBoundedPath(newest, REPRESENTATIVE_FILE_LIMIT, {
path,
mtimeMs: (await stat(path)).mtimeMs
})
} catch {
// Best effort: unreadable candidates are ignored here and reported during
// full collection if the project directory proves in-scope.
}
}
return newest.sort((left, right) => right.mtimeMs - left.mtimeMs).map((value) => value.path)
}
async function readFirstCwd(filePath: string): Promise<string | null> {
const input = createReadStream(filePath, { encoding: 'utf-8' })
const lines = createInterface({ input, crlfDelay: Infinity })
let read = 0
try {
for await (const line of lines) {
if (read++ >= REPRESENTATIVE_CWD_LINE_LIMIT) {
break
}
const cwd = extractString(parseJsonObject(line)?.cwd)
if (cwd) {
return cwd
}
}
} catch {
return null
} finally {
// readline.close() leaves the underlying stream open; destroy it so the early
// break/catch paths don't leak a file descriptor (this runs per project dir).
lines.close()
input.destroy()
}
return null
}
async function collectClaudeFiles(args: {
projectDir: string
issues: AiVaultScanIssue[]
collected: Map<string, FileWithMtime>
limit: number
excludedFilePaths: ReadonlySet<string>
}): Promise<void> {
let entries
try {
entries = await readdir(args.projectDir, { withFileTypes: true })
} catch {
return
}
for (const entry of entries) {
if (!entry.isFile() || !CLAUDE_EXTENSIONS.has(extname(entry.name).toLowerCase())) {
continue
}
const path = join(args.projectDir, entry.name)
if (args.collected.has(path) || args.excludedFilePaths.has(path)) {
continue
}
try {
const fileStat = await stat(path)
addBoundedFile(args.collected, args.limit, {
path,
mtimeMs: fileStat.mtimeMs,
modifiedAt: fileStat.mtime.toISOString()
})
} catch (err) {
args.issues.push({ agent: 'claude', path, message: errorMessage(err) })
}
}
}
function addBoundedFile(
collected: Map<string, FileWithMtime>,
limit: number,
file: FileWithMtime
): void {
if (collected.size < limit) {
collected.set(file.path, file)
return
}
let oldest: FileWithMtime | null = null
for (const candidate of collected.values()) {
if (!oldest || candidate.mtimeMs < oldest.mtimeMs) {
oldest = candidate
}
}
if (oldest && file.mtimeMs > oldest.mtimeMs) {
collected.delete(oldest.path)
collected.set(file.path, file)
}
}
function addBoundedPath<T extends { mtimeMs: number }>(items: T[], limit: number, item: T): void {
if (items.length < limit) {
items.push(item)
return
}
let oldestIndex = 0
for (let index = 1; index < items.length; index++) {
if (items[index].mtimeMs < items[oldestIndex].mtimeMs) {
oldestIndex = index
}
}
if (item.mtimeMs > items[oldestIndex].mtimeMs) {
items[oldestIndex] = item
}
}
@@ -0,0 +1,169 @@
import { mkdtemp, mkdir, rm, utimes, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, describe, expect, it } from 'vitest'
import { scanAiVaultSessions } from './session-scanner'
import type { AiVaultScanOptions } from './session-scanner-types'
let tempRoots: string[] = []
afterEach(async () => {
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
tempRoots = []
})
// Point every non-Claude source at a nonexistent dir so the scan only sees the
// Claude fixtures created per test.
function scopedScanOptions(claudeProjectsDir: string, extra: Partial<AiVaultScanOptions>) {
return {
claudeProjectsDir,
codexSessionsDir: '/nonexistent/codex',
geminiSessionsDir: '/nonexistent/gemini',
copilotSessionsDir: '/nonexistent/copilot',
cursorProjectsDir: '/nonexistent/cursor',
opencodeStorageDir: '/nonexistent/opencode',
opencodeDbPaths: [],
grokSessionsDir: '/nonexistent/grok',
devinTranscriptsDir: '/nonexistent/devin',
hermesSessionsDir: '/nonexistent/hermes',
rovoSessionsDir: '/nonexistent/rovo',
openclawStateDir: '/nonexistent/openclaw',
openclawLegacyStateDir: '/nonexistent/openclaw-legacy',
piSessionsDir: '/nonexistent/pi',
droidSessionsDir: '/nonexistent/droid',
droidProjectsDir: '/nonexistent/droid-projects',
kimiSessionsDir: '/nonexistent/kimi',
...extra
} satisfies AiVaultScanOptions
}
async function writeClaudeSession(args: {
claudeRoot: string
dirName: string
sessionId: string
cwd: string
iso: string
leadingCwdlessLine?: boolean
}): Promise<void> {
const dir = join(args.claudeRoot, args.dirName)
await mkdir(dir, { recursive: true })
const records: unknown[] = []
if (args.leadingCwdlessLine) {
records.push({ type: 'last-prompt', sessionId: args.sessionId })
}
records.push({
type: 'user',
sessionId: args.sessionId,
timestamp: args.iso,
cwd: args.cwd,
message: { role: 'user', content: `work in ${args.cwd}` }
})
const filePath = join(dir, `${args.sessionId}.jsonl`)
await writeFile(filePath, records.map((record) => JSON.stringify(record)).join('\n'))
const time = new Date(args.iso)
await utimes(filePath, time, time)
}
describe('scanAiVaultSessions scope inclusion', () => {
it('surfaces in-scope sessions older than the global recency cap', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-scope-'))
tempRoots.push(root)
const claudeRoot = join(root, 'claude-projects')
// One old in-scope session that the recency cap would otherwise drop.
await writeClaudeSession({
claudeRoot,
dirName: '-repo-app',
sessionId: 'old-in-scope',
cwd: '/repo/app',
iso: '2026-01-01T00:00:00.000Z',
leadingCwdlessLine: true
})
// A session in a sub-cwd directory of the same workspace path.
await writeClaudeSession({
claudeRoot,
dirName: '-repo-app-packages-ui',
sessionId: 'old-in-scope-subdir',
cwd: '/repo/app/packages/ui',
iso: '2026-01-02T00:00:00.000Z'
})
// Recent out-of-scope sessions that fill the cap.
for (let index = 0; index < 4; index++) {
await writeClaudeSession({
claudeRoot,
dirName: `-other-${index}`,
sessionId: `recent-${index}`,
cwd: `/other/${index}`,
iso: `2026-06-2${index}T00:00:00.000Z`
})
}
const withoutScope = await scanAiVaultSessions(scopedScanOptions(claudeRoot, { limit: 2 }))
const withScope = await scanAiVaultSessions(
scopedScanOptions(claudeRoot, { limit: 2, scopePaths: ['/repo/app'] })
)
const ids = (result: { sessions: { sessionId: string }[] }) =>
result.sessions.map((session) => session.sessionId)
// The cap hides the old in-scope sessions when no scope is provided.
expect(ids(withoutScope)).not.toContain('old-in-scope')
expect(ids(withoutScope)).not.toContain('old-in-scope-subdir')
// Scope paths force them back in, including the sub-cwd directory.
expect(ids(withScope)).toContain('old-in-scope')
expect(ids(withScope)).toContain('old-in-scope-subdir')
})
it('does not duplicate sessions already in the capped result', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-scope-'))
tempRoots.push(root)
const claudeRoot = join(root, 'claude-projects')
await writeClaudeSession({
claudeRoot,
dirName: '-repo-app',
sessionId: 'recent-in-scope',
cwd: '/repo/app',
iso: '2026-06-24T00:00:00.000Z'
})
const result = await scanAiVaultSessions(
scopedScanOptions(claudeRoot, { limit: 50, scopePaths: ['/repo/app'] })
)
const matches = result.sessions.filter((session) => session.sessionId === 'recent-in-scope')
expect(matches).toHaveLength(1)
})
it('matches WSL UNC scope paths against Linux Claude cwd values', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-scope-'))
tempRoots.push(root)
const claudeRoot = join(root, 'claude-projects')
await writeClaudeSession({
claudeRoot,
dirName: '-home-ada-repo',
sessionId: 'old-wsl-in-scope',
cwd: '/home/ada/repo',
iso: '2026-01-01T00:00:00.000Z'
})
for (let index = 0; index < 4; index++) {
await writeClaudeSession({
claudeRoot,
dirName: `-other-wsl-${index}`,
sessionId: `recent-wsl-${index}`,
cwd: `/other/wsl/${index}`,
iso: `2026-06-2${index}T00:00:00.000Z`
})
}
const result = await scanAiVaultSessions(
scopedScanOptions(claudeRoot, {
limit: 2,
scopePaths: ['\\\\wsl.localhost\\Ubuntu\\home\\ada\\repo']
})
)
expect(result.sessions.map((session) => session.sessionId)).toContain('old-wsl-in-scope')
})
})
@@ -29,6 +29,9 @@ export type AiVaultScanOptions = {
kimiSessionsDir?: string
limit?: number
limitPerAgent?: number
// Active workspace/project paths whose sessions must be included regardless of
// the recency cap (see discoverInScopeClaudeFiles).
scopePaths?: readonly string[]
platform?: NodeJS.Platform
}
+70 -2
View File
@@ -6,6 +6,7 @@ import type {
import { sessionSortTime } from './session-scanner-accumulator'
import { parseAgentSessionFile } from './session-scanner-agent-parser'
import { codexHomeForSessionsDir } from './session-scanner-codex-paths'
import { discoverInScopeClaudeFiles } from './session-scanner-scope-discovery'
import {
DEFAULT_CODEX_HOME_DIR,
discoverAiVaultSessionSources
@@ -13,6 +14,7 @@ import {
import type {
AiVaultScanOptions,
SessionFileCandidate,
SessionFileDiscovery,
SessionParseResult
} from './session-scanner-types'
import { clampPositiveInteger, errorMessage } from './session-scanner-values'
@@ -20,6 +22,9 @@ import { clampPositiveInteger, errorMessage } from './session-scanner-values'
const DEFAULT_LIMIT = 1000
const DEFAULT_SCAN_LIMIT_PER_AGENT = 1000
const SESSION_PARSE_CONCURRENCY = 8
// Upper bound on extra in-scope transcripts discovered and parsed past the
// recency cap; guards against a pathological scoped history directory.
const SCOPE_PARSE_LIMIT = 2000
/**
* Scan all supported AI agent session stores and return a unified, sorted,
@@ -61,17 +66,80 @@ export async function scanAiVaultSessions(
issues
})
const sessions = parsedSessions
const cappedSessions = parsedSessions
.sort((left, right) => sessionSortTime(right) - sessionSortTime(left))
.slice(0, limit)
const scopeSessions = await scanInScopeSessions({
discoveries,
scopePaths: options.scopePaths ?? [],
alreadyParsedFilePaths: new Set(cappedSessions.map((session) => session.filePath)),
platform,
issues
})
return {
sessions,
sessions: mergeSessions(cappedSessions, scopeSessions),
issues,
scannedAt: new Date().toISOString()
}
}
// In-scope sessions are guaranteed regardless of the recency cap, so the global
// (already capped) result and the scope result are unioned and de-duplicated by
// session id, then re-sorted DESC.
function mergeSessions(
cappedSessions: AiVaultSession[],
scopeSessions: AiVaultSession[]
): AiVaultSession[] {
if (scopeSessions.length === 0) {
return cappedSessions
}
const byId = new Map<string, AiVaultSession>()
for (const session of cappedSessions) {
byId.set(session.id, session)
}
for (const session of scopeSessions) {
byId.set(session.id, session)
}
return [...byId.values()].sort((left, right) => sessionSortTime(right) - sessionSortTime(left))
}
async function scanInScopeSessions(args: {
discoveries: SessionFileDiscovery[]
scopePaths: readonly string[]
alreadyParsedFilePaths: ReadonlySet<string>
platform: NodeJS.Platform
issues: AiVaultScanIssue[]
}): Promise<AiVaultSession[]> {
if (args.scopePaths.length === 0) {
return []
}
const claudeRootDirs = args.discoveries
.filter((discovery) => discovery.agent === 'claude')
.map((discovery) => discovery.rootDir)
const files = await discoverInScopeClaudeFiles({
rootDirs: claudeRootDirs,
scopePaths: args.scopePaths,
limit: SCOPE_PARSE_LIMIT,
excludedFilePaths: args.alreadyParsedFilePaths,
issues: args.issues
})
const candidates = files.map(
(file): SessionFileCandidate => ({ agent: 'claude', file, codexHome: null })
)
if (candidates.length === 0) {
return []
}
// Parse every in-scope candidate (limit === candidate count never early-stops).
return parseSessionCandidates({
candidates,
limit: candidates.length,
platform: args.platform,
issues: args.issues
})
}
async function parseSessionCandidates(args: {
candidates: SessionFileCandidate[]
limit: number
@@ -144,6 +144,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
terminalWindowsPowerShellImplementation: 'powershell.exe',
enableGitHubAttribution: true,
...overrides,
diffWordWrap: overrides.diffWordWrap ?? false,
localWindowsRuntimeDefault: overrides.localWindowsRuntimeDefault ?? { kind: 'windows-host' },
leftSidebarAppearanceMode: overrides.leftSidebarAppearanceMode ?? 'default',
appFontFamily,
+1
View File
@@ -148,6 +148,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
terminalWindowsPowerShellImplementation: 'powershell.exe',
enableGitHubAttribution: true,
...overrides,
diffWordWrap: overrides.diffWordWrap ?? false,
localWindowsRuntimeDefault: overrides.localWindowsRuntimeDefault ?? { kind: 'windows-host' },
leftSidebarAppearanceMode: overrides.leftSidebarAppearanceMode ?? 'default',
appFontFamily,
+182 -20
View File
@@ -16,6 +16,7 @@ import {
computeTrustKey,
computeTrustedHash,
escapeTomlString,
getCodexCanonicalTrustPath,
parseTrustKey,
readHookTrustEntries,
removeHookTrustEntries,
@@ -244,7 +245,31 @@ describe('computeTrustKey', () => {
handlerIndex: 0,
command: 'irrelevant'
})
).toBe(`${realpathSync.native(hooksPath)}:user_prompt_submit:0:0`)
).toBe(`${realpathSync.native(hooksPath).replace(/\\/g, '/')}:user_prompt_submit:0:0`)
})
it('normalizes Windows backslashes to forward slashes in the trust key', () => {
// Why: getCodexCanonicalTrustPath must return a forward-slash key even when
// the path uses native Windows separators, preventing separator mismatch
// against Codex-written literal-string keys.
const winPath = 'C:\\Users\\Rod\\AppData\\Roaming\\orca\\hooks.json'
const key = computeTrustKey({
sourcePath: winPath,
eventLabel: 'session_start',
groupIndex: 0,
handlerIndex: 0,
command: 'echo'
})
expect(key).not.toContain('\\')
expect(key.startsWith('C:/Users/Rod/AppData/Roaming/orca/hooks.json:')).toBe(true)
})
it('preserves literal backslashes in non-Windows-style fallback paths', () => {
// Why: SSH/POSIX paths can legally contain `\` as a filename character;
// only Windows-style separators should be normalized.
expect(getCodexCanonicalTrustPath('/tmp/with\\literal/hooks.json')).toBe(
'/tmp/with\\literal/hooks.json'
)
})
})
@@ -344,10 +369,11 @@ describe('upsertHookTrustEntries', () => {
})
it('collapses duplicate blocks for the same hook key while preserving unrelated hook state', () => {
const key =
'C:\\Users\\me\\AppData\\Roaming\\orca\\codex-runtime-home\\home\\hooks.json:session_start:0:0'
const unrelatedKey =
'C:\\Users\\me\\AppData\\Roaming\\orca\\codex-runtime-home\\home\\hooks.json:stop:0:0'
const sourcePath = 'C:\\Users\\me\\AppData\\Roaming\\orca\\codex-runtime-home\\home\\hooks.json'
const key = `${sourcePath}:session_start:0:0`
const unrelatedSourcePath =
'C:\\Users\\me\\AppData\\Roaming\\orca\\codex-runtime-home\\home\\hooks.json'
const unrelatedKey = `${unrelatedSourcePath}:stop:0:0`
const original = [
`[hooks.state."${escapeTomlString(key)}"]`,
'enabled = true',
@@ -365,7 +391,7 @@ describe('upsertHookTrustEntries', () => {
writeFileSync(configPath, original, 'utf-8')
const entry: CodexTrustEntry = {
sourcePath: 'C:\\Users\\me\\AppData\\Roaming\\orca\\codex-runtime-home\\home\\hooks.json',
sourcePath,
eventLabel: 'session_start',
groupIndex: 0,
handlerIndex: 0,
@@ -373,11 +399,15 @@ describe('upsertHookTrustEntries', () => {
}
upsertHookTrustEntries(configPath, [entry])
// Why: after normalization Orca writes the forward-slash form; the fixture's
// backslash form is matched via normalizeHookTrustKeyForLookup and collapsed.
const normalizedKey = key.replace(/\\/g, '/')
const written = readFileSync(configPath, 'utf-8')
const duplicateKeyOccurrences = written.match(
new RegExp(`\\[hooks\\.state\\."${escapeRegex(escapeTomlString(key))}"\\]`, 'g')
new RegExp(`\\[hooks\\.state\\."${escapeRegex(normalizedKey)}"\\]`, 'g')
)
expect(duplicateKeyOccurrences).toHaveLength(1)
// The unrelated key was not upserted and stays in its original escaped form.
expect(written).toContain(`[hooks.state."${escapeTomlString(unrelatedKey)}"]`)
expect(written).toContain('trusted_hash = "sha256:KEEP"')
expect(written).toContain('enabled = false')
@@ -406,10 +436,12 @@ describe('upsertHookTrustEntries', () => {
}
upsertHookTrustEntries(configPath, [entry])
// Why: Orca writes the normalized forward-slash key form after replacement.
const normalizedKey = key.replace(/\\/g, '/')
const written = readFileSync(configPath, 'utf-8')
expect(written).not.toContain(`[hooks.state.'${key}']`)
expect(written.match(/\[hooks\.state\./g)).toHaveLength(1)
expect(written).toContain(`[hooks.state."${escapeTomlString(key)}"]`)
expect(written).toContain(`[hooks.state."${normalizedKey}"]`)
expect(written).toContain('enabled = false')
expect(written).toContain(`trusted_hash = "${computeTrustedHash(entry)}"`)
})
@@ -710,7 +742,9 @@ describe('upsertHookTrustEntries', () => {
expect(written).toContain('[hooks.state."/x/hooks.json:pre_tool_use:0:0"]')
})
it('escapes literal `"` and `\\` in the source path inside the trust block header', () => {
it('escapes literal `"` and `\\` in non-Windows source paths inside the trust block header', () => {
// Why: a backslash in a POSIX path can be a literal filename character, so
// it must still be escaped instead of normalized away.
const entry: CodexTrustEntry = {
sourcePath: '/x/with"quote\\and\\back/hooks.json',
eventLabel: 'pre_tool_use',
@@ -821,6 +855,87 @@ describe('upsertHookTrustEntries', () => {
expect(headerCount).toBe(1)
expect(written).not.toContain('sha256:OLD')
})
it('finds and replaces a Codex literal-string backslash block when Orca upserts with forward-slash key', () => {
// Why: Codex writes literal-string keys with raw backslashes; Orca builds
// keys with forward slashes after getCodexCanonicalTrustPath normalization.
// normalizeHookTrustKeyForLookup must bridge that separator gap so upsert replaces
// rather than appends.
const backslashPath = 'C:\\Users\\Rod\\AppData\\Roaming\\orca\\hooks.json'
const literalKey = `${backslashPath}:session_start:0:0`
const original = [
`[hooks.state.'${literalKey}']`,
'enabled = true',
'trusted_hash = "sha256:CODEX-WRITTEN"',
''
].join('\n')
writeFileSync(configPath, original, 'utf-8')
const entry: CodexTrustEntry = {
sourcePath: backslashPath,
eventLabel: 'session_start',
groupIndex: 0,
handlerIndex: 0,
command: 'echo session'
}
upsertHookTrustEntries(configPath, [entry])
const written = readFileSync(configPath, 'utf-8')
expect((written.match(/\[hooks\.state\./g) ?? []).length).toBe(1)
expect(written).not.toContain('sha256:CODEX-WRITTEN')
expect(written).toContain(`trusted_hash = "${computeTrustedHash(entry)}"`)
})
it('produces exactly one [hooks.state.*] header after two consecutive upserts on Windows path', () => {
// Why: idempotency guard — repeated auto-install on app start must not
// accumulate duplicate trust blocks and produce invalid TOML.
const entry: CodexTrustEntry = {
sourcePath: 'C:\\Users\\Rod\\AppData\\Roaming\\orca\\hooks.json',
eventLabel: 'session_start',
groupIndex: 0,
handlerIndex: 0,
command: 'echo session'
}
upsertHookTrustEntries(configPath, [entry])
upsertHookTrustEntries(configPath, [entry])
const written = readFileSync(configPath, 'utf-8')
expect((written.match(/\[hooks\.state\./g) ?? []).length).toBe(1)
})
it.skipIf(process.platform !== 'win32')(
'finds a Codex-written block with lowercased username when Orca key has mixed-case username',
() => {
// Why: realpathSync.native casing can differ between what Codex wrote
// (C:\Users\rod\...) and what Orca resolves (C:\Users\Rod\...).
// normalizeHookTrustKeyForLookup case-folds on Windows so the existing block is
// replaced rather than a duplicate appended.
const lowercasePath = 'C:\\Users\\rod\\AppData\\Roaming\\orca\\hooks.json'
const mixedCasePath = 'C:\\Users\\Rod\\AppData\\Roaming\\orca\\hooks.json'
const literalKey = `${lowercasePath}:session_start:0:0`
const original = [
`[hooks.state.'${literalKey}']`,
'enabled = true',
'trusted_hash = "sha256:LOWERCASE"',
''
].join('\n')
writeFileSync(configPath, original, 'utf-8')
const entry: CodexTrustEntry = {
sourcePath: mixedCasePath,
eventLabel: 'session_start',
groupIndex: 0,
handlerIndex: 0,
command: 'echo session'
}
upsertHookTrustEntries(configPath, [entry])
const written = readFileSync(configPath, 'utf-8')
expect((written.match(/\[hooks\.state\./g) ?? []).length).toBe(1)
expect(written).not.toContain('sha256:LOWERCASE')
expect(written).toContain(`trusted_hash = "${computeTrustedHash(entry)}"`)
}
)
})
describe('upsertProjectTrustLevel', () => {
@@ -836,8 +951,9 @@ describe('upsertProjectTrustLevel', () => {
mkdirSync(nestedDir)
mkdirSync(projectDir)
const aliasedProjectPath = join(nestedDir, '..', 'project')
const trustedPath = realpathSync.native(aliasedProjectPath)
const trustedTomlPath = trustedPath.replaceAll('\\', '\\\\').replaceAll('"', '\\"')
// Why: getCodexCanonicalTrustPath normalizes separators to forward slashes.
const trustedPath = realpathSync.native(aliasedProjectPath).replace(/\\/g, '/')
const trustedTomlPath = trustedPath.replaceAll('"', '\\"')
expect(upsertProjectTrustLevelInContent('', aliasedProjectPath, 'trusted')).toBe(
[`[projects."${trustedTomlPath}"]`, 'trust_level = "trusted"', ''].join('\n')
@@ -884,17 +1000,38 @@ describe('upsertProjectTrustLevel', () => {
expect(updated).toContain('[other]\nvalue = 1')
})
it('preserves CRLF endings and escapes the project path in the header', () => {
it('preserves CRLF endings and normalizes Windows path separators in the header', () => {
// Why: getCodexCanonicalTrustPath normalizes backslashes to forward slashes;
// the project path header uses forward slashes even on Windows input.
const original = ['[profiles.default]', 'model = "gpt-5"', ''].join('\r\n')
const updated = upsertProjectTrustLevelInContent(original, 'C:\\Users\\nw\\repo', 'trusted')
expect(updated).toContain(
['[projects."C:\\\\Users\\\\nw\\\\repo"]', 'trust_level = "trusted"', ''].join('\r\n')
['[projects."C:/Users/nw/repo"]', 'trust_level = "trusted"', ''].join('\r\n')
)
expect(updated).toContain('[profiles.default]\r\nmodel = "gpt-5"')
})
it('updates an existing Windows backslash project block after separator normalization', () => {
// Why: forward-slash writes must not strand an older backslash-form
// project table and append a duplicate project trust block.
const original = [
'[projects."C:\\\\Users\\\\nw\\\\repo"]',
'notes = "keep"',
'trust_level = "untrusted"',
''
].join('\n')
const updated = upsertProjectTrustLevelInContent(original, 'C:\\Users\\nw\\repo', 'trusted')
expect(updated.match(/\[projects\./g)).toHaveLength(1)
expect(updated).toContain('[projects."C:\\\\Users\\\\nw\\\\repo"]')
expect(updated).toContain('notes = "keep"')
expect(updated).toContain('trust_level = "trusted"')
expect(updated).not.toContain('trust_level = "untrusted"')
})
it('writes config.toml and avoids rewriting an already-trusted project', () => {
upsertProjectTrustLevel(configPath, '/tmp/codex-ws', 'trusted')
const firstWrite = readFileSync(configPath, 'utf-8')
@@ -1181,10 +1318,11 @@ describe('readHookTrustEntries', () => {
expect(result.get(key)).toEqual({ trustedHash: 'sha256:AAA', enabled: true })
})
it('unescapes `\\\\` in the block key', () => {
it('normalizes backslash block key to forward-slash at ingestion', () => {
// Why: a real Windows path on disk like C:\foo gets written escaped as
// `C:\\foo` inside the TOML key — the returned Map should expose the
// original unescaped form.
// `C:\\foo` inside the TOML key. The Map key is normalized (backslash ->
// forward-slash) so computeTrustKey lookups match regardless of how Codex
// encoded the path separator.
const original = [
'[hooks.state."C:\\\\foo\\\\hooks.json:pre_tool_use:0:0"]',
'enabled = true',
@@ -1194,13 +1332,13 @@ describe('readHookTrustEntries', () => {
writeFileSync(configPath, original, 'utf-8')
const result = readHookTrustEntries(configPath)
expect(result.get('C:\\foo\\hooks.json:pre_tool_use:0:0')?.trustedHash).toBe('sha256:WIN')
expect(result.get('C:/foo/hooks.json:pre_tool_use:0:0')?.trustedHash).toBe('sha256:WIN')
})
it('reads a literal-string hook table key', () => {
const key = 'C:\\foo\\hooks.json:session_start:0:0'
const rawKey = 'C:\\foo\\hooks.json:session_start:0:0'
const original = [
`[hooks.state.'${key}']`,
`[hooks.state.'${rawKey}']`,
'enabled = false',
'trusted_hash = "sha256:LITERAL"',
''
@@ -1208,9 +1346,33 @@ describe('readHookTrustEntries', () => {
writeFileSync(configPath, original, 'utf-8')
const result = readHookTrustEntries(configPath)
expect(result.get(key)).toEqual({ trustedHash: 'sha256:LITERAL', enabled: false })
expect(result.get('C:/foo/hooks.json:session_start:0:0')).toEqual({
trustedHash: 'sha256:LITERAL',
enabled: false
})
})
it.skipIf(process.platform !== 'win32')(
'supports case-insensitive lookups for Windows hook trust keys read from config',
() => {
// Why: Codex and realpathSync.native can disagree on user-path casing;
// status checks still need Map.get(computeTrustKey(...)) to find the row.
const rawKey = 'C:\\Users\\rod\\AppData\\Roaming\\orca\\hooks.json:session_start:0:0'
const lookupKey = 'C:/Users/Rod/AppData/Roaming/orca/hooks.json:session_start:0:0'
const original = [
`[hooks.state.'${rawKey}']`,
'enabled = true',
'trusted_hash = "sha256:CASE"',
''
].join('\n')
writeFileSync(configPath, original, 'utf-8')
const result = readHookTrustEntries(configPath)
expect(result.get(lookupKey)).toEqual({ trustedHash: 'sha256:CASE', enabled: true })
}
)
it('reads entries from a CRLF-terminated config', () => {
const key = '/x/hooks.json:pre_tool_use:0:0'
const original = [
+57 -7
View File
@@ -52,6 +52,26 @@ export type CodexHookTrustState = {
export type CodexProjectTrustLevel = 'trusted' | 'untrusted'
// Why: callers use computeTrustKey() for lookups, while existing TOML can carry
// Codex-written separator/casing variants. Normalize both sides at the Map edge.
class HookTrustEntryMap extends Map<string, CodexHookTrustState> {
override get(key: string): CodexHookTrustState | undefined {
return super.get(normalizeHookTrustKeyForLookup(key))
}
override has(key: string): boolean {
return super.has(normalizeHookTrustKeyForLookup(key))
}
override delete(key: string): boolean {
return super.delete(normalizeHookTrustKeyForLookup(key))
}
override set(key: string, value: CodexHookTrustState): this {
return super.set(normalizeHookTrustKeyForLookup(key), value)
}
}
// Why: matches Codex's canonical_json. Sorts object keys recursively before
// SHA-256ing; arrays preserve order.
function canonicalize(value: unknown): unknown {
@@ -102,11 +122,23 @@ export function getCodexCanonicalTrustPath(sourcePath: string): string {
try {
// Why: Codex canonicalizes trust paths before building config keys. On
// macOS, /var is a symlink to /private/var; trusting the raw path still
// leaves the TUI in review/trust prompts.
return realpathSync.native(sourcePath)
// leaves the TUI in review/trust prompts. Forward-slash normalization
// prevents separator mismatch against Codex-written literal-string keys on Windows.
return normalizeWindowsPathSeparators(realpathSync.native(sourcePath))
} catch {
return normalizeWindowsPathSeparators(sourcePath)
}
}
function normalizeWindowsPathSeparators(sourcePath: string): string {
if (!usesWindowsPathSeparators(sourcePath)) {
return sourcePath
}
return sourcePath.replace(/\\/g, '/')
}
function usesWindowsPathSeparators(sourcePath: string): boolean {
return /^[A-Za-z]:[\\/]/.test(sourcePath) || sourcePath.startsWith('\\\\')
}
export function parseTrustKey(key: string): {
@@ -342,6 +374,17 @@ type TrustBlockRange = {
end: number
}
// Why: separator and casing drift between Codex-written keys (raw backslash,
// potentially lowercased) and Orca-built keys (forward-slash, realpathSync.native
// casing) must not prevent findTrustBlockRanges from matching an existing block.
export function normalizeHookTrustKeyForLookup(key: string): string {
const parsed = parseTrustKey(key)
const separated = parsed
? `${normalizeWindowsPathSeparators(parsed.sourcePath)}:${parsed.eventLabel}:${parsed.groupIndex}:${parsed.handlerIndex}`
: normalizeWindowsPathSeparators(key)
return process.platform === 'win32' ? separated.toLowerCase() : separated
}
function findTrustBlockRanges(content: string, key: string): TrustBlockRange[] {
const ranges: TrustBlockRange[] = []
let cursor = 0
@@ -355,7 +398,10 @@ function findTrustBlockRanges(content: string, key: string): TrustBlockRange[] {
const headerKey = isInsideTomlMultilineString(multilineState)
? null
: parseHookStateHeaderKey(line)
if (headerKey === key) {
if (
headerKey !== null &&
normalizeHookTrustKeyForLookup(headerKey) === normalizeHookTrustKeyForLookup(key)
) {
const headerLineEnd = rawLine.endsWith('\r') ? lineEnd - 1 : lineEnd
const after = content.slice(headerLineEnd)
const nextHeaderRel = findNextTableHeader(after)
@@ -371,9 +417,13 @@ function findTrustBlockRanges(content: string, key: string): TrustBlockRange[] {
}
function buildProjectHeaderPattern(projectPath: string): RegExp {
const escapedPath = escapeRegex(escapeTomlString(projectPath))
const headerPaths = [escapeRegex(escapeTomlString(projectPath))]
if (usesWindowsPathSeparators(projectPath)) {
headerPaths.push(escapeRegex(escapeTomlString(projectPath.replace(/\//g, '\\'))))
}
return new RegExp(
`(^|\\r?\\n)[ \\t]*\\[projects\\."${escapedPath}"\\][ \\t]*(?:#[^\\r\\n]*)?(?=\\r?\\n|$)`
`(^|\\r?\\n)[ \\t]*\\[projects\\."(?:${headerPaths.join('|')})"\\][ \\t]*(?:#[^\\r\\n]*)?(?=\\r?\\n|$)`,
process.platform === 'win32' ? 'i' : undefined
)
}
@@ -681,7 +731,7 @@ function removeTrustBlock(content: string, key: string): string {
}
export function readHookTrustEntries(configPath: string): Map<string, CodexHookTrustState> {
const result = new Map<string, CodexHookTrustState>()
const result = new HookTrustEntryMap()
if (!existsSync(configPath)) {
return result
}
@@ -707,7 +757,7 @@ export function readHookTrustEntries(configPath: string): Map<string, CodexHookT
// a line scan beats pulling in a full TOML value parser.
const hashMatch = /^[ \t]*trusted_hash[ \t]*=[ \t]*"((?:[^"\\]|\\.)*)"/m.exec(block)
const enabledMatch = /^[ \t]*enabled[ \t]*=[ \t]*(true|false)[ \t\r]*(?:#.*)?$/m.exec(block)
result.set(key, {
result.set(normalizeHookTrustKeyForLookup(key), {
trustedHash: hashMatch ? unescapeTomlString(hashMatch[1]) : undefined,
enabled: enabledMatch ? enabledMatch[1] === 'true' : undefined
})
+72 -2
View File
@@ -88,9 +88,11 @@ function canonicalizeHookTrustKeyForTest(key: string): string {
}
const sourcePath = key.slice(0, thirdLast)
try {
return `${realpathSync.native(sourcePath)}${key.slice(thirdLast)}`
// Why: mirrors getCodexCanonicalTrustPath separator normalization so test
// expectations match the forward-slash keys Orca writes on Windows.
return `${realpathSync.native(sourcePath).replace(/\\/g, '/')}${key.slice(thirdLast)}`
} catch {
return key
return key.replace(/\\/g, '/')
}
}
@@ -133,6 +135,32 @@ describe('CodexHookService', () => {
expect(trustConfig).toContain(':permission_request:0:0')
})
it('drops plugin manager metadata from runtime hooks.json during install', () => {
const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home')
mkdirSync(managedCodexHome, { recursive: true })
writeFileSync(
join(managedCodexHome, 'hooks.json'),
`${JSON.stringify({
hooks: {},
_managed: {
'compound-engineering': {
Stop: [0]
}
}
})}\n`,
'utf-8'
)
expect(new CodexHookService().install().state).toBe('installed')
const hooksConfig = JSON.parse(readFileSync(join(managedCodexHome, 'hooks.json'), 'utf-8')) as {
hooks: Record<string, unknown>
_managed?: unknown
}
expect(hooksConfig._managed).toBeUndefined()
expect(Object.keys(hooksConfig)).toEqual(['hooks'])
})
// Why: #6078 — a Windows user profile path like `C:\Users\Jane Doe` used to
// be written verbatim as the hook command, so Codex split it at the space and
// the hook exited with code 1. The managed command uses an encoded launcher
@@ -720,6 +748,11 @@ describe('CodexHookService', () => {
{ hooks: [{ type: 'command', command: legacyCommand }] }
],
SessionStart: [{ hooks: [{ type: 'command', command: legacyCommand }] }]
},
_managed: {
'external-manager': {
Stop: [0]
}
}
},
null,
@@ -752,9 +785,11 @@ describe('CodexHookService', () => {
const systemHooks = JSON.parse(readFileSync(systemHooksPath, 'utf-8')) as {
hooks: Record<string, { hooks?: { command?: string }[] }[]>
_managed?: unknown
}
expect(systemHooks.hooks.Stop).toEqual([{ hooks: [{ type: 'command', command: 'user-hook' }] }])
expect(systemHooks.hooks.SessionStart).toBeUndefined()
expect(systemHooks._managed).toEqual({ 'external-manager': { Stop: [0] } })
const systemToml = readFileSync(join(systemCodexHome, 'config.toml'), 'utf-8')
expect(systemToml).toContain('model = "system-model"')
expect(systemToml).not.toContain(':stop:1:0')
@@ -913,6 +948,41 @@ describe('CodexHookService', () => {
expect(existsSync(profilePath)).toBe(false)
})
it('sanitizes runtime hooks.json metadata during remove even without managed hooks', () => {
const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home')
const managedHooksPath = join(managedCodexHome, 'hooks.json')
mkdirSync(managedCodexHome, { recursive: true })
writeFileSync(
managedHooksPath,
`${JSON.stringify(
{
hooks: {
Stop: [{ hooks: [{ type: 'command', command: 'user-hook' }] }]
},
_managed: {
'compound-engineering': {
Stop: [0]
}
}
},
null,
2
)}\n`,
'utf-8'
)
const status = new CodexHookService().remove()
expect(status.state).toBe('not_installed')
const hooksConfig = JSON.parse(readFileSync(managedHooksPath, 'utf-8')) as {
hooks: Record<string, unknown>
_managed?: unknown
}
expect(hooksConfig._managed).toBeUndefined()
expect(Object.keys(hooksConfig)).toEqual(['hooks'])
expect(hooksConfig.hooks.Stop).toEqual([{ hooks: [{ type: 'command', command: 'user-hook' }] }])
})
it('cleans duplicate Codex hook representations while keeping status hooks in runtime CODEX_HOME', () => {
const systemCodexHome = join(tmpHome, '.codex')
const systemHooksPath = join(systemCodexHome, 'hooks.json')
+23 -12
View File
@@ -31,6 +31,7 @@ import {
computeTrustedHash,
escapeTomlString,
getCodexCanonicalTrustPath,
normalizeHookTrustKeyForLookup,
parseTrustKey,
readHookTrustEntries,
removeHookTrustEntries,
@@ -62,6 +63,12 @@ function getConfigPath(): string {
return join(getOrcaManagedCodexHomePath(), 'hooks.json')
}
function writeCodexHooksJson(configPath: string, hooks: Record<string, HookDefinition[]>): void {
// Why: Codex rejects unknown top-level hooks.json fields, so plugin manager
// bookkeeping such as `_managed` must not survive Orca's rewrite.
writeHooksJson(configPath, { hooks })
}
function getCodexConfigTomlPath(): string {
return join(getOrcaManagedCodexHomePath(), 'config.toml')
}
@@ -209,7 +216,10 @@ function removeStaleRuntimeHookTrustEntries(
expectedEntries: readonly CodexTrustEntry[]
): void {
const expectedHashes = new Map(
expectedEntries.map((entry) => [computeTrustKey(entry), computeTrustedHash(entry)])
expectedEntries.map((entry) => [
normalizeHookTrustKeyForLookup(computeTrustKey(entry)),
computeTrustedHash(entry)
])
)
const canonicalRuntimeHooksPath = getCodexCanonicalTrustPath(runtimeHooksPath)
const staleKeys: string[] = []
@@ -218,7 +228,7 @@ function removeStaleRuntimeHookTrustEntries(
if (!parsed || getCodexCanonicalTrustPath(parsed.sourcePath) !== canonicalRuntimeHooksPath) {
continue
}
if (expectedHashes.get(key) === state.trustedHash) {
if (expectedHashes.get(normalizeHookTrustKeyForLookup(key)) === state.trustedHash) {
continue
}
staleKeys.push(key)
@@ -532,6 +542,8 @@ function cleanupLegacySystemManagedHooks(): void {
// Why: Codex hooks moved to Orca's managed CODEX_HOME; old entries in
// ~/.codex would keep external Codex sessions reporting into Orca.
if (removedManagedHook) {
// Why: this is the user's system hooks file, not Orca's runtime copy.
// Remove only stale Orca hook entries and preserve other managers' metadata.
writeHooksJson(legacyConfigPath, { ...config, hooks: nextHooks })
}
removeMatchingTrustEntries(getSystemCodexConfigTomlPath(), trustEntries)
@@ -889,7 +901,7 @@ export class CodexHookService {
config.hooks = nextHooks
writeManagedScript(scriptPath, getManagedScript())
writeHooksJson(configPath, config)
writeCodexHooksJson(configPath, nextHooks)
// Why: trust entries write last so a half-write can't leave a hash
// pointing at a hook that doesn't exist. Surface failures — without this,
// getStatus would report green for a hook Codex won't actually fire.
@@ -979,7 +991,9 @@ export class CodexHookService {
// Why: SSH remotes use POSIX `.sh` hook paths even when Orca itself is
// running on Windows; never derive remote script syntax from local OS.
await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix'))
await writeHooksJsonRemote(sftp, remoteConfigPath, config)
// Why: SSH installs edit the user's remote ~/.codex/hooks.json directly.
// Preserve non-Orca top-level metadata while replacing the hooks tree.
await writeHooksJsonRemote(sftp, remoteConfigPath, { ...config, hooks: nextHooks })
try {
const existingToml = (await readTextFileRemote(sftp, remoteTomlPath)) ?? ''
const updatedToml = upsertHookTrustEntriesInContent(existingToml, trustEntries)
@@ -1035,7 +1049,7 @@ export class CodexHookService {
const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName())
const hookPlan = getRuntimeHooksWithSystemUserHooks(config.hooks, isManagedCommand)
config.hooks = hookPlan.hooks
writeHooksJson(configPath, config)
writeCodexHooksJson(configPath, hookPlan.hooks)
try {
const tomlPath = getCodexConfigTomlPath()
@@ -1080,7 +1094,6 @@ export class CodexHookService {
}
const nextHooks = { ...config.hooks }
let removedManagedHooks = false
// Why: same broad matcher as install(), so remove() also cleans up stale
// entries from older builds even if the current scriptPath has moved.
const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName())
@@ -1092,18 +1105,16 @@ export class CodexHookService {
continue
}
const cleaned = removeManagedCommands(definitions, isManagedCommand)
if (JSON.stringify(cleaned) !== JSON.stringify(definitions)) {
removedManagedHooks = true
}
if (cleaned.length === 0) {
delete nextHooks[eventName]
} else {
nextHooks[eventName] = cleaned
}
}
if (configExists && removedManagedHooks) {
config.hooks = nextHooks
writeHooksJson(configPath, config)
if (configExists) {
// Why: remove() can be the only repair path for a parseable runtime file
// whose top-level plugin metadata makes Codex reject hooks.json.
writeCodexHooksJson(configPath, nextHooks)
}
// Why: also drop our trust entries so config.toml doesn't accumulate dead
+234 -61
View File
@@ -81,58 +81,215 @@ describe('getWorkItemDetails', () => {
acquireMock.mockResolvedValue(undefined)
})
it('uses the collapsed GraphQL issue query as the hot path', async () => {
it('uses the collapsed GraphQL issue query with timeline activity enrichment', async () => {
getWorkItemMock.mockResolvedValueOnce({
id: 'issue:923',
type: 'issue',
number: 923,
title: 'Use upstream issues',
state: 'open',
url: 'https://github.com/stablyai/orca/issues/923',
url: 'https://github.com/acme/widgets/issues/923',
labels: [],
updatedAt: '2026-04-01T00:00:00Z',
author: 'octocat'
author: 'issue-author'
})
getIssueOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' })
ghExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify({
data: {
repository: {
issue: {
body: 'Issue body',
assignees: { nodes: [{ login: 'jinjing' }] },
participants: {
nodes: [{ login: 'octocat', avatarUrl: 'https://x/y', name: 'Octo Cat' }]
},
comments: {
nodes: [
{
databaseId: 7,
body: 'first',
createdAt: '2026-04-01T00:00:00Z',
url: 'https://github.com/stablyai/orca/issues/923#issuecomment-7',
author: { login: 'octocat', avatarUrl: 'https://x/y' }
}
]
getIssueOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
const timelineEvents = [
{
id: 101,
event: 'assigned',
actor: { login: 'timeline-actor', avatar_url: 'https://x/timeline-actor' },
assignee: { login: 'assigned-user' },
created_at: '2026-04-01T01:00:00Z'
},
{
id: 102,
event: 'cross-referenced',
actor: { login: 'timeline-actor', avatar_url: 'https://x/timeline-actor' },
created_at: '2026-04-01T02:00:00Z',
source: {
issue: {
number: 6180,
title: 'Synthetic reference PR',
html_url: 'https://github.com/acme/widgets/pull/6180',
repository: { owner: { login: 'acme' }, name: 'widgets' },
pull_request: {}
}
}
},
{
id: 103,
event: 'moved_columns_in_project',
actor: { login: 'github-project-automation', avatar_url: 'https://x/bot' },
created_at: '2026-04-01T03:00:00Z',
previous_column_name: 'Doing',
project_column_name: 'Complete',
project: { name: 'Example Project' }
},
{
id: 104,
event: 'closed',
actor: { login: 'timeline-actor', avatar_url: 'https://x/timeline-actor' },
created_at: '2026-04-01T04:00:00Z',
state_reason: 'completed',
closer: {
number: 6180,
title: 'Synthetic reference PR',
html_url: 'https://github.com/acme/widgets/pull/6180',
repository: { owner: { login: 'acme' }, name: 'widgets' },
pull_request: {}
}
}
]
ghExecFileAsyncMock
.mockResolvedValueOnce({
stdout: JSON.stringify({
data: {
repository: {
issue: {
body: 'Issue body',
assignees: { nodes: [{ login: 'assigned-user' }] },
participants: {
nodes: [{ login: 'issue-author', avatarUrl: 'https://x/y', name: 'Issue Author' }]
},
comments: {
nodes: [
{
databaseId: 7,
body: 'first',
createdAt: '2026-04-01T00:00:00Z',
url: 'https://github.com/acme/widgets/issues/923#issuecomment-7',
author: { login: 'issue-author', avatarUrl: 'https://x/y' }
}
]
}
}
}
}
}
})
})
.mockResolvedValueOnce({
stdout: timelineEvents.map((event) => JSON.stringify(event)).join('\n')
})
})
const details = await getWorkItemDetails('/repo-root', 923, 'issue')
expect(getWorkItemMock).toHaveBeenCalledWith('/repo-root', 923, 'issue', undefined)
// Why: a single gh subprocess call replaces the previous REST + REST + GraphQL fan-out.
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
expect(ghExecFileAsyncMock.mock.calls[0][0][0]).toBe('api')
expect(ghExecFileAsyncMock.mock.calls[0][0][1]).toBe('graphql')
expect(ghExecFileAsyncMock.mock.calls[1][0]).toEqual([
'api',
'--cache',
'60s',
'repos/acme/widgets/issues/923/timeline?per_page=100&page=1',
'--jq',
'.[] | @json'
])
expect(details?.body).toBe('Issue body')
expect(details?.assignees).toEqual(['jinjing'])
expect(details?.assignees).toEqual(['assigned-user'])
expect(details?.comments).toHaveLength(1)
expect(details?.comments[0].id).toBe(7)
expect(details?.participants?.[0]?.login).toBe('octocat')
expect(details?.timelineItems).toMatchObject([
{ event: 'assigned', actor: 'timeline-actor', assignee: 'assigned-user' },
{
event: 'cross-referenced',
source: {
type: 'pr',
number: 6180,
repository: 'acme/widgets'
}
},
{
event: 'moved_columns_in_project',
actor: 'github-project-automation',
previousColumnName: 'Doing',
columnName: 'Complete',
projectName: 'Example Project'
},
{
event: 'closed',
stateReason: 'completed',
closer: { type: 'pr', number: 6180 }
}
])
expect(details?.participants?.[0]?.login).toBe('issue-author')
})
it('caps issue timeline pagination by supported activity items', async () => {
getWorkItemMock.mockResolvedValueOnce({
id: 'issue:923',
type: 'issue',
number: 923,
title: 'Use upstream issues',
state: 'open',
url: 'https://github.com/acme/widgets/issues/923',
labels: [],
updatedAt: '2026-04-01T00:00:00Z',
author: 'issue-author'
})
getIssueOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
const makeTimelineEvent = (page: number, index: number, event: string): string =>
JSON.stringify({
id: `${page}:${index}`,
event,
actor: { login: 'issue-author', avatar_url: 'https://x/y' },
assignee: { login: `assignee-${page}-${index}` },
created_at: '2026-04-01T00:00:00Z'
})
const makeTimelinePage = (page: number, supportedCount: number): string =>
Array.from({ length: 100 }, (_, index) =>
makeTimelineEvent(page, index, index < supportedCount ? 'assigned' : 'subscribed')
).join('\n')
ghExecFileAsyncMock
.mockResolvedValueOnce({
stdout: JSON.stringify({
data: {
repository: {
issue: {
body: 'Issue body',
assignees: { nodes: [] },
participants: { nodes: [] },
comments: { nodes: [] }
}
}
}
})
})
.mockResolvedValueOnce({ stdout: makeTimelinePage(1, 0) })
.mockResolvedValueOnce({ stdout: makeTimelinePage(2, 0) })
.mockResolvedValueOnce({ stdout: makeTimelinePage(3, 10) })
.mockResolvedValueOnce({ stdout: makeTimelinePage(4, 100) })
.mockResolvedValueOnce({ stdout: makeTimelinePage(5, 100) })
.mockResolvedValueOnce({ stdout: makeTimelinePage(6, 100) })
const details = await getWorkItemDetails('/repo-root', 923, 'issue')
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(7)
expect(ghExecFileAsyncMock.mock.calls[1][0]).toContain(
'repos/acme/widgets/issues/923/timeline?per_page=100&page=1'
)
expect(ghExecFileAsyncMock.mock.calls[2][0]).toContain(
'repos/acme/widgets/issues/923/timeline?per_page=100&page=2'
)
expect(ghExecFileAsyncMock.mock.calls[3][0]).toContain(
'repos/acme/widgets/issues/923/timeline?per_page=100&page=3'
)
expect(ghExecFileAsyncMock.mock.calls[6][0]).toContain(
'repos/acme/widgets/issues/923/timeline?per_page=100&page=6'
)
expect(
ghExecFileAsyncMock.mock.calls.some((call) =>
call[0].includes('repos/acme/widgets/issues/923/timeline?per_page=100&page=7')
)
).toBe(false)
const timelineItems = details?.timelineItems
expect(timelineItems).toHaveLength(300)
if (!timelineItems) {
throw new Error('Expected timeline items to be present')
}
expect(timelineItems.at(0)).toMatchObject({ assignee: 'assignee-3-0' })
expect(timelineItems.at(-1)).toMatchObject({ assignee: 'assignee-6-89' })
})
it('falls back to REST + GraphQL when the collapsed issue query fails', async () => {
@@ -142,17 +299,18 @@ describe('getWorkItemDetails', () => {
number: 923,
title: 'Use upstream issues',
state: 'open',
url: 'https://github.com/stablyai/orca/issues/923',
url: 'https://github.com/acme/widgets/issues/923',
labels: [],
updatedAt: '2026-04-01T00:00:00Z',
author: 'octocat'
author: 'issue-author'
})
getIssueOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' })
getIssueOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
// Collapsed GraphQL throws → fallback path picks up.
ghExecFileAsyncMock
.mockRejectedValueOnce(new Error('GraphQL error'))
.mockResolvedValueOnce({ stdout: JSON.stringify({ body: 'Issue body' }) })
.mockResolvedValueOnce({ stdout: '[]' })
.mockResolvedValueOnce({ stdout: '[]' })
.mockResolvedValueOnce({
stdout: JSON.stringify({
data: { repository: { issue: { participants: { nodes: [] } } } }
@@ -166,12 +324,24 @@ describe('getWorkItemDetails', () => {
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
2,
['api', '--cache', '60s', 'repos/stablyai/orca/issues/923'],
['api', '--cache', '60s', 'repos/acme/widgets/issues/923'],
{ cwd: '/repo-root' }
)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
3,
['api', '--cache', '60s', 'repos/stablyai/orca/issues/923/comments?per_page=100'],
['api', '--cache', '60s', 'repos/acme/widgets/issues/923/comments?per_page=100'],
{ cwd: '/repo-root' }
)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
4,
[
'api',
'--cache',
'60s',
'repos/acme/widgets/issues/923/timeline?per_page=100&page=1',
'--jq',
'.[] | @json'
],
{ cwd: '/repo-root' }
)
expect(details?.body).toBe('Issue body')
@@ -190,19 +360,20 @@ describe('getWorkItemDetails', () => {
number: 923,
title: 'Use upstream issues',
state: 'open',
url: 'https://github.com/stablyai/orca/issues/923',
url: 'https://github.com/acme/widgets/issues/923',
labels: [],
updatedAt: '2026-04-01T00:00:00Z',
author: 'octocat'
author: 'issue-author'
})
getIssueOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' })
getIssueOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: JSON.stringify({ body: 'Issue body', assignees: [] }) })
.mockResolvedValueOnce({ stdout: '[]' })
.mockResolvedValueOnce({ stdout: '[]' })
const details = await getWorkItemDetails('/repo-root', 923, 'issue')
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(3)
expect(ghExecFileAsyncMock.mock.calls.some((call) => call[0][1] === 'graphql')).toBe(false)
expect(noteRateLimitSpendMock).not.toHaveBeenCalled()
expect(details?.body).toBe('Issue body')
@@ -216,31 +387,33 @@ describe('getWorkItemDetails', () => {
number: 923,
title: 'Use upstream issues',
state: 'open',
url: 'https://github.com/stablyai/orca/issues/923',
url: 'https://github.com/acme/widgets/issues/923',
labels: [],
updatedAt: '2026-04-01T00:00:00Z',
author: 'octocat'
author: 'issue-author'
})
getIssueOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' })
ghExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify({
data: {
repository: {
issue: {
body: 'Remote issue body',
assignees: { nodes: [] },
participants: { nodes: [] },
comments: { nodes: [] }
getIssueOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock
.mockResolvedValueOnce({
stdout: JSON.stringify({
data: {
repository: {
issue: {
body: 'Remote issue body',
assignees: { nodes: [] },
participants: { nodes: [] },
comments: { nodes: [] }
}
}
}
}
})
})
})
.mockResolvedValueOnce({ stdout: '[]' })
const details = await getWorkItemDetails('/home/jinwoo/orca', 923, 'issue', 'openclaw-2')
const details = await getWorkItemDetails('/home/tester/widgets', 923, 'issue', 'ssh-test-1')
expect(getWorkItemMock).toHaveBeenCalledWith('/home/jinwoo/orca', 923, 'issue', 'openclaw-2')
expect(getIssueOwnerRepoMock).toHaveBeenCalledWith('/home/jinwoo/orca', 'openclaw-2')
expect(getWorkItemMock).toHaveBeenCalledWith('/home/tester/widgets', 923, 'issue', 'ssh-test-1')
expect(getIssueOwnerRepoMock).toHaveBeenCalledWith('/home/tester/widgets', 'ssh-test-1')
expect(ghExecFileAsyncMock.mock.calls[0][1]).toEqual({})
expect(details?.body).toBe('Remote issue body')
})
@@ -253,17 +426,17 @@ describe('getWorkItemDetails', () => {
number: 42,
title: 'Review drawer WSL',
state: 'open',
url: 'https://github.com/stablyai/orca/pull/42',
url: 'https://github.com/acme/widgets/pull/42',
labels: [],
updatedAt: '2026-04-01T00:00:00Z',
author: 'octocat'
author: 'pr-author'
})
getOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' })
getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
getPRCommentsMock.mockResolvedValue([])
getPRChecksMock.mockResolvedValue([])
ghExecFileAsyncMock.mockImplementation(async (args: string[]) => {
const target = args.at(-1)
if (target === 'repos/stablyai/orca/pulls/42') {
if (target === 'repos/acme/widgets/pulls/42') {
return {
stdout: JSON.stringify({
body: 'PR body',
@@ -272,7 +445,7 @@ describe('getWorkItemDetails', () => {
})
}
}
if (target === 'repos/stablyai/orca/pulls/42/files?per_page=100') {
if (target === 'repos/acme/widgets/pulls/42/files?per_page=100') {
return { stdout: '[]' }
}
const query = args.find((arg) => arg.startsWith('query=')) ?? ''
+218 -10
View File
@@ -6,6 +6,8 @@ import type {
GitHubPRFile,
GitHubPRFileContents,
GitHubPRFileViewedState,
GitHubIssueTimelineItem,
GitHubIssueTimelineTarget,
GitHubWorkItem,
GitHubWorkItemDetails,
PRCheckDetail,
@@ -30,6 +32,10 @@ import { isMaxBufferOverflowError } from '../git/max-buffer-overflow'
// at 100 per page; we cap at a reasonable total so a massive PR cannot starve
// the gh semaphore while we fetch file listings.
const MAX_PR_FILES = 300
// Why: issue timelines can be extremely noisy from automation and cross-links.
// Bound drawer detail work so one huge issue cannot monopolize gh/API time.
const MAX_ISSUE_TIMELINE_ITEMS = 300
const GITHUB_REST_PAGE_SIZE = 100
// Why: hosted PR files must exceed the renderer's large-diff threshold before
// we give up on the raw fetch; otherwise the UI sees an empty diff instead of
// the safety fallback.
@@ -126,6 +132,197 @@ type GraphQLIssueDetailsResponse = {
errors?: { message?: string }[]
}
type GitHubOwnerRepoSlug = { owner: string; repo: string }
type RestTimelineUser = {
login?: string | null
avatar_url?: string | null
}
type RestTimelineIssue = {
number?: number | null
title?: string | null
html_url?: string | null
repository?: {
name?: string | null
owner?: { login?: string | null } | null
} | null
pull_request?: unknown
}
type RestTimelineEvent = {
id?: number | string | null
node_id?: string | null
event?: string | null
actor?: RestTimelineUser | null
user?: RestTimelineUser | null
assignee?: RestTimelineUser | null
created_at?: string | null
source?: {
issue?: RestTimelineIssue | null
} | null
closer?: RestTimelineIssue | null
state_reason?: string | null
project_card?: {
column_name?: string | null
previous_column_name?: string | null
project_url?: string | null
} | null
project?: {
name?: string | null
} | null
project_column_name?: string | null
previous_column_name?: string | null
}
function isSupportedTimelineEvent(
eventName: string | null | undefined
): eventName is GitHubIssueTimelineItem['event'] {
return (
eventName === 'assigned' ||
eventName === 'unassigned' ||
eventName === 'mentioned' ||
eventName === 'cross-referenced' ||
eventName === 'closed' ||
eventName === 'reopened' ||
eventName === 'moved_columns_in_project'
)
}
function mapTimelineTarget(
issue: RestTimelineIssue | null | undefined
): GitHubIssueTimelineTarget | undefined {
if (!issue || typeof issue.number !== 'number' || !issue.html_url) {
return undefined
}
const owner = issue.repository?.owner?.login
const repo = issue.repository?.name
return {
type: issue.pull_request ? 'pr' : 'issue',
number: issue.number,
title: issue.title ?? '',
url: issue.html_url,
repository: owner && repo ? `${owner}/${repo}` : undefined
}
}
function getTimelineActor(event: RestTimelineEvent): { login: string; avatarUrl: string } {
const actor = event.actor ?? event.user
return {
login: actor?.login ?? 'ghost',
avatarUrl: actor?.avatar_url ?? ''
}
}
function mapRestTimelineEvent(event: RestTimelineEvent): GitHubIssueTimelineItem | null {
const eventName = event.event
if (!isSupportedTimelineEvent(eventName)) {
return null
}
if (!event.created_at) {
return null
}
const actor = getTimelineActor(event)
const id = String(event.node_id ?? event.id ?? `${eventName}:${event.created_at}`)
const base = {
id,
event: eventName,
actor: actor.login,
actorAvatarUrl: actor.avatarUrl,
createdAt: event.created_at
}
if (eventName === 'assigned' || eventName === 'unassigned') {
return {
...base,
assignee: event.assignee?.login ?? undefined
}
}
if (eventName === 'mentioned' || eventName === 'cross-referenced') {
return {
...base,
source: mapTimelineTarget(event.source?.issue)
}
}
if (eventName === 'closed') {
return {
...base,
stateReason: event.state_reason ?? null,
closer: mapTimelineTarget(event.closer ?? event.source?.issue)
}
}
if (eventName === 'moved_columns_in_project') {
return {
...base,
previousColumnName:
event.previous_column_name ?? event.project_card?.previous_column_name ?? null,
columnName: event.project_column_name ?? event.project_card?.column_name ?? null,
projectName: event.project?.name ?? null
}
}
return base
}
function parseRestTimelineEventLines(stdout: string): RestTimelineEvent[] {
const events: RestTimelineEvent[] = []
for (const line of stdout.split('\n')) {
const trimmed = line.trim()
if (!trimmed) {
continue
}
try {
const parsed = JSON.parse(trimmed) as unknown
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
events.push(parsed)
}
} catch {
// Skip malformed jq lines; timeline activity is auxiliary to issue details.
}
}
return events
}
async function getIssueTimelineItems(
ownerRepo: GitHubOwnerRepoSlug,
issueNumber: number,
ghOptions: ReturnType<typeof ghRepoExecOptions>
): Promise<GitHubIssueTimelineItem[]> {
try {
const items: GitHubIssueTimelineItem[] = []
for (let page = 1; items.length < MAX_ISSUE_TIMELINE_ITEMS; page += 1) {
const { stdout } = await ghExecFileAsync(
[
'api',
'--cache',
'60s',
`repos/${ownerRepo.owner}/${ownerRepo.repo}/issues/${issueNumber}/timeline?per_page=${GITHUB_REST_PAGE_SIZE}&page=${page}`,
'--jq',
'.[] | @json'
],
ghOptions
)
// Why: --jq emits compact NDJSON while explicit pages let us stop once
// supported activity reaches the drawer cap.
const pageEvents = parseRestTimelineEventLines(stdout)
for (const event of pageEvents) {
const item = mapRestTimelineEvent(event)
if (!item) {
continue
}
items.push(item)
if (items.length === MAX_ISSUE_TIMELINE_ITEMS) {
break
}
}
if (pageEvents.length < GITHUB_REST_PAGE_SIZE) {
break
}
}
return items
} catch {
return []
}
}
async function getIssueDetailsViaGraphQL(
repoPath: string,
issueNumber: number,
@@ -136,6 +333,7 @@ async function getIssueDetailsViaGraphQL(
comments: PRComment[]
assignees: string[]
participants: GitHubAssignableUser[]
timelineItems: GitHubIssueTimelineItem[]
} | null> {
const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions))
const ownerRepo = await getIssueOwnerRepo(
@@ -198,11 +396,13 @@ async function getIssueDetailsViaGraphQL(
name: u.name ?? null,
avatarUrl: u.avatarUrl ?? ''
}))
const timelineItems = await getIssueTimelineItems(ownerRepo, issueNumber, ghOptions)
return {
body: issue.body ?? '',
comments,
assignees,
participants
participants,
timelineItems
}
} catch {
return null
@@ -471,7 +671,12 @@ async function getIssueBodyAndComments(
issueNumber: number,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<{ body: string; comments: PRComment[]; assignees: string[] }> {
): Promise<{
body: string
comments: PRComment[]
assignees: string[]
timelineItems: GitHubIssueTimelineItem[]
}> {
const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions))
const ownerRepo = await getIssueOwnerRepo(
repoPath,
@@ -480,7 +685,7 @@ async function getIssueBodyAndComments(
)
try {
if (ownerRepo) {
const [issueResult, commentsResult] = await Promise.all([
const [issueResult, commentsResult, timelineItems] = await Promise.all([
ghExecFileAsync(
[
'api',
@@ -498,7 +703,8 @@ async function getIssueBodyAndComments(
`repos/${ownerRepo.owner}/${ownerRepo.repo}/issues/${issueNumber}/comments?per_page=100`
],
ghOptions
)
),
getIssueTimelineItems(ownerRepo, issueNumber, ghOptions)
])
const issue = JSON.parse(issueResult.stdout) as {
body?: string | null
@@ -523,7 +729,7 @@ async function getIssueBodyAndComments(
})
)
const assignees = (issue.assignees ?? []).map((a) => a.login)
return { body: issue.body ?? '', comments, assignees }
return { body: issue.body ?? '', comments, assignees, timelineItems }
}
// Fallback: non-GitHub remote
const { stdout } = await ghExecFileAsync(
@@ -551,9 +757,9 @@ async function getIssueBodyAndComments(
})
)
const fallbackAssignees = (data.assignees ?? []).map((a) => a.login)
return { body: data.body ?? '', comments, assignees: fallbackAssignees }
return { body: data.body ?? '', comments, assignees: fallbackAssignees, timelineItems: [] }
} catch {
return { body: '', comments: [], assignees: [] }
return { body: '', comments: [], assignees: [], timelineItems: [] }
}
}
@@ -802,12 +1008,13 @@ export async function getWorkItemDetails(
body: collapsed.body,
comments: collapsed.comments,
assignees: collapsed.assignees,
participants: collapsed.participants
participants: collapsed.participants,
timelineItems: collapsed.timelineItems
}
}
// Why: fall back to body/comments and GraphQL participants in parallel;
// the mention-participant merge is a cheap local operation afterward.
const [{ body, comments, assignees }, participants] = await Promise.all([
const [{ body, comments, assignees, timelineItems }, participants] = await Promise.all([
getIssueBodyAndComments(repoPath, item.number, connectionId, localGitOptions),
getWorkItemParticipants(repoPath, item, connectionId, localGitOptions)
])
@@ -824,7 +1031,8 @@ export async function getWorkItemDetails(
body,
comments,
assignees,
participants: mentionParticipants
participants: mentionParticipants,
timelineItems
}
}
+6 -1
View File
@@ -22,7 +22,11 @@ let inflightKey: string | null = null
let handlerOptions: AiVaultHandlerOptions = {}
async function listAiVaultSessions(args?: AiVaultListArgs): Promise<AiVaultListResult> {
const key = String(args?.limit ?? 'default')
// Scope paths change the result set, so they must be part of the cache key.
const key = JSON.stringify({
limit: args?.limit ?? 'default',
scopePaths: args?.scopePaths ?? []
})
const now = Date.now()
// Why: opening this panel repeatedly should not re-parse hundreds of JSONL
// transcripts; explicit refreshes bypass the cache but not an active scan.
@@ -40,6 +44,7 @@ async function listAiVaultSessions(args?: AiVaultListArgs): Promise<AiVaultListR
inflightList = (async () =>
scanAiVaultSessions({
limit: args?.limit,
scopePaths: args?.scopePaths,
additionalCodexSessionsDirs,
wslHomeDirs: await getAiVaultWslHomeDirs()
}))()
+16 -2
View File
@@ -81,6 +81,7 @@ import { track } from '../telemetry/client'
import { getCohortAtEmit } from '../telemetry/cohort-classifier'
import type { RepoMethod } from '../../shared/telemetry-events'
import { detectRepoIconAndUpstream } from '../repo-icon-autodetect'
import { enrichMissingRepoGitRemoteIdentities } from '../repo-git-remote-identity-enrichment'
import { getProjectHostSetupForRepo } from '../../shared/project-host-setup-projection'
import { normalizeExecutionHostId, parseExecutionHostId } from '../../shared/execution-host'
import { joinRemotePath } from '../ssh/ssh-remote-platform'
@@ -1137,10 +1138,18 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
ipcMain.removeHandler('sparsePresets:remove')
ipcMain.handle('repos:list', () => {
enrichMissingRepoGitRemoteIdentities(store, {
onChanged: () => notifyReposChanged(mainWindow)
})
return store.getRepos()
})
ipcMain.handle('projects:list', () => store.getProjects())
ipcMain.handle('projects:list', () => {
enrichMissingRepoGitRemoteIdentities(store, {
onChanged: () => notifyReposChanged(mainWindow)
})
return store.getProjects()
})
ipcMain.handle('projects:update', (_event, rawArgs: ProjectUpdateArgs): Project | null => {
const args = parseProjectGroupIpcArgs(
@@ -1151,7 +1160,12 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
return store.updateProject(args.projectId, args.updates)
})
ipcMain.handle('projectHostSetups:list', () => store.getProjectHostSetups())
ipcMain.handle('projectHostSetups:list', () => {
enrichMissingRepoGitRemoteIdentities(store, {
onChanged: () => notifyReposChanged(mainWindow)
})
return store.getProjectHostSetups()
})
ipcMain.handle(
'projectHostSetups:create',
+32
View File
@@ -72,6 +72,7 @@ import {
normalizeProjectRuntimePreference
} from '../shared/project-execution-runtime'
import { projectHostSetupProjectionFromRepos } from '../shared/project-host-setup-projection'
import type { GitRemoteIdentity } from '../shared/git-remote-identity'
import {
buildTaskSourceContextFromRepo,
buildWorkspaceRunContext
@@ -1081,6 +1082,24 @@ function sanitizeRepoUpstream(value: unknown): Repo['upstream'] | undefined {
return owner && repo ? { owner, repo } : undefined
}
function sanitizeGitRemoteIdentity(value: unknown): GitRemoteIdentity | undefined {
if (!value || typeof value !== 'object') {
return undefined
}
const candidate = value as {
canonicalKey?: unknown
remoteName?: unknown
remoteUrl?: unknown
}
const canonicalKey =
typeof candidate.canonicalKey === 'string' ? candidate.canonicalKey.trim() : ''
const remoteName = typeof candidate.remoteName === 'string' ? candidate.remoteName.trim() : ''
const remoteUrl = typeof candidate.remoteUrl === 'string' ? candidate.remoteUrl.trim() : ''
return canonicalKey && remoteName && remoteUrl
? { canonicalKey, remoteName, remoteUrl }
: undefined
}
function sanitizeRepoProjectHostSetupMethod(
value: unknown
): RepoProjectHostSetupMethod | undefined {
@@ -1098,6 +1117,7 @@ function sanitizeRepoUpdatesForPersistence<
| 'badgeColor'
| 'repoIcon'
| 'upstream'
| 'gitRemoteIdentity'
| 'worktreeBasePath'
| 'projectHostSetupMethod'
| 'forkSyncMode'
@@ -1130,6 +1150,14 @@ function sanitizeRepoUpdatesForPersistence<
sanitized.upstream = upstream
}
}
if ('gitRemoteIdentity' in sanitized) {
const gitRemoteIdentity = sanitizeGitRemoteIdentity(sanitized.gitRemoteIdentity)
if (gitRemoteIdentity === undefined) {
delete sanitized.gitRemoteIdentity
} else {
sanitized.gitRemoteIdentity = gitRemoteIdentity
}
}
if ('worktreeBasePath' in sanitized && sanitized.worktreeBasePath !== undefined) {
if (typeof sanitized.worktreeBasePath === 'string') {
sanitized.worktreeBasePath = sanitized.worktreeBasePath.trim() || undefined
@@ -3720,6 +3748,7 @@ export class Store {
| 'badgeColor'
| 'repoIcon'
| 'upstream'
| 'gitRemoteIdentity'
| 'hookSettings'
| 'worktreeBaseRef'
| 'worktreeBasePath'
@@ -3895,6 +3924,7 @@ export class Store {
const {
repoIcon: rawRepoIcon,
upstream: rawUpstream,
gitRemoteIdentity: rawGitRemoteIdentity,
sourceControlAi: rawSourceControlAi,
projectHostSetupMethod: rawProjectHostSetupMethod,
forkSyncMode: rawForkSyncMode,
@@ -3902,6 +3932,7 @@ export class Store {
} = repo
const repoIcon = sanitizeRepoIcon(rawRepoIcon)
const upstream = sanitizeRepoUpstream(rawUpstream)
const gitRemoteIdentity = sanitizeGitRemoteIdentity(rawGitRemoteIdentity)
const sourceControlAi = normalizeRepoSourceControlAiOverrides(rawSourceControlAi)
const projectHostSetupMethod = sanitizeRepoProjectHostSetupMethod(rawProjectHostSetupMethod)
const forkSyncMode = sanitizeForkSyncMode(rawForkSyncMode)
@@ -3918,6 +3949,7 @@ export class Store {
...repoWithoutIcon,
...(repoIcon !== undefined ? { repoIcon } : {}),
...(upstream !== undefined ? { upstream } : {}),
...(gitRemoteIdentity !== undefined ? { gitRemoteIdentity } : {}),
...(sourceControlAi !== undefined ? { sourceControlAi } : {}),
...(projectHostSetupMethod !== undefined ? { projectHostSetupMethod } : {}),
...(forkSyncMode !== undefined ? { forkSyncMode } : {}),
@@ -0,0 +1,137 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { GitRemoteIdentity } from '../shared/git-remote-identity'
import type { Repo } from '../shared/types'
import { detectGitRemoteIdentity } from './repo-git-remote-identity'
import {
enrichMissingRepoGitRemoteIdentities,
flushRepoGitRemoteIdentityEnrichmentForTests,
resetRepoGitRemoteIdentityEnrichmentForTests
} from './repo-git-remote-identity-enrichment'
vi.mock('./repo-git-remote-identity', () => ({
detectGitRemoteIdentity: vi.fn()
}))
type RepoIdentityStore = {
getRepos: () => Repo[]
getRepo: (id: string) => Repo | undefined
updateRepo: (id: string, updates: Pick<Partial<Repo>, 'gitRemoteIdentity'>) => Repo | null
}
const remoteIdentity: GitRemoteIdentity = {
canonicalKey: 'git.company.test/team/sample-app',
remoteName: 'origin',
remoteUrl: 'git@git.company.test:team/sample-app.git'
}
function makeRepo(overrides: Partial<Repo> = {}): Repo {
return {
id: 'repo-1',
path: '/workspace/sample-app',
displayName: 'sample-app',
badgeColor: '#737373',
addedAt: 1,
kind: 'git',
...overrides
}
}
function makeStore(repo: Repo): RepoIdentityStore & { updateRepo: ReturnType<typeof vi.fn> } {
const repos = [repo]
return {
getRepos: () => repos,
getRepo: (id) => repos.find((candidate) => candidate.id === id),
updateRepo: vi.fn((id, updates) => {
const target = repos.find((candidate) => candidate.id === id)
if (!target) {
return null
}
Object.assign(target, updates)
return target
})
}
}
function deferred<T>(): {
promise: Promise<T>
resolve: (value: T) => void
} {
let resolve!: (value: T) => void
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise
})
return { promise, resolve }
}
afterEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
resetRepoGitRemoteIdentityEnrichmentForTests()
})
describe('enrichMissingRepoGitRemoteIdentities', () => {
it('schedules remote identity enrichment without blocking the caller', async () => {
vi.mocked(detectGitRemoteIdentity).mockResolvedValue(remoteIdentity)
const repo = makeRepo()
const store = makeStore(repo)
const onChanged = vi.fn()
enrichMissingRepoGitRemoteIdentities(store, { onChanged })
expect(repo.gitRemoteIdentity).toBeUndefined()
expect(detectGitRemoteIdentity).toHaveBeenCalledWith('/workspace/sample-app', undefined)
await flushRepoGitRemoteIdentityEnrichmentForTests()
expect(repo.gitRemoteIdentity).toEqual(remoteIdentity)
expect(onChanged).toHaveBeenCalledTimes(1)
})
it('coalesces concurrent probes for the same repo location', async () => {
const probe = deferred<GitRemoteIdentity | null>()
vi.mocked(detectGitRemoteIdentity).mockReturnValue(probe.promise)
const repo = makeRepo()
const store = makeStore(repo)
enrichMissingRepoGitRemoteIdentities(store)
enrichMissingRepoGitRemoteIdentities(store)
expect(detectGitRemoteIdentity).toHaveBeenCalledTimes(1)
probe.resolve(remoteIdentity)
await flushRepoGitRemoteIdentityEnrichmentForTests()
expect(store.updateRepo).toHaveBeenCalledTimes(1)
expect(repo.gitRemoteIdentity).toEqual(remoteIdentity)
})
it('caches no-identity probes briefly so list calls do not retry every time', async () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
vi.mocked(detectGitRemoteIdentity).mockResolvedValue(null)
const repo = makeRepo()
const store = makeStore(repo)
enrichMissingRepoGitRemoteIdentities(store)
await flushRepoGitRemoteIdentityEnrichmentForTests()
enrichMissingRepoGitRemoteIdentities(store)
await flushRepoGitRemoteIdentityEnrichmentForTests()
expect(detectGitRemoteIdentity).toHaveBeenCalledTimes(1)
})
it('does not write stale identity data after the repo path changes', async () => {
const probe = deferred<GitRemoteIdentity | null>()
vi.mocked(detectGitRemoteIdentity).mockReturnValue(probe.promise)
const repo = makeRepo()
const store = makeStore(repo)
enrichMissingRepoGitRemoteIdentities(store)
repo.path = '/workspace/renamed-sample-app'
probe.resolve(remoteIdentity)
await flushRepoGitRemoteIdentityEnrichmentForTests()
expect(store.updateRepo).not.toHaveBeenCalled()
expect(repo.gitRemoteIdentity).toBeUndefined()
})
})
@@ -0,0 +1,107 @@
import type { Repo } from '../shared/types'
import { detectGitRemoteIdentity } from './repo-git-remote-identity'
const NO_IDENTITY_RETRY_TTL_MS = 5 * 60 * 1000
type RepoIdentityStore = {
getRepos(): Repo[]
getRepo?(id: string): Repo | undefined
updateRepo(id: string, updates: Pick<Partial<Repo>, 'gitRemoteIdentity'>): Repo | null
}
type EnrichmentOptions = {
onChanged?: () => void
}
const inFlightProbesByLocation = new Map<string, Promise<boolean>>()
const noIdentityRetryAfterByLocation = new Map<string, number>()
function getRepoLocationKey(repo: Pick<Repo, 'path' | 'connectionId'>): string {
return `${repo.connectionId ?? 'local'}\0${repo.path}`
}
function getCurrentRepo(store: RepoIdentityStore, id: string): Repo | undefined {
return store.getRepo?.(id) ?? store.getRepos().find((repo) => repo.id === id)
}
function isSameUnenrichedRepo(snapshot: Repo, current: Repo | undefined): boolean {
return (
!!current &&
current.kind !== 'folder' &&
!current.gitRemoteIdentity &&
current.path === snapshot.path &&
(current.connectionId ?? null) === (snapshot.connectionId ?? null)
)
}
async function enrichRepoGitRemoteIdentity(store: RepoIdentityStore, repo: Repo): Promise<boolean> {
const locationKey = getRepoLocationKey(repo)
const retryAfter = noIdentityRetryAfterByLocation.get(locationKey) ?? 0
if (retryAfter > Date.now()) {
return false
}
const inFlight = inFlightProbesByLocation.get(locationKey)
if (inFlight) {
return inFlight
}
const probe = (async () => {
const identity = await detectGitRemoteIdentity(repo.path, repo.connectionId)
if (!identity) {
// Why: repos without a parseable remote are common; cache misses briefly so
// list calls stay cheap while still allowing recent remote changes to land.
noIdentityRetryAfterByLocation.set(locationKey, Date.now() + NO_IDENTITY_RETRY_TTL_MS)
return false
}
noIdentityRetryAfterByLocation.delete(locationKey)
const current = getCurrentRepo(store, repo.id)
if (!isSameUnenrichedRepo(repo, current)) {
return false
}
return !!store.updateRepo(repo.id, { gitRemoteIdentity: identity })
})().finally(() => {
if (inFlightProbesByLocation.get(locationKey) === probe) {
inFlightProbesByLocation.delete(locationKey)
}
})
inFlightProbesByLocation.set(locationKey, probe)
return probe
}
async function enrichMissingRepoGitRemoteIdentitiesInBackground(
store: RepoIdentityStore,
options: EnrichmentOptions
): Promise<void> {
const candidates = store
.getRepos()
.filter((repo) => repo.kind !== 'folder' && !repo.gitRemoteIdentity)
let changed = false
for (const repo of candidates) {
// Why: enrichment runs later; capture the location we probed so a mutable
// store cannot make the stale-write guard compare against changed fields.
if (await enrichRepoGitRemoteIdentity(store, { ...repo })) {
changed = true
}
}
if (changed) {
options.onChanged?.()
}
}
export function enrichMissingRepoGitRemoteIdentities(
store: RepoIdentityStore,
options: EnrichmentOptions = {}
): void {
void enrichMissingRepoGitRemoteIdentitiesInBackground(store, options).catch((error: unknown) => {
console.error('[repo-identity] Failed to enrich git remote identities:', error)
})
}
export async function flushRepoGitRemoteIdentityEnrichmentForTests(): Promise<void> {
await Promise.all(inFlightProbesByLocation.values())
}
export function resetRepoGitRemoteIdentityEnrichmentForTests(): void {
inFlightProbesByLocation.clear()
noIdentityRetryAfterByLocation.clear()
}
+18
View File
@@ -0,0 +1,18 @@
import { deriveGitRemoteIdentity, type GitRemoteIdentity } from '../shared/git-remote-identity'
import { gitExecFileAsync } from './git/runner'
import { getSshGitProvider } from './providers/ssh-git-dispatch'
export async function detectGitRemoteIdentity(
repoPath: string,
connectionId?: string | null
): Promise<GitRemoteIdentity | null> {
try {
const result = connectionId
? await getSshGitProvider(connectionId)?.exec(['remote', '-v'], repoPath)
: await gitExecFileAsync(['remote', '-v'], { cwd: repoPath })
return result ? deriveGitRemoteIdentity(result.stdout) : null
} catch {
// Repo creation must not fail because a best-effort remote probe failed.
return null
}
}
+23
View File
@@ -168,6 +168,11 @@ describe('detectRepoIcon', () => {
})
await expect(detectRepoIconAndUpstream({ repoPath, kind: 'git' })).resolves.toEqual({
gitRemoteIdentity: {
canonicalKey: 'github.com/stablyai/orca',
remoteName: 'upstream',
remoteUrl: 'git@github.com:stablyai/orca.git'
},
repoIcon: {
type: 'image',
src: 'https://github.com/stablyai.png?size=64',
@@ -177,4 +182,22 @@ describe('detectRepoIcon', () => {
upstream: { owner: 'stablyai', repo: 'orca' }
})
})
it('detects a provider-neutral git remote identity for non-GitHub remotes', async () => {
const repoPath = await makeTempRepoDir()
await gitExecFileAsync(['init'], { cwd: repoPath })
await gitExecFileAsync(
['remote', 'add', 'origin', 'git@git.company.test:platform/tools/sample-app.git'],
{ cwd: repoPath }
)
await expect(detectRepoIconAndUpstream({ repoPath, kind: 'git' })).resolves.toMatchObject({
gitRemoteIdentity: {
canonicalKey: 'git.company.test/platform/tools/sample-app',
remoteName: 'origin',
remoteUrl: 'git@git.company.test:platform/tools/sample-app.git'
},
upstream: null
})
})
})
+7 -7
View File
@@ -9,6 +9,7 @@ import {
import { getRepoSlug, getRepoUpstream } from './github/client'
import { getSshFilesystemProvider } from './providers/ssh-filesystem-dispatch'
import type { IFilesystemProvider } from './providers/types'
import { detectGitRemoteIdentity } from './repo-git-remote-identity'
import { iconHrefCandidates } from './repo-icon-href-candidates'
import { joinWorktreeRelativePath } from './runtime/runtime-relative-paths'
@@ -306,12 +307,8 @@ export async function detectRepoIcon({
return undefined
}
/**
* Detect a repo's icon and its fork upstream together. The upstream is resolved
* once and reused for the avatar so a fork shows the upstream owner's avatar.
* Returns a spread-ready slice of `Repo`. For git repos, `upstream: null`
* is a resolved "not a fork" marker and prevents repeated best-effort probes.
*/
// Why: `upstream: null` is a resolved "not a fork" marker and prevents
// repeated best-effort probes.
export async function detectRepoIconAndUpstream({
repoPath,
kind,
@@ -320,11 +317,14 @@ export async function detectRepoIconAndUpstream({
repoPath: string
kind: RepoKind
connectionId?: string | null
}): Promise<{ repoIcon?: RepoIcon; upstream?: GitHubRepositoryIdentity | null }> {
}) {
const upstream = kind === 'git' ? await getRepoUpstream(repoPath, connectionId) : null
const gitRemoteIdentity =
kind === 'git' ? await detectGitRemoteIdentity(repoPath, connectionId) : null
const repoIcon = await detectRepoIcon({ repoPath, kind, connectionId, upstream })
return {
...(repoIcon ? { repoIcon } : {}),
...(gitRemoteIdentity ? { gitRemoteIdentity } : {}),
...(kind === 'git' ? { upstream: upstream ?? null } : {})
}
}
+13
View File
@@ -643,6 +643,7 @@ import {
} from '../project-groups/folder-workspace-path-status'
import { getSshGitProvider, requireSshGitProvider } from '../providers/ssh-git-dispatch'
import { detectRepoIconAndUpstream } from '../repo-icon-autodetect'
import { enrichMissingRepoGitRemoteIdentities } from '../repo-git-remote-identity-enrichment'
import { githubAvatarIcon } from '../../shared/repo-icon'
import type { ClaudeAccountService } from '../claude-accounts/service'
import type { CodexAccountService } from '../codex-accounts/service'
@@ -8807,6 +8808,18 @@ export class OrcaRuntimeService {
return this.store?.getRepos() ?? []
}
enrichMissingRepoGitRemoteIdentities(): void {
if (!this.store) {
return
}
enrichMissingRepoGitRemoteIdentities(this.store, {
onChanged: () => {
this.invalidateResolvedWorktreeCache()
this.notifyReposChanged()
}
})
}
listProjects(): Project[] {
return this.store?.getProjects?.() ?? []
}
@@ -1,6 +1,7 @@
/* eslint-disable max-lines -- Why: coordinator tests cover dispatch, DAG ordering, escalation, decision gates, concurrency, and stop — splitting by category would scatter shared setup without improving clarity. */
import { afterEach, describe, expect, it } from 'vitest'
import { OrchestrationDb } from './db'
import { reconcileLifecycleMessage } from './lifecycle-reconciliation'
import {
Coordinator,
DISPATCH_STALE_THRESHOLD,
@@ -141,6 +142,68 @@ describe('Coordinator', () => {
expect(runtime.sentMessages.length).toBeGreaterThan(0)
})
it('records completedTasks when send reconciled worker_done before coordinator read', async () => {
db = new OrchestrationDb(':memory:')
const runtime = createMockRuntime()
const task = db.createTask({ spec: 'send-driven completion' })
const dispatch = db.createDispatchContext(task.id, 'term_a')
const msg = db.insertMessage({
from: 'term_a',
to: 'coord',
subject: 'Done',
type: 'worker_done',
payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id })
})
reconcileLifecycleMessage(db, msg)
const coordinator = new Coordinator(db, runtime, {
spec: 'go',
coordinatorHandle: 'coord',
pollIntervalMs: 20
})
const result = await coordinator.run()
expect(result.status).toBe('completed')
expect(result.completedTasks).toContain(task.id)
})
it('does not duplicate completedTasks for repeated completed worker_done messages', async () => {
db = new OrchestrationDb(':memory:')
const runtime = createMockRuntime()
const task = db.createTask({ spec: 'duplicate completion' })
const dispatch = db.createDispatchContext(task.id, 'term_a')
const payload = JSON.stringify({ taskId: task.id, dispatchId: dispatch.id })
const first = db.insertMessage({
from: 'term_a',
to: 'coord',
subject: 'Done',
type: 'worker_done',
payload
})
db.insertMessage({
from: 'term_a',
to: 'coord',
subject: 'Done again',
type: 'worker_done',
payload
})
reconcileLifecycleMessage(db, first)
const coordinator = new Coordinator(db, runtime, {
spec: 'go',
coordinatorHandle: 'coord',
pollIntervalMs: 20
})
const result = await coordinator.run()
expect(result.status).toBe('completed')
expect(result.completedTasks.filter((id) => id === task.id)).toHaveLength(1)
})
it('creates a terminal when none are available', async () => {
db = new OrchestrationDb(':memory:')
const runtime = createMockRuntime()
@@ -277,7 +277,9 @@ export class Coordinator {
private handleLifecycleMessage(msg: MessageRow): void {
const result = reconcileLifecycleMessage(this.db, msg, this.opts.onLog)
if (result.action === 'completed') {
this.state.completedTasks.push(result.taskId)
if (!this.state.completedTasks.includes(result.taskId)) {
this.state.completedTasks.push(result.taskId)
}
}
}
@@ -117,6 +117,11 @@ function reconcileWorkerDoneMessage(
)
return { action: 'ignored' }
}
// Why: `orchestration.send` can release the DB lock before waking the
// coordinator; the later coordinator read still needs to observe completion.
if (dispatch.status === 'completed' && task.status === 'completed') {
return { action: 'completed', taskId, dispatchId }
}
if (dispatch.status !== 'dispatched') {
onLog(`Warning: worker_done for inactive dispatch ${dispatchId} ignored`)
return { action: 'ignored' }
@@ -374,6 +374,71 @@ describe('orchestration RPC methods', () => {
})
).rejects.toThrow('No recipients resolved for group address')
})
it('releases dispatch lock before waking recipients when worker_done is sent via send', async () => {
setup()
const task = db.createTask({ spec: 'lock-release work' })
const dispatch = db.createDispatchContext(task.id, 'term_worker')
// Why: assert lock is already gone at delivery time, not just after the call.
vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {
expect(db.getActiveDispatchForTerminal('term_worker')).toBeUndefined()
})
const result = (await call('orchestration.send', {
from: 'term_worker',
to: 'term_coord',
subject: 'done',
type: 'worker_done',
payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id })
})) as { message: { type: string } }
expect(result.message.type).toBe('worker_done')
expect(db.getTask(task.id)?.status).toBe('completed')
expect(db.getDispatchContextById(dispatch.id)?.status).toBe('completed')
expect(db.getActiveDispatchForTerminal('term_worker')).toBeUndefined()
// Lock released — a new dispatch to the same terminal must succeed.
const t2 = db.createTask({ spec: 'follow-up work' })
expect(() => db.createDispatchContext(t2.id, 'term_worker')).not.toThrow()
})
it('records heartbeat when heartbeat is sent via send', async () => {
setup()
const task = db.createTask({ spec: 'heartbeat work' })
const dispatch = db.createDispatchContext(task.id, 'term_worker')
vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {})
await call('orchestration.send', {
from: 'term_worker',
to: 'term_coord',
subject: 'alive',
type: 'heartbeat',
payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id })
})
expect(db.getTask(task.id)?.status).toBe('dispatched')
expect(db.getDispatchContextById(dispatch.id)?.status).toBe('dispatched')
expect(db.getDispatchContextById(dispatch.id)?.last_heartbeat_at).toBeTruthy()
expect(db.getActiveDispatchForTerminal('term_worker')).toBeDefined()
})
it('does not release dispatch lock for non-lifecycle sends', async () => {
setup()
const task = db.createTask({ spec: 'in-flight work' })
const dispatch = db.createDispatchContext(task.id, 'term_worker')
vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {})
await call('orchestration.send', {
from: 'term_coord',
to: 'term_worker',
subject: 'how is it going?',
type: 'status'
})
expect(db.getTask(task.id)?.status).toBe('dispatched')
expect(db.getDispatchContextById(dispatch.id)?.status).toBe('dispatched')
expect(db.getActiveDispatchForTerminal('term_worker')).toBeDefined()
})
})
describe('orchestration.check', () => {
@@ -197,6 +197,13 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
threadId: params.threadId,
payload: params.payload
})
// Why: worker_done/heartbeat sent via `send` must release the dispatch
// lock before waking recipients — a coordinator woken by delivery may
// immediately dispatch to the same terminal, which fails if the lock
// is still held.
if (msg.type === 'worker_done' || msg.type === 'heartbeat') {
reconcileLifecycleMessage(db, msg)
}
runtime.deliverPendingMessagesForHandle(params.to)
runtime.notifyMessageArrived(params.to, msg.type)
return { message: msg }
@@ -90,7 +90,10 @@ export const PROJECT_RUNTIME_METHODS: RpcMethod[] = [
defineMethod({
name: 'project.list',
params: null,
handler: (_params, { runtime }) => ({ projects: runtime.listProjects() })
handler: (_params, { runtime }) => {
runtime.enrichMissingRepoGitRemoteIdentities?.()
return { projects: runtime.listProjects() }
}
}),
defineMethod({
name: 'project.update',
@@ -102,7 +105,10 @@ export const PROJECT_RUNTIME_METHODS: RpcMethod[] = [
defineMethod({
name: 'projectHostSetup.list',
params: null,
handler: (_params, { runtime }) => ({ setups: runtime.listProjectHostSetups() })
handler: (_params, { runtime }) => {
runtime.enrichMissingRepoGitRemoteIdentities?.()
return { setups: runtime.listProjectHostSetups() }
}
}),
defineMethod({
name: 'projectHostSetup.create',
+4 -1
View File
@@ -158,7 +158,10 @@ export const REPO_METHODS: RpcMethod[] = [
defineMethod({
name: 'repo.list',
params: null,
handler: (_params, { runtime }) => ({ repos: runtime.listRepos() })
handler: (_params, { runtime }) => {
runtime.enrichMissingRepoGitRemoteIdentities?.()
return { repos: runtime.listRepos() }
}
}),
...PROJECT_RUNTIME_METHODS,
defineMethod({
+3 -1
View File
@@ -234,7 +234,9 @@
--worktree-sidebar-foreground: #fafafa;
--worktree-sidebar-accent: #353535;
--worktree-sidebar-accent-foreground: #fafafa;
--worktree-sidebar-border: rgb(255 255 255 / 0.1);
/* Match --border / --sidebar-border (7%) so the left sidebar's divider lines
aren't brighter than the rest of the UI in dark mode (#5906). */
--worktree-sidebar-border: rgb(255 255 255 / 0.07);
--worktree-sidebar-ring: #737373;
--sidebar: #171717;
--sidebar-foreground: #fafafa;
+263 -16
View File
@@ -20,6 +20,7 @@ import {
Ban,
Braces,
Check,
CheckCircle2,
ChevronDown,
ChevronLeft,
ChevronRight,
@@ -33,9 +34,11 @@ import {
GitPullRequest,
GitPullRequestClosed,
ListChecks,
Link2,
LoaderCircle,
MessageSquare,
MessageSquarePlus,
MoveRight,
PanelLeftOpen,
Pencil,
Plus,
@@ -44,6 +47,8 @@ import {
Send,
Settings,
UndoDot,
UserMinus,
UserPlus,
Wrench,
X
} from 'lucide-react'
@@ -140,7 +145,10 @@ import {
type CommentCodeContextLineUpdate
} from '@/components/comment-code-context-state'
import { getPrCommentCodeContext } from '@/components/github/pr-comment-code-context'
import { resolveCommentReplyTarget } from '@/components/comment-reply-target-state'
import {
getCommentReplyTargetCandidates,
resolveCommentReplyTarget
} from '@/components/comment-reply-target-state'
import { useAppStore } from '@/store'
import { useAllWorktrees } from '@/store/selectors'
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
@@ -181,6 +189,8 @@ import type {
GitHubPRFileViewedState,
GitHubWorkItem,
GitHubWorkItemDetails,
GitHubIssueTimelineItem,
GitHubIssueTimelineTarget,
GitHubAssignableUser,
GitHubReaction,
GitHubPRMergeMethod,
@@ -2806,12 +2816,75 @@ function CommentCodeContext({
)
}
type IssueConversationEntry =
| { kind: 'comment'; id: string; createdAt: string; comment: PRComment; index: number }
| {
kind: 'activity'
id: string
createdAt: string
activity: GitHubIssueTimelineItem
index: number
}
const EMPTY_GITHUB_ISSUE_TIMELINE_ITEMS: GitHubIssueTimelineItem[] = []
function getTimelineSortValue(createdAt: string): number {
const value = new Date(createdAt).getTime()
return Number.isFinite(value) ? value : 0
}
function getIssueConversationEntries(
comments: PRComment[],
timelineItems: GitHubIssueTimelineItem[]
): IssueConversationEntry[] {
return [
...comments.map(
(comment, index): IssueConversationEntry => ({
kind: 'comment',
id: `comment:${comment.id}`,
createdAt: comment.createdAt,
comment,
index
})
),
...timelineItems.map(
(activity, index): IssueConversationEntry => ({
kind: 'activity',
id: `activity:${activity.id}`,
createdAt: activity.createdAt,
activity,
index: comments.length + index
})
)
].sort((a, b) => {
const diff = getTimelineSortValue(a.createdAt) - getTimelineSortValue(b.createdAt)
return diff === 0 ? a.index - b.index : diff
})
}
function getTimelineTargetLabel(target: GitHubIssueTimelineTarget): string {
const prefix = target.type === 'pr' ? 'PR' : 'issue'
const title = target.title ? ` ${target.title}` : ''
return `${prefix} #${target.number}${title}`
}
function getTimelineStateReasonLabel(reason: string | null | undefined): string | null {
if (reason === 'completed') {
return translate('auto.components.GitHubItemDialog.timeline.completed', 'as completed')
}
if (reason === 'not_planned') {
return translate('auto.components.GitHubItemDialog.timeline.notPlanned', 'as not planned')
}
return null
}
function ConversationTab({
item,
repoPath,
sourceContext,
body,
comments,
timelineItems,
files,
headSha,
baseSha,
@@ -2833,6 +2906,7 @@ function ConversationTab({
sourceContext?: TaskSourceContext | null
body: string
comments: PRComment[]
timelineItems?: GitHubIssueTimelineItem[]
files: GitHubPRFile[]
headSha: string | undefined
baseSha: string | undefined
@@ -2860,7 +2934,13 @@ function ConversationTab({
[commentFilter, comments]
)
const visibleCommentGroups = useMemo(() => groupPRComments(visibleComments), [visibleComments])
const resolvedReplyingTo = resolveCommentReplyTarget(replyingTo, visibleComments)
const resolvedTimelineItems = timelineItems ?? EMPTY_GITHUB_ISSUE_TIMELINE_ITEMS
const issueConversationEntries = useMemo(
() => getIssueConversationEntries(comments, resolvedTimelineItems),
[comments, resolvedTimelineItems]
)
const replyTargetComments = getCommentReplyTargetCandidates(item.type, comments, visibleComments)
const resolvedReplyingTo = resolveCommentReplyTarget(replyingTo, replyTargetComments)
if (resolvedReplyingTo !== replyingTo) {
// Why: comment filters/refetches can hide the active reply target; clear it
@@ -3192,6 +3272,147 @@ function ConversationTab({
)
}
const renderTimelineTarget = (target: GitHubIssueTimelineTarget | undefined): React.ReactNode => {
if (!target) {
return null
}
return (
<button
key={target.url}
type="button"
className="min-w-0 truncate font-medium text-foreground underline underline-offset-2 hover:text-muted-foreground"
title={getTimelineTargetLabel(target)}
onClick={() => window.api.shell.openUrl(target.url)}
>
{getTimelineTargetLabel(target)}
</button>
)
}
const renderTimelineActivityMessage = (activity: GitHubIssueTimelineItem): React.ReactNode => {
const assignee =
activity.assignee ?? translate('auto.components.GitHubItemDialog.timeline.someone', 'someone')
if (activity.event === 'assigned') {
return (
<>
{translate('auto.components.GitHubItemDialog.timeline.assigned', 'assigned')}{' '}
<span className="font-medium text-foreground">{assignee}</span>
</>
)
}
if (activity.event === 'unassigned') {
return (
<>
{translate('auto.components.GitHubItemDialog.timeline.unassigned', 'unassigned')}{' '}
<span className="font-medium text-foreground">{assignee}</span>
</>
)
}
if (activity.event === 'mentioned' || activity.event === 'cross-referenced') {
return (
<>
{translate('auto.components.GitHubItemDialog.timeline.mentioned', 'mentioned this')}
{activity.source ? (
<>
{' '}
{translate('auto.components.GitHubItemDialog.timeline.in', 'in')}{' '}
{renderTimelineTarget(activity.source)}
</>
) : null}
</>
)
}
if (activity.event === 'closed') {
const stateReason = getTimelineStateReasonLabel(activity.stateReason)
return (
<>
{translate('auto.components.GitHubItemDialog.timeline.closed', 'closed this')}
{stateReason ? ` ${stateReason}` : ''}
{activity.closer ? (
<>
{' '}
{translate('auto.components.GitHubItemDialog.timeline.in', 'in')}{' '}
{renderTimelineTarget(activity.closer)}
</>
) : null}
</>
)
}
if (activity.event === 'reopened') {
return translate('auto.components.GitHubItemDialog.timeline.reopened', 'reopened this')
}
const hasFrom = Boolean(activity.previousColumnName)
const hasTo = Boolean(activity.columnName)
return (
<>
{translate('auto.components.GitHubItemDialog.timeline.moved', 'moved this')}
{hasFrom ? (
<>
{' '}
{translate('auto.components.GitHubItemDialog.timeline.from', 'from')}{' '}
<span className="font-medium text-foreground">{activity.previousColumnName}</span>
</>
) : null}
{hasTo ? (
<>
{' '}
{translate('auto.components.GitHubItemDialog.timeline.to', 'to')}{' '}
<span className="font-medium text-foreground">{activity.columnName}</span>
</>
) : null}
{activity.projectName ? (
<>
{' '}
{translate('auto.components.GitHubItemDialog.timeline.in', 'in')}{' '}
<span className="font-medium text-foreground">{activity.projectName}</span>
</>
) : null}
</>
)
}
const renderTimelineActivity = (activity: GitHubIssueTimelineItem): React.JSX.Element => {
const Icon =
activity.event === 'assigned'
? UserPlus
: activity.event === 'unassigned'
? UserMinus
: activity.event === 'closed'
? CheckCircle2
: activity.event === 'reopened'
? CircleDot
: activity.event === 'moved_columns_in_project'
? MoveRight
: Link2
return (
<div
key={`activity-${activity.id}`}
className="flex min-w-0 items-start gap-3 rounded-md px-1 py-1.5 text-[13px] text-muted-foreground"
>
<span className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full border border-border/50 bg-muted/30 text-muted-foreground">
<Icon className="size-3.5" />
</span>
{activity.actorAvatarUrl ? (
<img src={activity.actorAvatarUrl} alt="" className="mt-1 size-5 shrink-0 rounded-full" />
) : null}
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1">
<span className="font-medium text-foreground">{activity.actor}</span>
<span className="contents">{renderTimelineActivityMessage(activity)}</span>
<span className="text-[12px] text-muted-foreground">
{formatRelativeTime(activity.createdAt)}
</span>
</div>
</div>
</div>
)
}
const renderIssueConversationEntry = (entry: IssueConversationEntry): React.JSX.Element =>
entry.kind === 'comment'
? renderCommentCard(entry.comment)
: renderTimelineActivity(entry.activity)
return (
<div
className={cn(
@@ -3308,13 +3529,19 @@ function ConversationTab({
{detailsLoaded ? (
<>
<div className="flex items-center gap-2 pt-1">
<MessageSquare className="size-4 text-muted-foreground" />
{item.type === 'issue' ? (
<FolderKanban className="size-4 text-muted-foreground" />
) : (
<MessageSquare className="size-4 text-muted-foreground" />
)}
<span className="text-[13px] font-medium text-foreground">
{translate('auto.components.GitHubItemDialog.1506916c09', 'Comments')}
{item.type === 'issue'
? translate('auto.components.GitHubItemDialog.timeline.activity', 'Activity')
: translate('auto.components.GitHubItemDialog.1506916c09', 'Comments')}
</span>
{comments.length > 0 && (
{comments.length + (item.type === 'issue' ? resolvedTimelineItems.length : 0) > 0 && (
<span className="rounded-full border border-border/50 bg-muted/30 px-1.5 py-0.5 text-[11px] tabular-nums text-muted-foreground">
{comments.length}
{comments.length + (item.type === 'issue' ? resolvedTimelineItems.length : 0)}
</span>
)}
</div>
@@ -3342,7 +3569,20 @@ function ConversationTab({
</div>
)}
{comments.length === 0 ? (
{item.type === 'issue' ? (
issueConversationEntries.length === 0 ? (
<div className="rounded-lg border border-dashed border-border/50 px-3 py-6 text-left text-[13px] text-muted-foreground">
{translate(
'auto.components.GitHubItemDialog.timeline.noActivity',
'No activity yet.'
)}
</div>
) : (
<div className="flex min-w-0 flex-col gap-3">
{issueConversationEntries.map(renderIssueConversationEntry)}
</div>
)
) : comments.length === 0 ? (
<div className="rounded-lg border border-dashed border-border/50 px-3 py-6 text-left text-[13px] text-muted-foreground">
{translate('auto.components.GitHubItemDialog.5a94f3d0e9', 'No comments yet.')}
</div>
@@ -6151,15 +6391,6 @@ export default function GitHubItemDialog({
})
}, [repoPath, effectiveRepoId, workItem, issueSourcePreference])
// Why: reset lifted edit state when the dialog switches items or when the
// same item receives an optimistic cache patch from the surrounding table.
useEffect(() => {
if (workItemState && workItemLabels) {
setLocalState(workItemState)
setLocalLabels(workItemLabels)
}
}, [workItemId, workItemState, workItemLabels])
// Why: track comments added optimistically before the detail fetch resolves
// so they can be merged into the fetch result instead of being overwritten.
const optimisticCommentsRef = useRef<PRComment[]>([])
@@ -6257,6 +6488,19 @@ export default function GitHubItemDialog({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cachedEntry, workItem, optimisticTick])
const resolvedWorkItemState = details?.item.state ?? workItemState
// Why: the list row that opens the dialog can be stale; the detail payload
// carries the authoritative issue/PR state and should refresh local edit UI.
useEffect(() => {
if (resolvedWorkItemState) {
setLocalState(resolvedWorkItemState)
}
if (workItemLabels) {
setLocalLabels(workItemLabels)
}
}, [workItemId, resolvedWorkItemState, workItemLabels])
const loading = !!cachedEntry?.pending && !cachedEntry?.details
const error = cachedEntry?.error && !cachedEntry?.details ? cachedEntry.error : null
const detailsLoaded =
@@ -6393,6 +6637,7 @@ export default function GitHubItemDialog({
const body = details?.body ?? ''
const comments = details?.comments ?? []
const timelineItems = details?.timelineItems ?? []
const files = details?.files ?? []
const checks = details?.checks ?? []
const [pendingViewedPaths, setPendingViewedPaths] = useState<Set<string>>(() => new Set())
@@ -6910,6 +7155,7 @@ export default function GitHubItemDialog({
sourceContext={sourceContext}
body={body}
comments={comments}
timelineItems={timelineItems}
files={files}
headSha={details?.headSha}
baseSha={details?.baseSha}
@@ -7030,6 +7276,7 @@ export default function GitHubItemDialog({
sourceContext={sourceContext}
body={body}
comments={comments}
timelineItems={timelineItems}
files={files}
headSha={details?.headSha}
baseSha={details?.baseSha}
@@ -185,8 +185,8 @@ function QuickTabBody({
const primaryActionLabel = isFolderWorkspaceTarget
? getFolderWorkspacePrimaryActionLabel()
: cardProps.selectedRepoIsGit
? 'Create worktree'
: 'Create workspace'
? translate('auto.components.NewWorkspaceComposerModal.createWorktree', 'Create worktree')
: translate('auto.components.NewWorkspaceComposerModal.createWorkspace', 'Create workspace')
// Cmd/Ctrl+Enter submits, Esc first blurs the focused input (like the full page).
useEffect(() => {
@@ -1,5 +1,8 @@
import { describe, expect, it } from 'vitest'
import { resolveCommentReplyTarget } from './comment-reply-target-state'
import {
getCommentReplyTargetCandidates,
resolveCommentReplyTarget
} from './comment-reply-target-state'
describe('comment reply target state', () => {
it('preserves a visible reply target', () => {
@@ -13,4 +16,17 @@ describe('comment reply target state', () => {
it('keeps an empty reply target empty', () => {
expect(resolveCommentReplyTarget(null, [{ id: 1 }])).toBeNull()
})
it('uses filtered comments as reply targets for PRs', () => {
expect(getCommentReplyTargetCandidates('pr', [{ id: 1 }, { id: 2 }], [{ id: 2 }])).toEqual([
{ id: 2 }
])
})
it('uses all comments as reply targets for issues', () => {
expect(getCommentReplyTargetCandidates('issue', [{ id: 1 }, { id: 2 }], [{ id: 2 }])).toEqual([
{ id: 1 },
{ id: 2 }
])
})
})
@@ -2,6 +2,16 @@ export type CommentReplyTargetComment = {
id: number
}
export function getCommentReplyTargetCandidates(
itemType: 'issue' | 'pr',
comments: readonly CommentReplyTargetComment[],
visibleComments: readonly CommentReplyTargetComment[]
): readonly CommentReplyTargetComment[] {
// Why: the PR audience filter is hidden on issues, so stale PR filter state
// should not constrain issue reply targets after switching items.
return itemType === 'issue' ? comments : visibleComments
}
export function resolveCommentReplyTarget(
replyingTo: number | null,
visibleComments: readonly CommentReplyTargetComment[]
@@ -46,7 +46,7 @@ import type {
GitDiffResult,
GitStatusEntry
} from '../../../../shared/types'
import { Check, Copy, MessageSquare, PanelLeftOpen, Sparkles, Trash2 } from 'lucide-react'
import { Check, Copy, MessageSquare, PanelLeftOpen, Sparkles, Trash2, WrapText } from 'lucide-react'
import { toast } from 'sonner'
import { DiffSectionItem } from './DiffSectionItem'
import { DiffNotesSendMenu } from './DiffNotesSendMenu'
@@ -55,10 +55,7 @@ import {
createCombinedDiffSectionIndexMap,
handleCombinedDiffFileTreeNavigation
} from './CombinedDiffFileTree'
import {
getCombinedDiffFileTreeSectionKey,
type CombinedDiffFileTreeMode
} from './combined-diff-file-tree-model'
import { getCombinedDiffFileTreeSectionKey } from './combined-diff-file-tree-model'
import {
ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT,
type EditorPathMutationTarget
@@ -77,6 +74,7 @@ import type { DiffSection } from './diff-section-types'
import { getInitialCombinedDiffSectionLoadIndices } from './combined-diff-initial-section-load'
import { removeDiffSectionMeasuredHeight } from './diff-section-height-cache'
import { createCombinedDiffLoadScheduler } from './combined-diff-load-scheduler'
import { combinedDiffSectionsMatchEntryMetadata } from './combined-diff-section-cache-match'
import {
beginCombinedDiffScrollbarDrag,
type CombinedDiffScrollbarDragCleanup
@@ -210,26 +208,6 @@ function getInitialCombinedDiffFileTreeCollapsed(
return combinedDiffFileTreeCollapsedPreference ?? combinedDiffFileTreeVisibleByDefault !== true
}
function cachedCombinedDiffSectionsMatchEntries({
entries,
sections,
treeMode
}: {
entries: readonly (GitStatusEntry | GitBranchChangeEntry)[]
sections: readonly DiffSection[]
treeMode: CombinedDiffFileTreeMode
}): boolean {
return (
sections.length === entries.length &&
sections.every((section, index) => {
const entry = entries[index]
return (
entry !== undefined && section.key === getCombinedDiffFileTreeSectionKey(treeMode, entry)
)
})
)
}
export default function CombinedDiffViewer({
file,
viewStateKey
@@ -251,6 +229,7 @@ export default function CombinedDiffViewer({
const openCommitDiff = useAppStore((s) => s.openCommitDiff)
const openConflictReview = useAppStore((s) => s.openConflictReview)
const openBranchAllDiffs = useAppStore((s) => s.openBranchAllDiffs)
const updateSettings = useAppStore((s) => s.updateSettings)
const clearDiffComments = useAppStore((s) => s.clearDiffComments)
const diffCommentsForWorktree = useAppStore((s) => s.getDiffComments(file.worktreeId))
const activeGroupId = useAppStore((s) => s.activeGroupIdByWorktree[file.worktreeId])
@@ -540,7 +519,7 @@ export default function CombinedDiffViewer({
const canRestoreSnapshotSectionsByKey =
hasUncommittedEntriesSnapshot &&
cached !== undefined &&
cachedCombinedDiffSectionsMatchEntries({
combinedDiffSectionsMatchEntryMetadata({
entries,
sections: cached.sections,
treeMode
@@ -1129,6 +1108,10 @@ export default function CombinedDiffViewer({
})
}, [])
const toggleDiffWordWrap = useCallback(() => {
void updateSettings({ diffWordWrap: settings?.diffWordWrap !== true })
}, [settings?.diffWordWrap, updateSettings])
const openSection = useCallback(
(index: number) => {
const section = sectionsRef.current[index]
@@ -1838,6 +1821,20 @@ export default function CombinedDiffViewer({
? translate('auto.components.editor.CombinedDiffViewer.f786fd54e1', 'Inline')
: translate('auto.components.editor.CombinedDiffViewer.ec5053c7f5', 'Side by Side')}
</button>
<button
className={`inline-flex h-6 items-center gap-1 rounded border border-border px-2 text-xs transition-colors hover:text-foreground ${
settings?.diffWordWrap === true
? 'bg-accent text-foreground'
: 'text-muted-foreground'
}`}
onClick={toggleDiffWordWrap}
aria-pressed={settings?.diffWordWrap === true}
>
<WrapText className="size-3.5" />
{settings?.diffWordWrap === true
? translate('auto.components.editor.CombinedDiffViewer.a4420ca1f7', 'Wrap On')
: translate('auto.components.editor.CombinedDiffViewer.dde325ddfe', 'Wrap Off')}
</button>
</div>
</div>
@@ -9,6 +9,7 @@ import { combinedDiffSectionScrollbarOptions } from './diff-editor-scrollbar-opt
import type { DiffSection } from './diff-section-types'
import { translate } from '@/i18n/i18n'
import { LargeDiffFallback } from './LargeDiffFallback'
import { buildDiffEditorWordWrapOptions } from './diff-editor-word-wrap-options'
const ImageDiffViewer = lazy(() => import('./ImageDiffViewer'))
@@ -34,6 +35,7 @@ type DiffSectionBodyProps = {
modelPathBase: string
isEditable: boolean
diffEditorFontSize: number
diffWordWrap?: boolean
terminalFontFamily?: string
onCancelComment: () => void
onSubmitComment: (body: string) => Promise<void>
@@ -58,6 +60,7 @@ export function DiffSectionBody({
modelPathBase,
isEditable,
diffEditorFontSize,
diffWordWrap,
terminalFontFamily,
onCancelComment,
onSubmitComment,
@@ -190,6 +193,7 @@ export function DiffSectionBody({
fontSize: diffEditorFontSize,
fontFamily: terminalFontFamily || 'monospace',
lineNumbers: 'on',
...buildDiffEditorWordWrapOptions(diffWordWrap),
automaticLayout: true,
renderOverviewRuler: false,
scrollbar: combinedDiffSectionScrollbarOptions,
@@ -65,7 +65,11 @@ export function DiffSectionItem({
isBranchMode: boolean
sideBySide: boolean
isDark: boolean
settings: { terminalFontSize?: number; terminalFontFamily?: string } | null
settings: {
terminalFontSize?: number
terminalFontFamily?: string
diffWordWrap?: boolean
} | null
sectionHeight: number | undefined
worktreeId?: string
loadSection: (index: number) => void
@@ -421,6 +425,7 @@ export function DiffSectionItem({
modelPathBase={modelPathBase}
isEditable={isEditable}
diffEditorFontSize={diffEditorFontSize}
diffWordWrap={settings?.diffWordWrap}
terminalFontFamily={settings?.terminalFontFamily}
onCancelComment={() => setPopover(null)}
onSubmitComment={handleSubmitComment}
@@ -23,6 +23,7 @@ import { getLargeDiffRenderLimit } from './large-diff-render-limit'
import { useDiffViewerLargeDiffLifecycle } from './useDiffViewerLargeDiffLifecycle'
import { getDiffViewerLargeDiffSaveAction } from './diff-viewer-large-diff-save-action'
import type { DiffViewerProps } from './diff-viewer-props'
import { buildDiffEditorWordWrapOptions } from './diff-editor-word-wrap-options'
export default function DiffViewer({
modelKey,
@@ -450,6 +451,7 @@ export default function DiffViewer({
fontSize: diffEditorFontSize,
fontFamily: settings?.terminalFontFamily || 'monospace',
lineNumbers: 'on',
...buildDiffEditorWordWrapOptions(settings?.diffWordWrap),
automaticLayout: true,
renderOverviewRuler: true,
scrollbar: diffEditorScrollbarOptions,
@@ -83,6 +83,8 @@ export function EditorPanelHeader({
}: EditorPanelHeaderProps): React.JSX.Element {
const diffComments = useAppStore((s) => s.getDiffComments(activeFile.worktreeId))
const activeGroupId = useAppStore((s) => s.activeGroupIdByWorktree[activeFile.worktreeId])
const diffWordWrap = useAppStore((s) => s.settings?.diffWordWrap === true)
const updateSettings = useAppStore((s) => s.updateSettings)
const fileDiffComments = useMemo(
() => diffComments.filter((comment) => comment.filePath === activeFile.relativePath),
[activeFile.relativePath, diffComments]
@@ -246,10 +248,13 @@ export function EditorPanelHeader({
)}
<EditorPanelMarkdownActionsMenu
isMarkdown={isMarkdown}
isDiffSurface={isDiffSurface}
diffWordWrap={diffWordWrap}
shouldShowMarkdownExportAction={shouldShowMarkdownExportAction}
canExportMarkdownToPdf={canExportMarkdownToPdf}
canShowMarkdownFrontmatterToggle={canShowMarkdownFrontmatterToggle}
markdownFrontmatterVisible={markdownFrontmatterVisible}
onToggleDiffWordWrap={() => void updateSettings({ diffWordWrap: !diffWordWrap })}
onToggleMarkdownFrontmatter={onToggleMarkdownFrontmatter}
onExportMarkdownToPdf={onExportMarkdownToPdf}
/>
@@ -2,6 +2,7 @@ import type React from 'react'
import { MoreHorizontal } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
@@ -11,24 +12,32 @@ import { translate } from '@/i18n/i18n'
type EditorPanelMarkdownActionsMenuProps = {
isMarkdown: boolean
isDiffSurface: boolean
diffWordWrap: boolean
shouldShowMarkdownExportAction: boolean
canExportMarkdownToPdf: boolean
canShowMarkdownFrontmatterToggle: boolean
markdownFrontmatterVisible: boolean
onToggleDiffWordWrap: () => void
onToggleMarkdownFrontmatter: () => void
onExportMarkdownToPdf: () => void
}
export function EditorPanelMarkdownActionsMenu({
isMarkdown,
isDiffSurface,
diffWordWrap,
shouldShowMarkdownExportAction,
canExportMarkdownToPdf,
canShowMarkdownFrontmatterToggle,
markdownFrontmatterVisible,
onToggleDiffWordWrap,
onToggleMarkdownFrontmatter,
onExportMarkdownToPdf
}: EditorPanelMarkdownActionsMenuProps): React.JSX.Element | null {
if (!isMarkdown || (!shouldShowMarkdownExportAction && !canShowMarkdownFrontmatterToggle)) {
const hasMarkdownActions =
isMarkdown && (shouldShowMarkdownExportAction || canShowMarkdownFrontmatterToggle)
if (!isDiffSurface && !hasMarkdownActions) {
return null
}
@@ -51,6 +60,17 @@ export function EditorPanelMarkdownActionsMenu({
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={4}>
{isDiffSurface ? (
<>
<DropdownMenuCheckboxItem checked={diffWordWrap} onCheckedChange={onToggleDiffWordWrap}>
{translate(
'auto.components.editor.EditorPanelMarkdownActionsMenu.1eef809708',
'Word Wrap'
)}
</DropdownMenuCheckboxItem>
{hasMarkdownActions ? <DropdownMenuSeparator /> : null}
</>
) : null}
{canShowMarkdownFrontmatterToggle ? (
<>
<DropdownMenuItem
@@ -0,0 +1,87 @@
import { describe, expect, it } from 'vitest'
import type { GitBranchChangeEntry, GitStatusEntry } from '../../../../shared/types'
import type { DiffSection } from './diff-section-types'
import { combinedDiffSectionsMatchEntryMetadata } from './combined-diff-section-cache-match'
function section(overrides: Partial<DiffSection>): DiffSection {
return {
key: 'unstaged:src/file.ts',
path: 'src/file.ts',
status: 'modified',
area: 'unstaged',
originalContent: '',
modifiedContent: '',
collapsed: false,
loading: false,
dirty: false,
diffResult: null,
largeDiffRenderLimit: null,
...overrides
}
}
describe('combinedDiffSectionsMatchEntryMetadata', () => {
it('matches sections when keys and entry metadata still match', () => {
const entry: GitStatusEntry = {
path: 'src/file.ts',
status: 'modified',
area: 'unstaged',
added: 2,
removed: 1
}
expect(
combinedDiffSectionsMatchEntryMetadata({
entries: [entry],
sections: [section({ added: 2, removed: 1 })],
treeMode: 'uncommitted'
})
).toBe(true)
})
it('rejects cached sections when a same-path entry has new line counts', () => {
const entry: GitStatusEntry = {
path: 'src/file.ts',
status: 'modified',
area: 'unstaged',
added: 177,
removed: 175
}
expect(
combinedDiffSectionsMatchEntryMetadata({
entries: [entry],
sections: [section({ added: 2, removed: 1 })],
treeMode: 'uncommitted'
})
).toBe(false)
})
it('keeps branch sections separate when old paths change for the same new path', () => {
const entry: GitBranchChangeEntry = {
path: 'src/new.ts',
oldPath: 'src/old.ts',
status: 'renamed',
added: 4,
removed: 3
}
expect(
combinedDiffSectionsMatchEntryMetadata({
entries: [entry],
sections: [
section({
key: 'combined-branch:src/new.ts',
path: 'src/new.ts',
status: 'renamed',
area: undefined,
oldPath: 'src/older.ts',
added: 4,
removed: 3
})
],
treeMode: 'branch'
})
).toBe(false)
})
})
@@ -0,0 +1,37 @@
import type { GitBranchChangeEntry, GitStatusEntry } from '../../../../shared/types'
import type { CombinedDiffFileTreeMode } from './combined-diff-file-tree-model'
import { getCombinedDiffFileTreeSectionKey } from './combined-diff-file-tree-model'
import type { DiffSection } from './diff-section-types'
export function combinedDiffSectionsMatchEntryMetadata({
entries,
sections,
treeMode
}: {
entries: readonly (GitStatusEntry | GitBranchChangeEntry)[]
sections: readonly DiffSection[]
treeMode: CombinedDiffFileTreeMode
}): boolean {
// Why: same-path combined sections can survive status refreshes; metadata
// drift means restoring cached content would replay stale, partial diffs.
return (
sections.length === entries.length &&
sections.every((section, index) => {
const entry = entries[index]
if (!entry) {
return false
}
const entryArea = 'area' in entry ? entry.area : undefined
const entryAdded = 'added' in entry ? entry.added : undefined
const entryRemoved = 'removed' in entry ? entry.removed : undefined
return (
section.key === getCombinedDiffFileTreeSectionKey(treeMode, entry) &&
section.status === entry.status &&
section.area === entryArea &&
section.oldPath === entry.oldPath &&
section.added === entryAdded &&
section.removed === entryRemoved
)
})
)
}
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest'
import { buildDiffEditorWordWrapOptions } from './diff-editor-word-wrap-options'
describe('buildDiffEditorWordWrapOptions', () => {
it('keeps long diff lines unwrapped by default', () => {
expect(buildDiffEditorWordWrapOptions(undefined)).toEqual({ wordWrap: 'off' })
expect(buildDiffEditorWordWrapOptions(false)).toEqual({ wordWrap: 'off' })
})
it('enables Monaco diff word wrapping when the diff preference is on', () => {
expect(buildDiffEditorWordWrapOptions(true)).toEqual({ wordWrap: 'on' })
})
})
@@ -0,0 +1,9 @@
import type { editor } from 'monaco-editor'
export function buildDiffEditorWordWrapOptions(
diffWordWrap: boolean | undefined
): Pick<editor.IStandaloneDiffEditorConstructionOptions, 'wordWrap'> {
return {
wordWrap: diffWordWrap === true ? 'on' : 'off'
}
}
@@ -23,6 +23,6 @@ export const PLATFORM_COPY: Record<
)
},
ctaLabel: 'Download APK',
url: 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk'
url: 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk'
}
}
@@ -1,10 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { toast } from 'sonner'
import {
buildAiVaultResumeCommandForWorktree,
buildAiVaultResumeStartupForWorktree
} from '@/lib/ai-vault-resume-command'
import { launchAiVaultSessionInNewTab } from '@/lib/launch-ai-vault-session'
import { useAppStore } from '@/store'
import {
useActiveRepo,
@@ -14,23 +9,28 @@ import {
useRepoById,
useRepos
} from '@/store/selectors'
import { filterAiVaultSessions, groupAiVaultSessions } from './ai-vault-session-filters'
import {
agentLabel,
deriveAiVaultWorkspaceScopePaths,
filterAiVaultSessions,
groupAiVaultSessions
} from './ai-vault-session-filters'
deriveAiVaultScopeSessionPaths,
deriveAiVaultWorkspaceScopePaths
} from './ai-vault-scope-paths'
import {
DEFAULT_AI_VAULT_SCOPE,
getRestorableAiVaultScope,
normalizeAiVaultScopeForContext
} from './ai-vault-scope-state'
import { buildAiVaultProjectContext } from './ai-vault-session-projects'
import {
resolveAiVaultSessionResumeActions,
resolveAiVaultSessionResumeState
} from './ai-vault-session-resume'
import { useAiVaultSessionLaunchActions } from './ai-vault-session-launch-actions'
import { useAiVaultSessionWorktreeMap } from './ai-vault-session-worktree'
import { useAiVaultOriginalPaneActions } from './ai-vault-original-pane-actions'
import {
AI_VAULT_AGENTS,
type AiVaultAgent,
type AiVaultGroup,
type AiVaultListResult,
type AiVaultScope,
type AiVaultSession,
type AiVaultSort
@@ -39,8 +39,7 @@ import { getLocalExecutionHostLabel } from '../../../../shared/execution-host'
import { translate } from '@/i18n/i18n'
import { AiVaultPanelHeader } from './AiVaultPanelHeader'
import { AiVaultSessionVirtualList } from './AiVaultSessionVirtualList'
const SESSION_LIMIT = 500
import { useAiVaultSessionRefresh } from './ai-vault-session-refresh'
export default function AiVaultPanel(): React.JSX.Element {
const activeWorktree = useActiveWorktree()
@@ -50,21 +49,16 @@ export default function AiVaultPanel(): React.JSX.Element {
const allWorktrees = useAllWorktrees()
const projectHostSetupProjection = useProjectHostSetupProjection()
const settings = useAppStore((s) => s.settings)
const agentCmdOverrides = settings?.agentCmdOverrides ?? {}
const agentCmdOverrides = settings?.agentCmdOverrides
const { getOriginalPaneTarget, jumpToOriginalPane, jumpToWorktree } =
useAiVaultOriginalPaneActions()
const [query, setQuery] = useState('')
const [scope, setScope] = useState<AiVaultScope>(DEFAULT_AI_VAULT_SCOPE)
const [sort, setSort] = useState<AiVaultSort>('updated')
const [group, setGroup] = useState<AiVaultGroup>('project')
const [hideEmptySessions, setHideEmptySessions] = useState(true)
const [agents, setAgents] = useState<AiVaultAgent[]>([...AI_VAULT_AGENTS])
const [sessions, setSessions] = useState<AiVaultSession[]>([])
const [scanResult, setScanResult] = useState<AiVaultListResult | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(() => new Set())
const refreshIdRef = useRef(0)
const refreshInFlightRef = useRef(false)
const mountedRef = useRef(true)
const userChangedScopeRef = useRef(false)
const preferredScopeRef = useRef<AiVaultScope>(DEFAULT_AI_VAULT_SCOPE)
@@ -75,7 +69,31 @@ export default function AiVaultPanel(): React.JSX.Element {
() => deriveAiVaultWorkspaceScopePaths(activeWorktree ?? null, allWorktrees),
[activeWorktree, allWorktrees]
)
const projectContext = useMemo(
const projectScopeContext = useMemo(
() =>
buildAiVaultProjectContext({
repos,
worktrees: allWorktrees,
projectHostSetupProjection,
activeRepo,
activeWorktree,
sessions: []
}),
[activeRepo, activeWorktree, allWorktrees, projectHostSetupProjection, repos]
)
const activeProjectKey = projectScopeContext.activeProjectKey
const projectLabelByKey = projectScopeContext.projectLabelByKey
// Sent to the scanner so scoped views surface sessions older than the global cap.
const scopePaths = useMemo(
() =>
deriveAiVaultScopeSessionPaths(activeWorktree ?? null, allWorktrees, {
activeProjectKey,
projectHostSetupProjection
}),
[activeProjectKey, activeWorktree, allWorktrees, projectHostSetupProjection]
)
const { error, loading, refresh, scanResult, sessions } = useAiVaultSessionRefresh(scopePaths)
const sessionProjectById = useMemo(
() =>
buildAiVaultProjectContext({
repos,
@@ -84,12 +102,20 @@ export default function AiVaultPanel(): React.JSX.Element {
activeRepo,
activeWorktree,
sessions
}),
}).sessionProjectById,
[activeRepo, activeWorktree, allWorktrees, projectHostSetupProjection, repos, sessions]
)
const activeProjectKey = projectContext.activeProjectKey
const projectLabelByKey = projectContext.projectLabelByKey
const sessionProjectById = projectContext.sessionProjectById
const sessionWorktreeById = useAiVaultSessionWorktreeMap({
sessions,
worktrees: allWorktrees,
activeWorktreeId: activeWorktree?.id ?? null
})
const { buildResumeStartup, copyResumeCommand, handleResume } = useAiVaultSessionLaunchActions({
activeWorktree: activeWorktree ?? null,
allWorktrees,
repos,
agentCmdOverrides
})
const hasAllAgentsSelected = agents.length === AI_VAULT_AGENTS.length
const viewAdjustmentCount =
(hasAllAgentsSelected ? 0 : 1) +
@@ -122,51 +148,6 @@ export default function AiVaultPanel(): React.JSX.Element {
}
}, [activeProjectKey, activeWorktreePath, scope])
const refresh = useCallback(async (args: { force?: boolean } = {}): Promise<void> => {
if (refreshInFlightRef.current) {
return
}
refreshInFlightRef.current = true
const refreshId = refreshIdRef.current + 1
refreshIdRef.current = refreshId
setLoading(true)
setError(null)
try {
const result = await window.api.aiVault.listSessions({
limit: SESSION_LIMIT,
force: args.force
})
if (!mountedRef.current || refreshIdRef.current !== refreshId) {
return
}
setScanResult(result)
setSessions(result.sessions)
} catch (err) {
if (mountedRef.current && refreshIdRef.current === refreshId) {
setError(err instanceof Error ? err.message : String(err))
}
} finally {
refreshInFlightRef.current = false
if (mountedRef.current && refreshIdRef.current === refreshId) {
setLoading(false)
}
}
}, [])
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
refreshIdRef.current += 1
refreshInFlightRef.current = false
}
}, [])
useEffect(() => {
void refresh()
}, [refresh])
const filteredSessions = useMemo(
() =>
filterAiVaultSessions(sessions, {
@@ -203,41 +184,6 @@ export default function AiVaultPanel(): React.JSX.Element {
[filteredSessions, group, projectLabelByKey, sessionProjectById]
)
const buildResumeCommand = useCallback(
(session: AiVaultSession): string =>
buildAiVaultResumeCommandForWorktree({
state: useAppStore.getState(),
worktreeId: activeWorktree?.id ?? null,
session,
commandOverride: agentCmdOverrides[session.agent]
}),
[activeWorktree?.id, agentCmdOverrides, settings]
)
const buildResumeStartup = useCallback(
(session: AiVaultSession) =>
buildAiVaultResumeStartupForWorktree({
state: useAppStore.getState(),
worktreeId: activeWorktree?.id ?? null,
session,
commandOverride: agentCmdOverrides[session.agent]
}),
[activeWorktree?.id, agentCmdOverrides, settings]
)
const copyResumeCommand = useCallback(
async (session: AiVaultSession): Promise<void> => {
await window.api.ui.writeClipboardText(buildResumeCommand(session))
toast.success(
translate(
'auto.components.right.sidebar.AiVaultPanel.resumeCommandCopied',
'Resume command copied'
)
)
},
[buildResumeCommand]
)
const copyText = useCallback(async (text: string, label: string): Promise<void> => {
await window.api.ui.writeClipboardText(text)
toast.success(
@@ -247,40 +193,26 @@ export default function AiVaultPanel(): React.JSX.Element {
)
}, [])
const handleResume = useCallback(
(session: AiVaultSession): void => {
if (!activeWorktree) {
toast.error(
translate(
'auto.components.right.sidebar.AiVaultPanel.openWorkspaceBeforeResuming',
'Open a workspace before resuming a session.'
)
)
return
}
if (isRemoteWorktree) {
toast.error(
translate(
'auto.components.right.sidebar.AiVaultPanel.localWorkspacesOnly',
'Resume from history is only available in local workspaces.'
)
)
return
}
launchAiVaultSessionInNewTab({
agent: session.agent,
worktreeId: activeWorktree.id,
...buildResumeStartup(session)
})
toast.success(
translate(
'auto.components.right.sidebar.AiVaultPanel.agentSessionQueued',
'{{value0}} session queued',
{ value0: agentLabel(session.agent) }
)
)
},
[activeWorktree, buildResumeStartup, isRemoteWorktree]
const getSessionResumeState = useCallback(
(session: AiVaultSession) =>
resolveAiVaultSessionResumeState({
worktreeInfo: sessionWorktreeById.get(session.id) ?? null,
activeWorktreeId: activeWorktree?.id ?? null,
worktrees: allWorktrees,
repos
}),
[activeWorktree?.id, allWorktrees, repos, sessionWorktreeById]
)
const getSessionResumeActions = useCallback(
(session: AiVaultSession) =>
resolveAiVaultSessionResumeActions({
worktreeInfo: sessionWorktreeById.get(session.id) ?? null,
activeWorktreeId: activeWorktree?.id ?? null,
worktrees: allWorktrees,
repos
}),
[activeWorktree?.id, allWorktrees, repos, sessionWorktreeById]
)
const setAgentEnabled = useCallback((agent: AiVaultAgent, enabled: boolean) => {
@@ -377,11 +309,16 @@ export default function AiVaultPanel(): React.JSX.Element {
sessionsCount={sessions.length}
filteredSessionsCount={filteredSessions.length}
error={error}
resumeDisabled={!activeWorktree || isRemoteWorktree}
buildResumeStartup={buildResumeStartup}
getSessionResumeState={getSessionResumeState}
getSessionResumeActions={getSessionResumeActions}
getOriginalPaneTarget={getOriginalPaneTarget}
getWorktreeInfo={(session) => sessionWorktreeById.get(session.id) ?? null}
onToggleGroup={toggleGroup}
onJumpToOriginalPane={jumpToOriginalPane}
onJumpToWorktree={jumpToWorktree}
onResume={handleResume}
onCopyResume={(session) => void copyResumeCommand(session)}
onCopyResume={(session, worktreeId) => void copyResumeCommand(session, worktreeId)}
onCopyId={(session) =>
void copyText(
session.sessionId,
@@ -1,33 +1,47 @@
import type React from 'react'
import { Copy, Play } from 'lucide-react'
import { FileJson, FolderGit2, MessageSquare, Play } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { AgentIcon } from '@/lib/agent-catalog'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
import { agentLabel } from './ai-vault-session-filters'
import { translate } from '@/i18n/i18n'
import { recentSessionConversationTurns } from './ai-vault-session-display'
import { sessionDetailConversationTurns } from './ai-vault-session-display'
import {
aiVaultWorktreeCompactPath,
aiVaultWorktreeStatusLabel,
shouldShowAiVaultWorktreeStatusBadge,
type AiVaultSessionWorktreeInfo
} from './ai-vault-session-worktree'
export function SessionInlineDetails({
id,
session,
onResume,
onCopyResume,
resumeDisabled
worktreeInfo,
resumeActions,
onResumeInWorktree,
onResumeInNewTab,
onOpenLog
}: {
id: string
session: AiVaultSession
onResume: () => void
onCopyResume: () => void
resumeDisabled: boolean
worktreeInfo: AiVaultSessionWorktreeInfo | null
resumeActions: {
worktree: { worktreeId: string | null; disabled: boolean }
newTab: { worktreeId: string | null; disabled: boolean }
}
onResumeInWorktree: () => void
onResumeInNewTab: () => void
onOpenLog: () => void
}): React.JSX.Element {
const updatedAt = session.updatedAt ?? session.modifiedAt
const recentTurns = recentSessionConversationTurns(session, 3)
const showResumeInWorktree = Boolean(resumeActions.worktree.worktreeId)
const showResumeInNewTab = !showResumeInWorktree || Boolean(resumeActions.newTab.worktreeId)
const detailTurns = sessionDetailConversationTurns(session, 3)
const worktreeDisplay = worktreeInfo
return (
<div
id={id}
className="mt-2 rounded-md border border-sidebar-border bg-sidebar-accent/25 p-2.5"
className="mt-2 overflow-hidden rounded-lg border border-sidebar-border/80 bg-background/50 shadow-xs"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
@@ -36,83 +50,88 @@ export function SessionInlineDetails({
event.stopPropagation()
}}
>
<div className="space-y-2.5">
<SessionReceiptSection
label={translate(
'auto.components.right.sidebar.AiVaultSessionDetails.originalAsk',
'Original ask'
)}
>
<div className="min-w-0 break-words text-[12px] font-medium leading-4 text-foreground [overflow-wrap:anywhere]">
{session.title}
</div>
</SessionReceiptSection>
<div className="space-y-3 p-3">
<SessionReceiptSection
icon={<MessageSquare className="size-3" />}
label={translate(
'auto.components.right.sidebar.AiVaultSessionDetails.latestTurns',
'Latest turns'
)}
>
{recentTurns.length > 0 ? (
<div className="grid gap-1.5">
{recentTurns.map((turn, index) => (
<div
{detailTurns.length > 0 ? (
<div className="space-y-1.5">
{detailTurns.map((turn, index) => (
<ConversationTurnCard
key={`${turn.timestamp ?? 'turn'}-${index}`}
className="grid min-w-0 grid-cols-[3.25rem_minmax(0,1fr)] gap-2 text-[11px] leading-4"
>
<span className="text-muted-foreground">{conversationRoleLabel(turn.role)}</span>
<span className="line-clamp-3 text-foreground/90">{turn.text}</span>
</div>
role={turn.role}
text={turn.text}
/>
))}
</div>
) : (
<div className="text-[11px] leading-4 text-muted-foreground">
{translate(
<SessionDetailEmptyState
message={translate(
'auto.components.right.sidebar.AiVaultSessionDetails.noPreviewAvailable',
'No conversation preview available'
)}
</div>
/>
)}
</SessionReceiptSection>
<div className="flex min-w-0 items-center gap-1.5 text-[11px] leading-4 text-muted-foreground">
<span className="flex size-4 shrink-0 items-center justify-center text-muted-foreground">
<AgentIcon agent={session.agent} size={14} />
</span>
<span className="min-w-0 truncate">{agentLabel(session.agent)}</span>
<span className="shrink-0 text-muted-foreground/55">·</span>
<span className="shrink-0">
{translate(
'auto.components.right.sidebar.AiVaultSessionDetails.messageCount',
'{{value0}} msgs',
{ value0: session.messageCount }
{worktreeDisplay ? (
<SessionReceiptSection
icon={<FolderGit2 className="size-3" />}
label={translate(
'auto.components.right.sidebar.AiVaultSessionDetails.worktree',
'Worktree'
)}
</span>
<span className="shrink-0 text-muted-foreground/55">·</span>
<SessionTime value={updatedAt} />
</div>
>
<WorktreeMetadataLines worktreeInfo={worktreeDisplay} />
</SessionReceiptSection>
) : null}
</div>
<div className="mt-2 grid gap-1">
<Button
type="button"
variant="secondary"
size="xs"
disabled={resumeDisabled}
draggable={false}
onClick={(event) => {
event.stopPropagation()
onResume()
}}
className="h-7 justify-start px-2 text-[11px]"
>
<Play className="size-3.5" />
{translate(
'auto.components.right.sidebar.AiVaultSessionDetails.resumeInNewTab',
'Resume in New Tab'
)}
</Button>
<div className="flex flex-wrap items-center gap-1.5 border-t border-sidebar-border/80 bg-sidebar-accent/15 px-3 py-2">
{showResumeInWorktree ? (
<Button
type="button"
variant="default"
size="xs"
disabled={resumeActions.worktree.disabled}
draggable={false}
onClick={(event) => {
event.stopPropagation()
onResumeInWorktree()
}}
className="h-7 shrink-0 px-2.5 text-[11px]"
>
<Play className="size-3.5" />
{translate(
'auto.components.right.sidebar.AiVaultSessionDetails.resumeInWorktree',
'Resume in Worktree'
)}
</Button>
) : null}
{showResumeInNewTab ? (
<Button
type="button"
variant={showResumeInWorktree ? 'secondary' : 'default'}
size="xs"
disabled={resumeActions.newTab.disabled}
draggable={false}
onClick={(event) => {
event.stopPropagation()
onResumeInNewTab()
}}
className="h-7 shrink-0 px-2.5 text-[11px]"
>
<Play className="size-3.5" />
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.resumeInNewTab',
'Resume in New Tab'
)}
</Button>
) : null}
<Button
type="button"
variant="ghost"
@@ -120,15 +139,12 @@ export function SessionInlineDetails({
draggable={false}
onClick={(event) => {
event.stopPropagation()
onCopyResume()
onOpenLog()
}}
className="h-7 justify-start px-2 text-[11px]"
className="h-7 shrink-0 px-2.5 text-[11px] text-muted-foreground"
>
<Copy className="size-3.5" />
{translate(
'auto.components.right.sidebar.AiVaultSessionDetails.copyResumeCommand',
'Copy Resume Command'
)}
<FileJson className="size-3.5" />
{translate('auto.components.right.sidebar.AiVaultSessionDetails.viewLog', 'View Log')}
</Button>
</div>
</div>
@@ -136,18 +152,110 @@ export function SessionInlineDetails({
}
function SessionReceiptSection({
icon,
label,
children
}: {
icon: React.ReactNode
label: string
children: React.ReactNode
}): React.JSX.Element {
return (
<div className="grid gap-1">
<div className="text-[10px] font-semibold uppercase leading-3 text-muted-foreground">
{label}
<section className="space-y-1.5">
<div className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
<span className="text-muted-foreground/80">{icon}</span>
<span>{label}</span>
</div>
{children}
</section>
)
}
function ConversationTurnCard({
role,
text
}: {
role: AiVaultSession['previewMessages'][number]['role']
text: string
}): React.JSX.Element {
const isUserTurn = role === 'user'
return (
<div
className={cn(
'rounded-md border px-2.5 py-2',
isUserTurn
? 'border-border/70 bg-foreground/[0.04]'
: 'border-sidebar-border/70 bg-sidebar-accent/25'
)}
>
<div className="mb-1 text-[10px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
{conversationRoleLabel(role)}
</div>
<p className="line-clamp-4 text-[12px] leading-[1.35] text-foreground/90 [overflow-wrap:anywhere]">
{text}
</p>
</div>
)
}
function WorktreeMetadataLines({
worktreeInfo
}: {
worktreeInfo: AiVaultSessionWorktreeInfo
}): React.JSX.Element {
const compactPath = aiVaultWorktreeCompactPath(worktreeInfo.path)
const pathLine =
compactPath && compactPath !== worktreeInfo.label ? compactPath : worktreeInfo.path
const showPathLine = Boolean(pathLine) && pathLine !== worktreeInfo.label
return (
<div className="grid min-w-0 gap-1 text-[11px] leading-4">
<div className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5">
{shouldShowAiVaultWorktreeStatusBadge(worktreeInfo.status) ? (
<>
<span className="shrink-0 text-[10px] font-medium uppercase tracking-[0.04em] text-muted-foreground">
{aiVaultWorktreeStatusLabel(worktreeInfo.status)}
</span>
<span className="shrink-0 text-muted-foreground/45">·</span>
</>
) : null}
<span className="min-w-0 text-[12px] font-medium leading-4 text-foreground">
{worktreeInfo.label}
</span>
</div>
{showPathLine ? (
<WorktreePathHint compactPath={pathLine} fullPath={worktreeInfo.path} />
) : null}
</div>
)
}
function WorktreePathHint({
compactPath,
fullPath
}: {
compactPath: string
fullPath: string
}): React.JSX.Element {
return (
<Tooltip>
<TooltipTrigger asChild>
<div className="min-w-0 truncate font-mono text-[11px] leading-4 text-muted-foreground">
{compactPath}
</div>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4} className="max-w-sm break-all font-mono text-xs">
{fullPath}
</TooltipContent>
</Tooltip>
)
}
function SessionDetailEmptyState({ message }: { message: string }): React.JSX.Element {
return (
<div className="rounded-md border border-dashed border-sidebar-border/80 px-2.5 py-2 text-[11px] leading-4 text-muted-foreground">
{message}
</div>
)
}
@@ -1,6 +1,6 @@
import { useCallback } from 'react'
import type React from 'react'
import { Copy, FileJson, FolderOpen, Play } from 'lucide-react'
import { Copy, FileJson, FolderOpen, LocateFixed, PanelTopOpen, Play } from 'lucide-react'
import { DropdownMenuItem, DropdownMenuSeparator } from '@/components/ui/dropdown-menu'
import {
ContextMenu,
@@ -9,8 +9,13 @@ import {
ContextMenuSeparator,
ContextMenuTrigger
} from '@/components/ui/context-menu'
import { Badge } from '@/components/ui/badge'
import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel'
import { AgentIcon } from '@/lib/agent-catalog'
import { cn } from '@/lib/utils'
import { useRepoById } from '@/store/selectors'
import { resolveRepoBadgeColor } from '../../../../shared/repo-badge-color'
import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree-id'
import {
AI_VAULT_SESSION_DRAG_START_EVENT,
writeAiVaultSessionDragData
@@ -22,14 +27,27 @@ import { translate } from '@/i18n/i18n'
import { SessionInlineDetails, SessionTime } from './AiVaultSessionDetails'
import { latestSessionConversationTurn } from './ai-vault-session-display'
import { SessionRowTrailingActions } from './SessionRowTrailingActions'
import type { AiVaultSessionResumeActions } from './ai-vault-session-resume'
import {
aiVaultWorktreeStatusLabel,
shouldShowAiVaultWorktreeStatusBadge,
type AiVaultSessionWorktreeInfo
} from './ai-vault-session-worktree'
export function VaultSessionRow({
session,
resumeStartup,
worktreeInfo,
detailsExpanded,
resumeDisabled,
onToggleDetails,
onJumpToOriginalPane,
onJumpToWorktree,
onResume,
resumeLabel,
resumeActions,
onResumeInWorktree,
onResumeInNewTab,
onCopyResume,
onCopyId,
onCopyPath,
@@ -39,10 +57,17 @@ export function VaultSessionRow({
}: {
session: AiVaultSession
resumeStartup: AiVaultResumeStartup
worktreeInfo: AiVaultSessionWorktreeInfo | null
detailsExpanded: boolean
resumeDisabled: boolean
onToggleDetails: () => void
onJumpToOriginalPane?: () => void
onJumpToWorktree?: () => void
onResume: () => void
resumeLabel: string
resumeActions: AiVaultSessionResumeActions
onResumeInWorktree: () => void
onResumeInNewTab: () => void
onCopyResume: () => void
onCopyId: () => void
onCopyPath: () => void
@@ -80,7 +105,10 @@ export function VaultSessionRow({
<ContextMenu>
<ContextMenuTrigger asChild className="block w-full min-w-0">
<div
className="group/session-row flex min-h-[82px] w-full min-w-0 cursor-pointer flex-col border-b border-sidebar-border px-3 py-2 text-left transition-colors hover:bg-sidebar-accent/55"
className={cn(
'group/session-row flex w-full min-w-0 cursor-pointer flex-col border-b border-sidebar-border px-3 py-2 text-left transition-colors hover:bg-sidebar-accent/55',
!detailsExpanded && 'min-h-[98px]'
)}
onClick={() => {
onToggleDetails()
}}
@@ -89,7 +117,7 @@ export function VaultSessionRow({
<div
className={cn(
'min-w-0 text-[13px] font-medium leading-5 text-foreground',
detailsExpanded ? 'break-words [overflow-wrap:anywhere]' : 'line-clamp-1'
detailsExpanded ? 'line-clamp-2 [overflow-wrap:anywhere]' : 'line-clamp-1'
)}
>
{session.title}
@@ -100,7 +128,11 @@ export function VaultSessionRow({
detailsId={detailsId}
detailsTooltip={detailsTooltip}
resumeDisabled={resumeDisabled}
resumeLabel={resumeLabel}
worktreeInfo={worktreeInfo}
onToggleDetails={onToggleDetails}
onJumpToOriginalPane={onJumpToOriginalPane}
onJumpToWorktree={onJumpToWorktree}
onResume={onResume}
onCopyResume={onCopyResume}
onCopyId={onCopyId}
@@ -111,29 +143,44 @@ export function VaultSessionRow({
onStartResumeDrag={startResumeDrag}
/>
</div>
<div className="mt-0.5 min-w-0 line-clamp-2 text-[12px] leading-4 text-muted-foreground">
{latestTurn ? (
<>
<span className="font-medium text-foreground/80">
{conversationRoleLabel(latestTurn.role)}
</span>
<span>: {latestTurn.text}</span>
</>
) : (
translate(
'auto.components.right.sidebar.AiVaultSessionRow.noPreviewAvailable',
'No conversation preview available'
)
)}
</div>
<SessionMetadata session={session} updatedAt={updatedAt} />
{detailsExpanded && worktreeInfo ? (
<div className="mt-1">
<SessionWorktreeLine worktreeInfo={worktreeInfo} />
</div>
) : null}
{!detailsExpanded ? (
<>
<div className="mt-0.5 min-w-0 line-clamp-2 text-[12px] leading-4 text-muted-foreground">
{latestTurn ? (
<>
<span className="font-medium text-foreground/80">
{conversationRoleLabel(latestTurn.role)}
</span>
<span>: {latestTurn.text}</span>
</>
) : (
translate(
'auto.components.right.sidebar.AiVaultSessionRow.noPreviewAvailable',
'No conversation preview available'
)
)}
</div>
<SessionMetadata
session={session}
updatedAt={updatedAt}
worktreeInfo={worktreeInfo}
/>
</>
) : null}
{detailsExpanded ? (
<SessionInlineDetails
id={detailsId}
session={session}
resumeDisabled={resumeDisabled}
onResume={onResume}
onCopyResume={onCopyResume}
worktreeInfo={worktreeInfo}
resumeActions={resumeActions}
onResumeInWorktree={onResumeInWorktree}
onResumeInNewTab={onResumeInNewTab}
onOpenLog={onOpenLog}
/>
) : null}
</div>
@@ -142,6 +189,9 @@ export function VaultSessionRow({
<SessionActionMenuItems
menuKind="context"
resumeDisabled={resumeDisabled}
resumeLabel={resumeLabel}
onJumpToOriginalPane={onJumpToOriginalPane}
onJumpToWorktree={onJumpToWorktree}
onResume={onResume}
onCopyResume={onCopyResume}
onCopyId={onCopyId}
@@ -158,7 +208,10 @@ export function VaultSessionRow({
export function SessionActionMenuItems({
menuKind = 'dropdown',
resumeDisabled,
resumeLabel,
onResume,
onJumpToOriginalPane,
onJumpToWorktree,
onCopyResume,
onCopyId,
onCopyPath,
@@ -168,7 +221,10 @@ export function SessionActionMenuItems({
}: {
menuKind?: 'dropdown' | 'context'
resumeDisabled: boolean
resumeLabel: string
onResume: () => void
onJumpToOriginalPane?: () => void
onJumpToWorktree?: () => void
onCopyResume: () => void
onCopyId: () => void
onCopyPath: () => void
@@ -181,12 +237,25 @@ export function SessionActionMenuItems({
return (
<>
{onJumpToOriginalPane ? (
<Item onSelect={onJumpToOriginalPane}>
<LocateFixed className="size-3.5" />
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.jumpToOriginalPane',
'Jump to Original Pane'
)}
</Item>
) : null}
<Item disabled={!onJumpToWorktree} onSelect={onJumpToWorktree}>
<PanelTopOpen className="size-3.5" />
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.jumpToWorktree',
'Jump to Worktree'
)}
</Item>
<Item disabled={resumeDisabled} onSelect={onResume}>
<Play className="size-3.5" />
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.resumeInNewTab',
'Resume in New Tab'
)}
{resumeLabel}
</Item>
<Item onSelect={onCopyResume}>
<Copy className="size-3.5" />
@@ -231,26 +300,74 @@ function getSessionDetailsId(sessionId: string): string {
return `ai-vault-session-details-${sessionId.replace(/[^A-Za-z0-9_-]/g, '-')}`
}
function SessionMetadata({ session, updatedAt }: { session: AiVaultSession; updatedAt: string }) {
function SessionMetadata({
session,
updatedAt,
worktreeInfo
}: {
session: AiVaultSession
updatedAt: string
worktreeInfo: AiVaultSessionWorktreeInfo | null
}) {
return (
<div className="mt-1 flex min-w-0 items-center gap-1.5 text-[11px] leading-4 text-muted-foreground">
<span className="flex size-4 shrink-0 items-center justify-center text-muted-foreground">
<AgentIcon agent={session.agent} size={14} />
</span>
<span className="min-w-0 truncate">{agentLabel(session.agent)}</span>
<span className="shrink-0 rounded-sm border border-sidebar-border bg-sidebar-accent/45 px-1.5 py-0.5 text-[10px] leading-none text-muted-foreground">
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.messageCount',
'{{value0}} msgs',
{ value0: session.messageCount }
)}
</span>
<span className="shrink-0 text-muted-foreground/55">·</span>
<SessionTime value={updatedAt} />
<div className="mt-1 grid min-w-0 gap-0.5 text-[11px] leading-4 text-muted-foreground">
<div className="flex min-w-0 items-center gap-1.5">
<span className="flex size-4 shrink-0 items-center justify-center text-muted-foreground">
<AgentIcon agent={session.agent} size={14} />
</span>
<span className="min-w-0 truncate">{agentLabel(session.agent)}</span>
<span className="shrink-0 tabular-nums">
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.messageCount',
'{{value0}} msgs',
{ value0: session.messageCount }
)}
</span>
<span className="shrink-0 text-muted-foreground/55">·</span>
<SessionTime value={updatedAt} />
</div>
{worktreeInfo ? <SessionWorktreeLine worktreeInfo={worktreeInfo} /> : null}
</div>
)
}
function SessionWorktreeLine({
worktreeInfo
}: {
worktreeInfo: AiVaultSessionWorktreeInfo
}): React.JSX.Element {
const repoId = worktreeInfo.worktreeId
? (splitWorktreeIdForFilesystem(worktreeInfo.worktreeId)?.repoId ?? null)
: null
const repo = useRepoById(repoId)
return (
<div className="flex min-w-0 items-center gap-1.5 pl-5">
{shouldShowAiVaultWorktreeStatusBadge(worktreeInfo.status) ? (
<span className="shrink-0 rounded-sm border border-sidebar-border bg-sidebar-accent/45 px-1.5 py-0.5 text-[10px] leading-none text-muted-foreground">
{worktreeStatusLabel(worktreeInfo.status)}
</span>
) : null}
<Badge
variant="outline"
className="h-5 max-w-full gap-1 border-border/70 bg-background px-1.5 py-0 text-[11px] font-medium"
title={worktreeInfo.label}
>
<RepoBadgeLabel
name={worktreeInfo.label}
color={resolveRepoBadgeColor(repo?.badgeColor)}
className="min-w-0 max-w-full"
badgeClassName="size-1.5"
/>
</Badge>
</div>
)
}
function worktreeStatusLabel(status: AiVaultSessionWorktreeInfo['status']): string {
return aiVaultWorktreeStatusLabel(status)
}
function conversationRoleLabel(role: AiVaultSession['previewMessages'][number]['role']): string {
if (role === 'user') {
return translate('auto.components.right.sidebar.AiVaultSessionRow.userRole', 'You')
@@ -8,6 +8,16 @@ import { getActiveStickyHeaderIndexForScroll } from '../sidebar/worktree-list-vi
import { EmptyState, SessionLoadingState, VaultGroupHeader } from './AiVaultPanelControls'
import { VaultSessionRow } from './AiVaultSessionRow'
import type { AiVaultSessionGroup } from './ai-vault-session-filters'
import type { AiVaultOriginalPaneTarget } from './ai-vault-original-pane'
import {
aiVaultSessionResumeLabel,
type AiVaultSessionResumeActions,
type AiVaultSessionResumeState
} from './ai-vault-session-resume'
import {
canJumpToAiVaultSessionWorktree,
type AiVaultSessionWorktreeInfo
} from './ai-vault-session-worktree'
import {
extractVaultVirtualRowIndexes,
getVaultStickyHeaderIndexes,
@@ -16,7 +26,7 @@ import {
} from './ai-vault-virtual-rows'
const VAULT_ROW_OVERSCAN = 8
const VAULT_EXPANDED_SESSION_ROW_ESTIMATED_HEIGHT = 360
const VAULT_EXPANDED_SESSION_ROW_ESTIMATED_HEIGHT = 420
type AiVaultListRow =
| { type: 'group'; group: AiVaultSessionGroup }
@@ -29,9 +39,14 @@ export function AiVaultSessionVirtualList({
sessionsCount,
filteredSessionsCount,
error,
resumeDisabled,
buildResumeStartup,
getOriginalPaneTarget,
getWorktreeInfo,
getSessionResumeState,
getSessionResumeActions,
onToggleGroup,
onJumpToOriginalPane,
onJumpToWorktree,
onResume,
onCopyResume,
onCopyId,
@@ -46,11 +61,16 @@ export function AiVaultSessionVirtualList({
sessionsCount: number
filteredSessionsCount: number
error: string | null
resumeDisabled: boolean
buildResumeStartup: (session: AiVaultSession) => AiVaultResumeStartup
buildResumeStartup: (session: AiVaultSession, worktreeId?: string | null) => AiVaultResumeStartup
getOriginalPaneTarget: (session: AiVaultSession) => AiVaultOriginalPaneTarget | null
getWorktreeInfo: (session: AiVaultSession) => AiVaultSessionWorktreeInfo | null
getSessionResumeState: (session: AiVaultSession) => AiVaultSessionResumeState
getSessionResumeActions: (session: AiVaultSession) => AiVaultSessionResumeActions
onToggleGroup: (key: string) => void
onResume: (session: AiVaultSession) => void
onCopyResume: (session: AiVaultSession) => void
onJumpToOriginalPane: (session: AiVaultSession) => void
onJumpToWorktree: (worktreeId: string) => void
onResume: (session: AiVaultSession, worktreeId: string) => void
onCopyResume: (session: AiVaultSession, worktreeId?: string | null) => void
onCopyId: (session: AiVaultSession) => void
onCopyPath: (session: AiVaultSession) => void
onOpenLog: (session: AiVaultSession) => void
@@ -163,10 +183,15 @@ export function AiVaultSessionVirtualList({
measureElement={virtualizer.measureElement}
collapsedGroups={collapsedGroups}
expandedSessionIds={expandedSessionIds}
resumeDisabled={resumeDisabled}
buildResumeStartup={buildResumeStartup}
getOriginalPaneTarget={getOriginalPaneTarget}
getWorktreeInfo={getWorktreeInfo}
getSessionResumeState={getSessionResumeState}
getSessionResumeActions={getSessionResumeActions}
onToggleGroup={onToggleGroup}
onToggleSessionDetails={toggleSessionDetails}
onJumpToOriginalPane={onJumpToOriginalPane}
onJumpToWorktree={onJumpToWorktree}
onResume={onResume}
onCopyResume={onCopyResume}
onCopyId={onCopyId}
@@ -190,10 +215,15 @@ function AiVaultVirtualRow({
measureElement,
collapsedGroups,
expandedSessionIds,
resumeDisabled,
buildResumeStartup,
getOriginalPaneTarget,
getWorktreeInfo,
getSessionResumeState,
getSessionResumeActions,
onToggleGroup,
onToggleSessionDetails,
onJumpToOriginalPane,
onJumpToWorktree,
onResume,
onCopyResume,
onCopyId,
@@ -209,12 +239,17 @@ function AiVaultVirtualRow({
measureElement: (node: Element | null) => void
collapsedGroups: ReadonlySet<string>
expandedSessionIds: ReadonlySet<string>
resumeDisabled: boolean
buildResumeStartup: (session: AiVaultSession) => AiVaultResumeStartup
buildResumeStartup: (session: AiVaultSession, worktreeId?: string | null) => AiVaultResumeStartup
getOriginalPaneTarget: (session: AiVaultSession) => AiVaultOriginalPaneTarget | null
getWorktreeInfo: (session: AiVaultSession) => AiVaultSessionWorktreeInfo | null
getSessionResumeState: (session: AiVaultSession) => AiVaultSessionResumeState
getSessionResumeActions: (session: AiVaultSession) => AiVaultSessionResumeActions
onToggleGroup: (key: string) => void
onToggleSessionDetails: (sessionId: string) => void
onResume: (session: AiVaultSession) => void
onCopyResume: (session: AiVaultSession) => void
onJumpToOriginalPane: (session: AiVaultSession) => void
onJumpToWorktree: (worktreeId: string) => void
onResume: (session: AiVaultSession, worktreeId: string) => void
onCopyResume: (session: AiVaultSession, worktreeId?: string | null) => void
onCopyId: (session: AiVaultSession) => void
onCopyPath: (session: AiVaultSession) => void
onOpenLog: (session: AiVaultSession) => void
@@ -226,6 +261,14 @@ function AiVaultVirtualRow({
}
const isActiveStickyHeader = row.type === 'group' && activeStickyHeaderIndex === index
const originalPaneTarget = row.type === 'session' ? getOriginalPaneTarget(row.session) : null
const worktreeInfo = row.type === 'session' ? getWorktreeInfo(row.session) : null
const worktreeJumpId = canJumpToAiVaultSessionWorktree(worktreeInfo)
? worktreeInfo?.worktreeId
: null
const resumeState = row.type === 'session' ? getSessionResumeState(row.session) : null
const resumeActions = row.type === 'session' ? getSessionResumeActions(row.session) : null
const resumeLabel = resumeState ? aiVaultSessionResumeLabel(resumeState) : ''
return (
<div
@@ -246,12 +289,38 @@ function AiVaultVirtualRow({
) : (
<VaultSessionRow
session={row.session}
resumeStartup={buildResumeStartup(row.session)}
resumeStartup={buildResumeStartup(row.session, resumeState?.worktreeId)}
worktreeInfo={worktreeInfo}
detailsExpanded={expandedSessionIds.has(row.session.id)}
resumeDisabled={resumeDisabled}
resumeDisabled={resumeState?.blocked ?? true}
resumeLabel={resumeLabel}
resumeActions={
resumeActions ?? {
worktree: { worktreeId: null, disabled: true },
newTab: { worktreeId: null, disabled: true }
}
}
onToggleDetails={() => onToggleSessionDetails(row.session.id)}
onResume={() => onResume(row.session)}
onCopyResume={() => onCopyResume(row.session)}
onJumpToOriginalPane={
originalPaneTarget ? () => onJumpToOriginalPane(row.session) : undefined
}
onJumpToWorktree={worktreeJumpId ? () => onJumpToWorktree(worktreeJumpId) : undefined}
onResume={() => {
if (resumeState?.worktreeId) {
onResume(row.session, resumeState.worktreeId)
}
}}
onResumeInWorktree={() => {
if (resumeActions?.worktree.worktreeId) {
onResume(row.session, resumeActions.worktree.worktreeId)
}
}}
onResumeInNewTab={() => {
if (resumeActions?.newTab.worktreeId) {
onResume(row.session, resumeActions.newTab.worktreeId)
}
}}
onCopyResume={() => onCopyResume(row.session, resumeState?.worktreeId)}
onCopyId={() => onCopyId(row.session)}
onCopyPath={() => onCopyPath(row.session)}
onOpenLog={() => onOpenLog(row.session)}
@@ -1,6 +1,13 @@
import { useCallback, useEffect, useRef } from 'react'
import type React from 'react'
import { ChevronDown, GripVertical, MoreHorizontal, Play } from 'lucide-react'
import {
ChevronDown,
GripVertical,
LocateFixed,
MoreHorizontal,
PanelTopOpen,
Play
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import {
@@ -14,6 +21,10 @@ import type { AiVaultSession } from '../../../../shared/ai-vault-types'
import { agentLabel } from './ai-vault-session-filters'
import { translate } from '@/i18n/i18n'
import { SessionActionMenuItems } from './AiVaultSessionRow'
import {
aiVaultWorktreeJumpTooltip,
type AiVaultSessionWorktreeInfo
} from './ai-vault-session-worktree'
// Why: hover-only actions live on the title row and collapse on hover-capable
// devices so the prompt keeps the full width until the row is hovered.
@@ -36,7 +47,11 @@ export function SessionRowTrailingActions({
detailsId,
detailsTooltip,
resumeDisabled,
resumeLabel,
worktreeInfo,
onToggleDetails,
onJumpToOriginalPane,
onJumpToWorktree,
onResume,
onCopyResume,
onCopyId,
@@ -51,7 +66,11 @@ export function SessionRowTrailingActions({
detailsId: string
detailsTooltip: string
resumeDisabled: boolean
resumeLabel: string
worktreeInfo: AiVaultSessionWorktreeInfo | null
onToggleDetails: () => void
onJumpToOriginalPane?: () => void
onJumpToWorktree?: () => void
onResume: () => void
onCopyResume: () => void
onCopyId: () => void
@@ -77,6 +96,8 @@ export function SessionRowTrailingActions({
window.dispatchEvent(new Event(AI_VAULT_SESSION_DRAG_END_EVENT))
}, [])
const jumpToWorktreeTooltip = aiVaultWorktreeJumpTooltip(worktreeInfo)
// Cleanup drag state on unmount if a drag was in progress
useEffect(() => {
return () => {
@@ -126,17 +147,53 @@ export function SessionRowTrailingActions({
)}
</TooltipContent>
</Tooltip>
{onJumpToOriginalPane ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={translate(
'auto.components.right.sidebar.AiVaultSessionRow.jumpToOriginalPane',
'Jump to Original Pane'
)}
draggable={false}
onClick={(event) => {
event.stopPropagation()
onJumpToOriginalPane()
}}
data-testid="ai-vault-session-jump-original-pane"
className="can-hover:pointer-events-none group-hover/session-row:pointer-events-auto group-focus-within/session-row:pointer-events-auto focus-visible:pointer-events-auto"
>
<LocateFixed className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.jumpToOriginalPane',
'Jump to Original Pane'
)}
</TooltipContent>
</Tooltip>
) : null}
<Tooltip>
<WorktreeJumpTooltipTrigger
disabled={!onJumpToWorktree}
ariaLabel={jumpToWorktreeTooltip}
onJumpToWorktree={onJumpToWorktree}
/>
<TooltipContent side="top" sideOffset={4}>
{jumpToWorktreeTooltip}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={translate(
'auto.components.right.sidebar.AiVaultSessionRow.resumeAgentSession',
'Resume {{value0}} session',
{ value0: agentLabel(session.agent) }
)}
aria-label={resumeLabel}
disabled={resumeDisabled}
draggable={false}
onClick={(event) => {
@@ -153,10 +210,7 @@ export function SessionRowTrailingActions({
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.resumeInNewTab',
'Resume in New Tab'
)}
{resumeLabel}
</TooltipContent>
</Tooltip>
</div>
@@ -219,7 +273,10 @@ export function SessionRowTrailingActions({
<DropdownMenuContent align="end">
<SessionActionMenuItems
resumeDisabled={resumeDisabled}
resumeLabel={resumeLabel}
onResume={onResume}
onJumpToOriginalPane={onJumpToOriginalPane}
onJumpToWorktree={onJumpToWorktree}
onCopyResume={onCopyResume}
onCopyId={onCopyId}
onCopyPath={onCopyPath}
@@ -232,3 +289,50 @@ export function SessionRowTrailingActions({
</div>
)
}
function WorktreeJumpTooltipTrigger({
disabled,
ariaLabel,
onJumpToWorktree
}: {
disabled: boolean
ariaLabel: string
onJumpToWorktree?: () => void
}): React.JSX.Element {
const button = (
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={ariaLabel}
disabled={disabled}
draggable={false}
onClick={(event) => {
event.stopPropagation()
onJumpToWorktree?.()
}}
data-testid="ai-vault-session-jump-worktree"
className={cn(
!disabled &&
'can-hover:pointer-events-none group-hover/session-row:pointer-events-auto group-focus-within/session-row:pointer-events-auto focus-visible:pointer-events-auto'
)}
>
<PanelTopOpen className="size-3.5" />
</Button>
)
if (!disabled) {
return <TooltipTrigger asChild>{button}</TooltipTrigger>
}
return (
<TooltipTrigger asChild>
<span
className="inline-flex can-hover:pointer-events-none group-hover/session-row:pointer-events-auto group-focus-within/session-row:pointer-events-auto"
onClick={(event) => event.stopPropagation()}
>
{button}
</span>
</TooltipTrigger>
)
}
@@ -6501,7 +6501,9 @@ export function CommitArea({
aria-describedby={describedBy || undefined}
// Why: reserve right padding so typed text does not slide under the
// absolute-positioned Generate icon in the top-right corner.
className={`mt-0.5 min-h-14 w-full resize-none rounded-md border border-border bg-background px-2 py-1.5 text-xs text-foreground outline-none placeholder:text-muted-foreground/70 focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 ${
// Why: match Input surface tokens and pin disabled:border-input so
// Chromium's UA disabled styles don't wash out the field outline.
className={`mt-0.5 min-h-14 w-full resize-none appearance-none rounded-md border border-input bg-background shadow-xs px-2 py-1.5 text-xs text-foreground outline-none placeholder:text-muted-foreground/70 focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:border-input disabled:bg-background disabled:text-foreground disabled:shadow-xs dark:bg-input/30 dark:disabled:bg-input/30 ${
showGenerate ? 'pr-8' : ''
}`}
/>
@@ -0,0 +1,74 @@
import { useCallback } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { toast } from 'sonner'
import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { useAppStore } from '@/store'
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
import { translate } from '@/i18n/i18n'
import { findOriginalAiVaultSessionPane } from './ai-vault-original-pane'
export function useAiVaultOriginalPaneActions(): {
getOriginalPaneTarget: (
session: AiVaultSession
) => ReturnType<typeof findOriginalAiVaultSessionPane>
jumpToOriginalPane: (session: AiVaultSession) => void
jumpToWorktree: (worktreeId: string) => void
} {
const originalPaneLookupState = useAppStore(
useShallow((s) => ({
agentStatusByPaneKey: s.agentStatusByPaneKey,
retainedAgentsByPaneKey: s.retainedAgentsByPaneKey,
sleepingAgentSessionsByPaneKey: s.sleepingAgentSessionsByPaneKey,
tabsByWorktree: s.tabsByWorktree,
terminalLayoutsByTabId: s.terminalLayoutsByTabId
}))
)
const getOriginalPaneTarget = useCallback(
(session: AiVaultSession) => findOriginalAiVaultSessionPane(originalPaneLookupState, session),
[originalPaneLookupState]
)
const jumpToOriginalPane = useCallback((session: AiVaultSession): void => {
const target = findOriginalAiVaultSessionPane(useAppStore.getState(), session)
if (!target) {
toast.error(
translate(
'auto.components.right.sidebar.AiVaultPanel.originalPaneUnavailable',
'Original pane is no longer available.'
)
)
return
}
if (!activateAndRevealWorktree(target.worktreeId)) {
toast.error(
translate(
'auto.components.right.sidebar.AiVaultPanel.worktreeUnavailable',
'Worktree is no longer available.'
)
)
return
}
const state = useAppStore.getState()
state.setActiveTabType('terminal')
activateTabAndFocusPane(target.tabId, target.leafId, {
flashFocusedPane: true,
scrollToBottomIfOutputSinceLastView: true
})
}, [])
const jumpToWorktree = useCallback((worktreeId: string): void => {
if (!activateAndRevealWorktree(worktreeId)) {
toast.error(
translate(
'auto.components.right.sidebar.AiVaultPanel.worktreeUnavailable',
'Worktree is no longer available.'
)
)
}
}, [])
return { getOriginalPaneTarget, jumpToOriginalPane, jumpToWorktree }
}
@@ -0,0 +1,213 @@
import { describe, expect, it } from 'vitest'
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import { findOriginalAiVaultSessionPane } from './ai-vault-original-pane'
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
const OTHER_LEAF_ID = '22222222-2222-4222-8222-222222222222'
const baseSession: AiVaultSession = {
id: 'codex:session-1',
agent: 'codex',
sessionId: 'session-1',
title: 'Fix the pane focus',
cwd: '/repo',
branch: null,
model: null,
filePath: '/home/ada/.codex/session-1.jsonl',
codexHome: null,
createdAt: null,
updatedAt: '2026-06-24T10:00:00.000Z',
modifiedAt: '2026-06-24T10:00:00.000Z',
messageCount: 2,
totalTokens: 42,
previewMessages: [],
resumeCommand: "codex resume 'session-1'"
}
function makeTab(id = 'tab-1', worktreeId = 'wt-1') {
return {
id,
ptyId: null,
worktreeId,
title: 'Agent',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
}
function makeLayout(leafId = LEAF_ID) {
return {
root: { type: 'leaf' as const, leafId },
activeLeafId: leafId,
expandedLeafId: null,
ptyIdsByLeafId: { [leafId]: 'pty-1' }
}
}
function makeState(overrides: Record<string, unknown> = {}) {
return {
agentStatusByPaneKey: {},
retainedAgentsByPaneKey: {},
sleepingAgentSessionsByPaneKey: {},
tabsByWorktree: { 'wt-1': [makeTab()] },
terminalLayoutsByTabId: { 'tab-1': makeLayout() },
...overrides
} as never
}
function makeEntry(overrides: Partial<AgentStatusEntry> = {}): AgentStatusEntry {
const paneKey = makePaneKey('tab-1', LEAF_ID)
return {
state: 'working',
prompt: 'continue',
updatedAt: 1,
stateStartedAt: 1,
agentType: 'codex',
paneKey,
tabId: 'tab-1',
worktreeId: 'wt-1',
stateHistory: [],
providerSession: { key: 'session_id', id: 'session-1' },
...overrides
}
}
function makeSleepingRecord(
overrides: Partial<SleepingAgentSessionRecord> = {}
): SleepingAgentSessionRecord {
return {
paneKey: makePaneKey('tab-1', LEAF_ID),
tabId: 'tab-1',
worktreeId: 'wt-1',
agent: 'codex',
providerSession: { key: 'session_id', id: 'session-1' },
prompt: 'continue',
state: 'working',
capturedAt: 1,
updatedAt: 1,
origin: 'live',
...overrides
}
}
describe('findOriginalAiVaultSessionPane', () => {
it('finds a live pane with a matching provider session', () => {
const entry = makeEntry()
const target = findOriginalAiVaultSessionPane(
makeState({ agentStatusByPaneKey: { [entry.paneKey]: entry } }),
baseSession
)
expect(target).toEqual({
paneKey: entry.paneKey,
worktreeId: 'wt-1',
tabId: 'tab-1',
leafId: LEAF_ID
})
})
it('finds a unique live pane by prompt when provider session is not known yet', () => {
const entry = makeEntry({
providerSession: undefined,
prompt: 'Fix the pane focus'
})
const target = findOriginalAiVaultSessionPane(
makeState({ agentStatusByPaneKey: { [entry.paneKey]: entry } }),
baseSession
)
expect(target?.paneKey).toBe(entry.paneKey)
})
it('does not prompt-match when multiple panes are plausible', () => {
const firstPaneKey = makePaneKey('tab-1', LEAF_ID)
const secondPaneKey = makePaneKey('tab-2', OTHER_LEAF_ID)
const first = makeEntry({ paneKey: firstPaneKey, providerSession: undefined })
const second = makeEntry({
paneKey: secondPaneKey,
tabId: 'tab-2',
providerSession: undefined
})
const target = findOriginalAiVaultSessionPane(
makeState({
agentStatusByPaneKey: {
[first.paneKey]: first,
[second.paneKey]: second
},
tabsByWorktree: { 'wt-1': [makeTab('tab-1'), makeTab('tab-2')] },
terminalLayoutsByTabId: {
'tab-1': makeLayout(LEAF_ID),
'tab-2': makeLayout(OTHER_LEAF_ID)
}
}),
baseSession
)
expect(target).toBeNull()
})
it('does not return a target when the original leaf is gone', () => {
const entry = makeEntry()
const target = findOriginalAiVaultSessionPane(
makeState({
agentStatusByPaneKey: { [entry.paneKey]: entry },
terminalLayoutsByTabId: { 'tab-1': makeLayout(OTHER_LEAF_ID) }
}),
baseSession
)
expect(target).toBeNull()
})
it('finds a retained completed pane when the tab and layout still exist', () => {
const entry = makeEntry({ state: 'done' })
const target = findOriginalAiVaultSessionPane(
makeState({
retainedAgentsByPaneKey: {
[entry.paneKey]: {
entry,
worktreeId: 'wt-1',
tab: makeTab(),
agentType: 'codex',
startedAt: 1
}
}
}),
baseSession
)
expect(target?.leafId).toBe(LEAF_ID)
})
it('finds a preserved sleeping pane from a matching session record', () => {
const record = makeSleepingRecord()
const target = findOriginalAiVaultSessionPane(
makeState({ sleepingAgentSessionsByPaneKey: { [record.paneKey]: record } }),
baseSession
)
expect(target?.paneKey).toBe(record.paneKey)
})
it('resolves legacy numeric pane keys through the current tab layout', () => {
const record = makeSleepingRecord({ paneKey: 'tab-1:1' })
const target = findOriginalAiVaultSessionPane(
makeState({ sleepingAgentSessionsByPaneKey: { [record.paneKey]: record } }),
baseSession
)
expect(target?.leafId).toBe(LEAF_ID)
})
})
@@ -0,0 +1,262 @@
import type { AppState } from '@/store/types'
import { resolveRuntimePaneTitleLeafId } from '@/lib/runtime-pane-title-leaf-id'
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../../shared/stable-pane-id'
import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode } from '../../../../shared/types'
export type AiVaultOriginalPaneTarget = {
paneKey: string
worktreeId: string
tabId: string
leafId: string
}
type OriginalPaneState = Pick<
AppState,
| 'agentStatusByPaneKey'
| 'retainedAgentsByPaneKey'
| 'sleepingAgentSessionsByPaneKey'
| 'tabsByWorktree'
| 'terminalLayoutsByTabId'
>
function agentMatches(session: AiVaultSession, agent: string | undefined): boolean {
return agent === session.agent
}
function providerSessionMatches(session: AiVaultSession, providerSessionId: string | undefined) {
return providerSessionId === session.sessionId
}
function normalizeMatchText(value: string | null | undefined): string {
return value?.trim().replace(/\s+/g, ' ').toLowerCase() ?? ''
}
function longEnoughForPrefixMatch(value: string): boolean {
return value.length >= 24
}
function textMatchesSessionPrompt(sessionText: string, candidateText: string): boolean {
if (!sessionText || !candidateText) {
return false
}
if (sessionText === candidateText) {
return true
}
return (
longEnoughForPrefixMatch(sessionText) &&
longEnoughForPrefixMatch(candidateText) &&
(sessionText.startsWith(candidateText) || candidateText.startsWith(sessionText))
)
}
function sessionPromptCandidates(session: AiVaultSession): string[] {
const candidates = new Set<string>()
const title = normalizeMatchText(session.title)
if (title) {
candidates.add(title)
}
for (const message of session.previewMessages) {
if (message.role !== 'user') {
continue
}
const text = normalizeMatchText(message.text)
if (text) {
candidates.add(text)
}
}
return [...candidates]
}
function entryPromptCandidates(entry: {
prompt?: string
stateHistory?: readonly { prompt: string }[]
}): string[] {
const candidates = new Set<string>()
const prompt = normalizeMatchText(entry.prompt)
if (prompt) {
candidates.add(prompt)
}
for (const historyEntry of entry.stateHistory ?? []) {
const text = normalizeMatchText(historyEntry.prompt)
if (text) {
candidates.add(text)
}
}
return [...candidates]
}
function promptsMatchSession(
session: AiVaultSession,
entry: Parameters<typeof entryPromptCandidates>[0]
) {
const sessionCandidates = sessionPromptCandidates(session)
if (sessionCandidates.length === 0) {
return false
}
const entryCandidates = entryPromptCandidates(entry)
return sessionCandidates.some((sessionText) =>
entryCandidates.some((entryText) => textMatchesSessionPrompt(sessionText, entryText))
)
}
function layoutHasLeaf(node: TerminalPaneLayoutNode | null | undefined, leafId: string): boolean {
if (!node) {
return false
}
if (node.type === 'leaf') {
return node.leafId === leafId
}
return layoutHasLeaf(node.first, leafId) || layoutHasLeaf(node.second, leafId)
}
function hasAvailableLeaf(layout: TerminalLayoutSnapshot | undefined, leafId: string): boolean {
return layoutHasLeaf(layout?.root, leafId) || Boolean(layout?.ptyIdsByLeafId?.[leafId])
}
function getTabOwnerWorktreeId(
state: OriginalPaneState,
tabId: string,
worktreeIdHint?: string
): string | null {
if (
worktreeIdHint &&
(state.tabsByWorktree[worktreeIdHint] ?? []).some((tab) => tab.id === tabId)
) {
return worktreeIdHint
}
for (const [worktreeId, tabs] of Object.entries(state.tabsByWorktree)) {
if (tabs.some((tab) => tab.id === tabId)) {
return worktreeId
}
}
return null
}
function resolveOriginalPaneTarget(args: {
state: OriginalPaneState
paneKey: string
worktreeIdHint?: string
tabIdHint?: string
}): AiVaultOriginalPaneTarget | null {
const { state, paneKey, worktreeIdHint, tabIdHint } = args
const stable = parsePaneKey(paneKey)
if (stable) {
if (tabIdHint && tabIdHint !== stable.tabId) {
return null
}
const worktreeId = getTabOwnerWorktreeId(state, stable.tabId, worktreeIdHint)
if (
!worktreeId ||
!hasAvailableLeaf(state.terminalLayoutsByTabId[stable.tabId], stable.leafId)
) {
return null
}
return { paneKey, worktreeId, tabId: stable.tabId, leafId: stable.leafId }
}
const legacy = parseLegacyNumericPaneKey(paneKey)
if (!legacy || (tabIdHint && tabIdHint !== legacy.tabId)) {
return null
}
const worktreeId = getTabOwnerWorktreeId(state, legacy.tabId, worktreeIdHint)
if (!worktreeId) {
return null
}
const layout = state.terminalLayoutsByTabId[legacy.tabId]
const leafId = resolveRuntimePaneTitleLeafId(layout, legacy.numericPaneId)
if (!leafId || !hasAvailableLeaf(layout, leafId)) {
return null
}
return { paneKey, worktreeId, tabId: legacy.tabId, leafId }
}
export function findOriginalAiVaultSessionPane(
state: OriginalPaneState,
session: AiVaultSession
): AiVaultOriginalPaneTarget | null {
const promptMatchedTargets: AiVaultOriginalPaneTarget[] = []
for (const entry of Object.values(state.agentStatusByPaneKey)) {
if (
agentMatches(session, entry.agentType) &&
providerSessionMatches(session, entry.providerSession?.id)
) {
const target = resolveOriginalPaneTarget({
state,
paneKey: entry.paneKey,
worktreeIdHint: entry.worktreeId,
tabIdHint: entry.tabId
})
if (target) {
return target
}
}
if (
agentMatches(session, entry.agentType) &&
entry.providerSession === undefined &&
promptsMatchSession(session, entry)
) {
const target = resolveOriginalPaneTarget({
state,
paneKey: entry.paneKey,
worktreeIdHint: entry.worktreeId,
tabIdHint: entry.tabId
})
if (target) {
promptMatchedTargets.push(target)
}
}
}
for (const retained of Object.values(state.retainedAgentsByPaneKey)) {
if (
agentMatches(session, retained.agentType) &&
providerSessionMatches(session, retained.entry.providerSession?.id)
) {
const target = resolveOriginalPaneTarget({
state,
paneKey: retained.entry.paneKey,
worktreeIdHint: retained.worktreeId,
tabIdHint: retained.entry.tabId ?? retained.tab.id
})
if (target) {
return target
}
}
if (
agentMatches(session, retained.agentType) &&
retained.entry.providerSession === undefined &&
promptsMatchSession(session, retained.entry)
) {
const target = resolveOriginalPaneTarget({
state,
paneKey: retained.entry.paneKey,
worktreeIdHint: retained.worktreeId,
tabIdHint: retained.entry.tabId ?? retained.tab.id
})
if (target) {
promptMatchedTargets.push(target)
}
}
}
for (const record of Object.values(state.sleepingAgentSessionsByPaneKey)) {
if (
agentMatches(session, record.agent) &&
providerSessionMatches(session, record.providerSession.id)
) {
const target = resolveOriginalPaneTarget({
state,
paneKey: record.paneKey,
worktreeIdHint: record.worktreeId,
tabIdHint: record.tabId
})
if (target) {
return target
}
}
}
return promptMatchedTargets.length === 1 ? promptMatchedTargets[0] : null
}
@@ -0,0 +1,128 @@
import {
isRuntimePathAbsolute,
normalizeRuntimePathForComparison
} from '../../../../shared/cross-platform-path'
import type { ProjectHostSetupProjection } from '../../../../shared/project-host-setup-projection'
import type { Worktree } from '../../../../shared/types'
import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree-id'
export function deriveAiVaultWorkspaceScopePaths(
activeWorktree: Pick<Worktree, 'id' | 'path' | 'priorWorktreeIds' | 'repoId'> | null,
liveWorktrees: readonly Pick<Worktree, 'id' | 'path' | 'repoId'>[] = []
): string[] {
if (!activeWorktree) {
return []
}
const paths: string[] = []
addAiVaultWorkspaceScopePath(paths, activeWorktree.path)
for (const priorWorktreeId of activeWorktree.priorWorktreeIds ?? []) {
const parsed = splitWorktreeIdForFilesystem(priorWorktreeId)
if (!parsed || parsed.repoId !== activeWorktree.repoId) {
continue
}
if (isAiVaultWorkspaceScopePathClaimed(parsed.worktreePath, activeWorktree, liveWorktrees)) {
continue
}
addAiVaultWorkspaceScopePath(paths, parsed.worktreePath)
}
return paths
}
/**
* Paths sent to the scanner so a scoped panel view surfaces its own sessions
* even when they are older than the global recency cap. Covers the active
* workspace plus the active project's other worktrees (same repo), so both the
* Workspace and Project scopes stay complete.
*/
export function deriveAiVaultScopeSessionPaths(
activeWorktree: Pick<
Worktree,
'id' | 'path' | 'priorWorktreeIds' | 'projectId' | 'repoId'
> | null,
liveWorktrees: readonly Pick<Worktree, 'id' | 'path' | 'projectId' | 'repoId'>[] = [],
options: {
activeProjectKey?: string | null
projectHostSetupProjection?: ProjectHostSetupProjection
} = {}
): string[] {
const paths = deriveAiVaultWorkspaceScopePaths(activeWorktree, liveWorktrees)
if (!activeWorktree) {
return paths
}
const setupsByRepoId = buildProjectSetupsByRepoId(options.projectHostSetupProjection)
for (const worktree of liveWorktrees) {
if (
worktree.repoId === activeWorktree.repoId ||
worktreeProjectKey(worktree) === options.activeProjectKey ||
(setupsByRepoId.get(worktree.repoId) ?? []).some(
(setup) => worktreeProjectKey(setup, setup) === options.activeProjectKey
)
) {
addAiVaultWorkspaceScopePath(paths, worktree.path)
}
}
for (const setup of options.projectHostSetupProjection?.setups ?? []) {
if (worktreeProjectKey(setup, setup) === options.activeProjectKey) {
addAiVaultWorkspaceScopePath(paths, setup.path)
}
}
return paths
}
function buildProjectSetupsByRepoId(
projection?: ProjectHostSetupProjection
): Map<string, ProjectHostSetupProjection['setups']> {
const setupsByRepoId = new Map<string, ProjectHostSetupProjection['setups']>()
for (const setup of projection?.setups ?? []) {
const setups = setupsByRepoId.get(setup.repoId) ?? []
setups.push(setup)
setupsByRepoId.set(setup.repoId, setups)
}
return setupsByRepoId
}
function worktreeProjectKey(
entry: Pick<Worktree, 'projectId' | 'repoId'> | { projectId?: string | null; repoId?: string },
setup?: { projectId?: string | null; repoId?: string }
): string | null {
const projectId = entry.projectId ?? setup?.projectId ?? null
if (projectId) {
return projectId.startsWith('repo:') ? projectId : `project:${projectId}`
}
return entry.repoId ? `repo:${entry.repoId}` : null
}
function addAiVaultWorkspaceScopePath(paths: string[], pathValue: string): void {
const trimmedPath = pathValue.trim()
if (!trimmedPath || !isRuntimePathAbsolute(trimmedPath)) {
return
}
const comparisonPath = normalizeRuntimePathForComparison(trimmedPath)
if (
paths.some((existingPath) => normalizeRuntimePathForComparison(existingPath) === comparisonPath)
) {
return
}
paths.push(trimmedPath)
}
function isAiVaultWorkspaceScopePathClaimed(
pathValue: string,
activeWorktree: Pick<Worktree, 'id'>,
liveWorktrees: readonly Pick<Worktree, 'id' | 'path'>[]
): boolean {
const trimmedPath = pathValue.trim()
if (!trimmedPath || !isRuntimePathAbsolute(trimmedPath)) {
return false
}
const comparisonPath = normalizeRuntimePathForComparison(trimmedPath)
// AI Vault sessions are keyed by cwd only, so any live worktree now owning this path wins.
return liveWorktrees.some(
(worktree) =>
worktree.id !== activeWorktree.id &&
normalizeRuntimePathForComparison(worktree.path) === comparisonPath
)
}
@@ -3,6 +3,7 @@ import type { AiVaultSession } from '../../../../shared/ai-vault-types'
import {
latestSessionConversationTurn,
recentSessionConversationTurns,
sessionDetailConversationTurns,
sessionPreviewSearchText
} from './ai-vault-session-display'
@@ -70,4 +71,22 @@ describe('ai vault session display', () => {
})
).toBe('tool-only transcript')
})
it('drops title-matching turns and adjacent duplicates from detail turns', () => {
const session: AiVaultSession = {
...baseSession,
title: 'Fix the flaky golden tests',
previewMessages: [
{ role: 'user', text: 'Fix the flaky golden tests', timestamp: null },
{ role: 'assistant', text: 'I updated the fixture ordering', timestamp: null },
{ role: 'assistant', text: 'I updated the fixture ordering', timestamp: null },
{ role: 'assistant', text: 'Added a regression test', timestamp: null }
]
}
expect(sessionDetailConversationTurns(session, 3).map((turn) => turn.text)).toEqual([
'I updated the fixture ordering',
'Added a regression test'
])
})
})
@@ -28,6 +28,58 @@ export function recentSessionConversationTurns(
return displayableSessionPreviewMessages(session).slice(-limit).map(toDisplayTurn)
}
export function sessionDetailConversationTurns(
session: AiVaultSession,
limit: number
): AiVaultSessionDisplayTurn[] {
if (limit <= 0) {
return []
}
const turns = displayableSessionPreviewMessages(session)
.map(toDisplayTurn)
.filter((turn) => !turnTextMatchesSessionTitle(session.title, turn.text))
return dedupeAdjacentConversationTurns(turns).slice(-limit)
}
function turnTextMatchesSessionTitle(title: string, turnText: string): boolean {
const sessionText = normalizeSessionDisplayText(title)
const candidateText = normalizeSessionDisplayText(turnText)
if (!sessionText || !candidateText) {
return false
}
if (sessionText === candidateText) {
return true
}
const longEnough = sessionText.length >= 24 && candidateText.length >= 24
return (
longEnough && (sessionText.startsWith(candidateText) || candidateText.startsWith(sessionText))
)
}
function dedupeAdjacentConversationTurns(
turns: AiVaultSessionDisplayTurn[]
): AiVaultSessionDisplayTurn[] {
const deduped: AiVaultSessionDisplayTurn[] = []
for (const turn of turns) {
const previous = deduped.at(-1)
if (
previous &&
previous.role === turn.role &&
normalizeSessionDisplayText(previous.text) === normalizeSessionDisplayText(turn.text)
) {
continue
}
deduped.push(turn)
}
return deduped
}
function normalizeSessionDisplayText(value: string): string {
return value.trim().replace(/\s+/g, ' ').toLowerCase()
}
export function sessionPreviewSearchText(session: AiVaultSession): string {
return displayableSessionPreviewMessages(session)
.map((message) => message.text)
@@ -2,13 +2,16 @@ import { describe, expect, it } from 'vitest'
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
import {
AI_VAULT_SESSION_FILTER_QUERY_MAX_BYTES,
deriveAiVaultWorkspaceScopePaths,
filterAiVaultSessions,
folderLabel,
groupAiVaultSessions,
isAiVaultSessionFilterQueryTooLarge,
parseVaultQuery
} from './ai-vault-session-filters'
import {
deriveAiVaultScopeSessionPaths,
deriveAiVaultWorkspaceScopePaths
} from './ai-vault-scope-paths'
const baseSession: AiVaultSession = {
id: 'claude:1',
@@ -415,6 +418,176 @@ describe('deriveAiVaultWorkspaceScopePaths', () => {
})
})
describe('deriveAiVaultScopeSessionPaths', () => {
it('adds same-repo sibling worktrees on top of the workspace paths', () => {
expect(
deriveAiVaultScopeSessionPaths(
{
id: 'repo1::/Users/ada/workspaces/orca/fix-agent-history',
repoId: 'repo1',
path: '/Users/ada/workspaces/orca/fix-agent-history',
priorWorktreeIds: []
},
[
{
id: 'repo1::/Users/ada/workspaces/orca/fix-agent-history',
repoId: 'repo1',
path: '/Users/ada/workspaces/orca/fix-agent-history'
},
{
id: 'repo1::/Users/ada/workspaces/orca/sibling',
repoId: 'repo1',
path: '/Users/ada/workspaces/orca/sibling'
},
{
id: 'repo2::/Users/ada/workspaces/other/elsewhere',
repoId: 'repo2',
path: '/Users/ada/workspaces/other/elsewhere'
}
]
)
).toEqual([
'/Users/ada/workspaces/orca/fix-agent-history',
'/Users/ada/workspaces/orca/sibling'
])
})
it('returns no paths without an active worktree', () => {
expect(deriveAiVaultScopeSessionPaths(null, [])).toEqual([])
})
it('adds active project setup paths across repos', () => {
expect(
deriveAiVaultScopeSessionPaths(
{
id: 'repo1::/Users/ada/workspaces/orca/app',
repoId: 'repo1',
path: '/Users/ada/workspaces/orca/app',
priorWorktreeIds: []
},
[
{
id: 'repo1::/Users/ada/workspaces/orca/app',
repoId: 'repo1',
path: '/Users/ada/workspaces/orca/app'
},
{
id: 'repo2::/Users/ada/workspaces/orca/docs',
repoId: 'repo2',
path: '/Users/ada/workspaces/orca/docs'
}
],
{
activeProjectKey: 'project:orca',
projectHostSetupProjection: {
projects: [
{
id: 'orca',
displayName: 'Orca',
badgeColor: '#2563eb',
sourceRepoIds: ['repo1', 'repo2'],
createdAt: 1,
updatedAt: 1
}
],
setups: [
{
id: 'setup-1',
projectId: 'orca',
hostId: 'local',
repoId: 'repo1',
displayName: 'App',
path: '/Users/ada/workspaces/orca/app',
setupState: 'ready',
setupMethod: 'imported-existing-folder',
createdAt: 1,
updatedAt: 1
},
{
id: 'setup-2',
projectId: 'orca',
hostId: 'local',
repoId: 'repo2',
displayName: 'Docs',
path: '/Users/ada/workspaces/orca/docs',
setupState: 'ready',
setupMethod: 'imported-existing-folder',
createdAt: 1,
updatedAt: 1
}
]
}
}
)
).toEqual(['/Users/ada/workspaces/orca/app', '/Users/ada/workspaces/orca/docs'])
})
it('keeps live worktree paths when another setup shares the repo id', () => {
expect(
deriveAiVaultScopeSessionPaths(
{
id: 'repo1::/Users/ada/workspaces/orca/app',
repoId: 'repo1',
path: '/Users/ada/workspaces/orca/app',
priorWorktreeIds: []
},
[
{
id: 'repo2::/Users/ada/workspaces/orca/docs-worktree',
repoId: 'repo2',
path: '/Users/ada/workspaces/orca/docs-worktree'
}
],
{
activeProjectKey: 'project:orca',
projectHostSetupProjection: {
projects: [
{
id: 'orca',
displayName: 'Orca',
badgeColor: '#2563eb',
sourceRepoIds: ['repo1', 'repo2'],
createdAt: 1,
updatedAt: 1
}
],
setups: [
{
id: 'setup-1',
projectId: 'orca',
hostId: 'local',
repoId: 'repo2',
displayName: 'Docs',
path: '/Users/ada/workspaces/orca/docs',
setupState: 'ready',
setupMethod: 'imported-existing-folder',
createdAt: 1,
updatedAt: 1
},
{
id: 'setup-2',
projectId: 'other',
hostId: 'local',
repoId: 'repo2',
displayName: 'Other',
path: '/Users/ada/workspaces/other',
setupState: 'ready',
setupMethod: 'imported-existing-folder',
createdAt: 1,
updatedAt: 1
}
]
}
}
)
).toEqual([
'/Users/ada/workspaces/orca/app',
'/Users/ada/workspaces/orca/docs-worktree',
'/Users/ada/workspaces/orca/docs'
])
})
})
describe('isAiVaultSessionFilterQueryTooLarge', () => {
it('counts UTF-8 bytes rather than UTF-16 code units', () => {
expect(
@@ -1,7 +1,5 @@
import {
isPathInsideOrEqual,
isRuntimePathAbsolute,
normalizeRuntimePathForComparison,
normalizeRuntimePathSeparators
} from '../../../../shared/cross-platform-path'
import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text'
@@ -14,8 +12,6 @@ import type {
AiVaultSort
} from '../../../../shared/ai-vault-types'
import { aiVaultAgentLabel } from '../../../../shared/ai-vault-types'
import type { Worktree } from '../../../../shared/types'
import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree-id'
import { sessionPreviewSearchText } from './ai-vault-session-display'
import type { AiVaultSessionProject } from './ai-vault-session-projects'
@@ -95,31 +91,6 @@ export function filterAiVaultSessions(
.sort((left, right) => compareSessions(left, right, filters.sort))
}
export function deriveAiVaultWorkspaceScopePaths(
activeWorktree: Pick<Worktree, 'id' | 'path' | 'priorWorktreeIds' | 'repoId'> | null,
liveWorktrees: readonly Pick<Worktree, 'id' | 'path' | 'repoId'>[] = []
): string[] {
if (!activeWorktree) {
return []
}
const paths: string[] = []
addAiVaultWorkspaceScopePath(paths, activeWorktree.path)
for (const priorWorktreeId of activeWorktree.priorWorktreeIds ?? []) {
const parsed = splitWorktreeIdForFilesystem(priorWorktreeId)
if (!parsed || parsed.repoId !== activeWorktree.repoId) {
continue
}
if (isAiVaultWorkspaceScopePathClaimed(parsed.worktreePath, activeWorktree, liveWorktrees)) {
continue
}
addAiVaultWorkspaceScopePath(paths, parsed.worktreePath)
}
return paths
}
export function groupAiVaultSessions(
sessions: readonly AiVaultSession[],
group: AiVaultGroup,
@@ -264,20 +235,6 @@ function getFolderGroupKey(pathValue: string | null): string {
return pathValue ? normalizeRuntimePathSeparators(pathValue).toLowerCase() : 'unknown'
}
function addAiVaultWorkspaceScopePath(paths: string[], pathValue: string): void {
const trimmedPath = pathValue.trim()
if (!trimmedPath || !isRuntimePathAbsolute(trimmedPath)) {
return
}
const comparisonPath = normalizeRuntimePathForComparison(trimmedPath)
if (
paths.some((existingPath) => normalizeRuntimePathForComparison(existingPath) === comparisonPath)
) {
return
}
paths.push(trimmedPath)
}
function isAiVaultSessionInWorkspacePath(workspacePath: string, sessionCwd: string): boolean {
if (isPathInsideOrEqual(workspacePath, sessionCwd)) {
return true
@@ -293,24 +250,6 @@ function isAiVaultSessionInWorkspacePath(workspacePath: string, sessionCwd: stri
return isPathInsideOrEqual(workspaceWslPath.linuxPath, sessionCwd)
}
function isAiVaultWorkspaceScopePathClaimed(
pathValue: string,
activeWorktree: Pick<Worktree, 'id'>,
liveWorktrees: readonly Pick<Worktree, 'id' | 'path'>[]
): boolean {
const trimmedPath = pathValue.trim()
if (!trimmedPath || !isRuntimePathAbsolute(trimmedPath)) {
return false
}
const comparisonPath = normalizeRuntimePathForComparison(trimmedPath)
// AI Vault sessions are keyed by cwd only, so any live worktree now owning this path wins.
return liveWorktrees.some(
(worktree) =>
worktree.id !== activeWorktree.id &&
normalizeRuntimePathForComparison(worktree.path) === comparisonPath
)
}
function tokenizeQuery(query: string): string[] {
const tokens: string[] = []
const pattern = /"([^"]+)"|'([^']+)'|(\S+)/g
@@ -0,0 +1,113 @@
import { useCallback } from 'react'
import { toast } from 'sonner'
import {
buildAiVaultResumeCommandForWorktree,
buildAiVaultResumeStartupForWorktree,
type AiVaultResumeStartup
} from '@/lib/ai-vault-resume-command'
import { launchAiVaultSessionInNewTab } from '@/lib/launch-ai-vault-session'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { useAppStore } from '@/store'
import type { AiVaultAgent, AiVaultSession } from '../../../../shared/ai-vault-types'
import type { Repo, Worktree } from '../../../../shared/types'
import { translate } from '@/i18n/i18n'
import { agentLabel } from './ai-vault-session-filters'
export function useAiVaultSessionLaunchActions({
activeWorktree,
allWorktrees,
repos,
agentCmdOverrides
}: {
activeWorktree: Worktree | null
allWorktrees: readonly Worktree[]
repos: readonly Repo[]
agentCmdOverrides?: Partial<Record<AiVaultAgent, string | null>>
}): {
buildResumeStartup: (session: AiVaultSession, worktreeId?: string | null) => AiVaultResumeStartup
copyResumeCommand: (session: AiVaultSession, worktreeId?: string | null) => Promise<void>
handleResume: (session: AiVaultSession, targetWorktreeId?: string) => void
} {
const buildResumeCommand = useCallback(
(session: AiVaultSession, worktreeId?: string | null): string =>
buildAiVaultResumeCommandForWorktree({
state: useAppStore.getState(),
worktreeId: worktreeId ?? activeWorktree?.id ?? null,
session,
commandOverride: agentCmdOverrides?.[session.agent]
}),
[activeWorktree?.id, agentCmdOverrides]
)
const buildResumeStartup = useCallback(
(session: AiVaultSession, worktreeId?: string | null) =>
buildAiVaultResumeStartupForWorktree({
state: useAppStore.getState(),
worktreeId: worktreeId ?? activeWorktree?.id ?? null,
session,
commandOverride: agentCmdOverrides?.[session.agent]
}),
[activeWorktree?.id, agentCmdOverrides]
)
const copyResumeCommand = useCallback(
async (session: AiVaultSession, worktreeId?: string | null): Promise<void> => {
await window.api.ui.writeClipboardText(buildResumeCommand(session, worktreeId))
toast.success(
translate(
'auto.components.right.sidebar.AiVaultPanel.resumeCommandCopied',
'Resume command copied'
)
)
},
[buildResumeCommand]
)
const handleResume = useCallback(
(session: AiVaultSession, targetWorktreeId?: string): void => {
const worktree =
(targetWorktreeId
? allWorktrees.find((candidate) => candidate.id === targetWorktreeId)
: null) ?? activeWorktree
if (!worktree) {
toast.error(
translate(
'auto.components.right.sidebar.AiVaultPanel.openWorkspaceBeforeResuming',
'Open a workspace before resuming a session.'
)
)
return
}
const worktreeRepo = repos.find((repo) => repo.id === worktree.repoId)
if (worktreeRepo?.connectionId) {
toast.error(
translate(
'auto.components.right.sidebar.AiVaultPanel.localWorkspacesOnly',
'Resume from history is only available in local workspaces.'
)
)
return
}
launchAiVaultSessionInNewTab({
agent: session.agent,
worktreeId: worktree.id,
...buildResumeStartup(session, worktree.id)
})
if (useAppStore.getState().activeWorktreeId !== worktree.id) {
activateAndRevealWorktree(worktree.id)
}
toast.success(
translate(
'auto.components.right.sidebar.AiVaultPanel.agentSessionQueued',
'{{value0}} session queued',
{ value0: agentLabel(session.agent) }
)
)
},
[activeWorktree, allWorktrees, buildResumeStartup, repos]
)
return { buildResumeStartup, copyResumeCommand, handleResume }
}
@@ -0,0 +1,87 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { AiVaultListResult, AiVaultSession } from '../../../../shared/ai-vault-types'
const SESSION_LIMIT = 500
export function useAiVaultSessionRefresh(scopePaths: readonly string[]): {
error: string | null
loading: boolean
refresh: (args?: { force?: boolean }) => Promise<void>
scanResult: AiVaultListResult | null
sessions: AiVaultSession[]
} {
const [sessions, setSessions] = useState<AiVaultSession[]>([])
const [scanResult, setScanResult] = useState<AiVaultListResult | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const refreshIdRef = useRef(0)
const refreshInFlightRef = useRef(false)
const pendingRefreshRef = useRef(false)
const pendingForceRef = useRef(false)
const mountedRef = useRef(true)
const scopePathsKey = useMemo(() => scopePaths.join('\n'), [scopePaths])
const scopePathsRef = useRef<readonly string[]>(scopePaths)
scopePathsRef.current = scopePaths
const refresh = useCallback(async (args: { force?: boolean } = {}): Promise<void> => {
// A scope change during an in-flight scan must not be dropped; queue one more
// scan so the current scoped view is refreshed after the older scan settles.
if (refreshInFlightRef.current) {
pendingRefreshRef.current = true
pendingForceRef.current ||= args.force === true
return
}
refreshInFlightRef.current = true
const refreshId = refreshIdRef.current + 1
refreshIdRef.current = refreshId
setLoading(true)
setError(null)
try {
const result = await window.api.aiVault.listSessions({
limit: SESSION_LIMIT,
scopePaths: scopePathsRef.current,
force: args.force
})
if (!mountedRef.current || refreshIdRef.current !== refreshId) {
return
}
setScanResult(result)
setSessions(result.sessions)
} catch (err) {
if (mountedRef.current && refreshIdRef.current === refreshId) {
setError(err instanceof Error ? err.message : String(err))
}
} finally {
refreshInFlightRef.current = false
if (mountedRef.current && refreshIdRef.current === refreshId) {
setLoading(false)
}
if (pendingRefreshRef.current && mountedRef.current) {
pendingRefreshRef.current = false
const force = pendingForceRef.current
pendingForceRef.current = false
void refresh({ force })
}
}
// Deps are intentionally empty: refresh reads changing values through refs
// and recurses on itself, so its identity must stay stable.
}, [])
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
refreshIdRef.current += 1
refreshInFlightRef.current = false
}
}, [])
// Re-scan on mount and whenever the active scope changes, since the scanner
// tailors its in-scope results to scopePaths.
useEffect(() => {
void refresh()
}, [refresh, scopePathsKey])
return { error, loading, refresh, scanResult, sessions }
}
@@ -0,0 +1,175 @@
import { describe, expect, it } from 'vitest'
import type { Repo, Worktree } from '../../../../shared/types'
import type { AiVaultSessionWorktreeInfo } from './ai-vault-session-worktree'
import {
aiVaultSessionResumeLabel,
resolveAiVaultSessionResumeActions,
resolveAiVaultSessionResumeState
} from './ai-vault-session-resume'
function makeWorktree(overrides: Partial<Worktree> = {}): Worktree {
return {
id: 'repo-1::/repo/orca',
repoId: 'repo-1',
displayName: 'orca',
path: '/repo/orca',
head: 'abc123',
branch: 'main',
isBare: false,
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 1,
isMainWorktree: false,
...overrides
}
}
function makeWorktreeInfo(
status: AiVaultSessionWorktreeInfo['status']
): AiVaultSessionWorktreeInfo {
return {
status,
label: 'orca',
path: '/repo/orca',
...(status === 'unavailable' ? {} : { worktreeId: 'repo-1::/repo/orca' })
}
}
describe('resolveAiVaultSessionResumeState', () => {
it('prefers the session worktree over the active workspace', () => {
expect(
resolveAiVaultSessionResumeState({
worktreeInfo: makeWorktreeInfo('active'),
activeWorktreeId: 'repo-1::/repo/other',
worktrees: [
makeWorktree(),
makeWorktree({ id: 'repo-1::/repo/other', path: '/repo/other' })
],
repos: [{ id: 'repo-1' } as Repo]
})
).toEqual({
blocked: false,
worktreeId: 'repo-1::/repo/orca',
usesSessionWorktree: true
})
})
it('falls back to the active workspace when the session worktree is unavailable', () => {
expect(
resolveAiVaultSessionResumeState({
worktreeInfo: makeWorktreeInfo('archived'),
activeWorktreeId: 'repo-1::/repo/orca',
worktrees: [makeWorktree()],
repos: [{ id: 'repo-1' } as Repo]
})
).toEqual({
blocked: false,
worktreeId: 'repo-1::/repo/orca',
usesSessionWorktree: false
})
})
it('blocks remote targets and missing worktrees', () => {
expect(
resolveAiVaultSessionResumeState({
worktreeInfo: makeWorktreeInfo('active'),
activeWorktreeId: null,
worktrees: [],
repos: [{ id: 'repo-1', connectionId: 'ssh-1' } as Repo]
})
).toEqual({
blocked: true,
worktreeId: null,
usesSessionWorktree: false
})
})
it('uses a local session worktree when the active workspace is remote', () => {
expect(
resolveAiVaultSessionResumeState({
worktreeInfo: makeWorktreeInfo('active'),
activeWorktreeId: 'repo-2::/remote/orca',
worktrees: [
makeWorktree(),
makeWorktree({
id: 'repo-2::/remote/orca',
repoId: 'repo-2',
path: '/remote/orca'
})
],
repos: [{ id: 'repo-1' } as Repo, { id: 'repo-2', connectionId: 'ssh-1' } as Repo]
})
).toEqual({
blocked: false,
worktreeId: 'repo-1::/repo/orca',
usesSessionWorktree: true
})
})
})
describe('resolveAiVaultSessionResumeActions', () => {
it('exposes separate session-worktree and active-workspace targets', () => {
expect(
resolveAiVaultSessionResumeActions({
worktreeInfo: makeWorktreeInfo('active'),
activeWorktreeId: 'repo-1::/repo/other',
worktrees: [
makeWorktree(),
makeWorktree({ id: 'repo-1::/repo/other', path: '/repo/other' })
],
repos: [{ id: 'repo-1' } as Repo]
})
).toEqual({
worktree: { worktreeId: 'repo-1::/repo/orca', disabled: false },
newTab: { worktreeId: 'repo-1::/repo/other', disabled: false }
})
})
it('disables only the remote active-workspace action when the session worktree is local', () => {
expect(
resolveAiVaultSessionResumeActions({
worktreeInfo: makeWorktreeInfo('active'),
activeWorktreeId: 'repo-2::/remote/orca',
worktrees: [
makeWorktree(),
makeWorktree({
id: 'repo-2::/remote/orca',
repoId: 'repo-2',
path: '/remote/orca'
})
],
repos: [{ id: 'repo-1' } as Repo, { id: 'repo-2', connectionId: 'ssh-1' } as Repo]
})
).toEqual({
worktree: { worktreeId: 'repo-1::/repo/orca', disabled: false },
newTab: { worktreeId: 'repo-2::/remote/orca', disabled: true }
})
})
it('does not expose the active workspace as a duplicate new-tab target', () => {
expect(
resolveAiVaultSessionResumeActions({
worktreeInfo: makeWorktreeInfo('current'),
activeWorktreeId: 'repo-1::/repo/orca',
worktrees: [makeWorktree()],
repos: [{ id: 'repo-1' } as Repo]
})
).toEqual({
worktree: { worktreeId: 'repo-1::/repo/orca', disabled: false },
newTab: { worktreeId: null, disabled: true }
})
})
})
describe('aiVaultSessionResumeLabel', () => {
it('names the session worktree action distinctly from the active-workspace fallback', () => {
expect(aiVaultSessionResumeLabel({ usesSessionWorktree: true })).toBe('Resume in Worktree')
expect(aiVaultSessionResumeLabel({ usesSessionWorktree: false })).toBe('Resume in New Tab')
})
})
@@ -0,0 +1,140 @@
import type { Repo, Worktree } from '../../../../shared/types'
import { translate } from '@/i18n/i18n'
import {
canJumpToAiVaultSessionWorktree,
type AiVaultSessionWorktreeInfo
} from './ai-vault-session-worktree'
export type AiVaultSessionResumeState = {
blocked: boolean
worktreeId: string | null
usesSessionWorktree: boolean
}
export type AiVaultSessionResumeAction = {
worktreeId: string | null
disabled: boolean
}
export type AiVaultSessionResumeActions = {
worktree: AiVaultSessionResumeAction
newTab: AiVaultSessionResumeAction
}
export function resolveAiVaultSessionResumeState(args: {
worktreeInfo: AiVaultSessionWorktreeInfo | null
activeWorktreeId: string | null
worktrees: readonly Worktree[]
repos: readonly Repo[]
}): AiVaultSessionResumeState {
const sessionWorktreeId =
canJumpToAiVaultSessionWorktree(args.worktreeInfo) && args.worktreeInfo?.worktreeId
? args.worktreeInfo.worktreeId
: null
const candidateWorktreeIds = [
sessionWorktreeId,
args.activeWorktreeId && args.activeWorktreeId !== sessionWorktreeId
? args.activeWorktreeId
: null
].filter((value): value is string => Boolean(value))
for (const worktreeId of candidateWorktreeIds) {
const worktree = args.worktrees.find((candidate) => candidate.id === worktreeId)
if (!worktree) {
continue
}
const repo = args.repos.find((candidate) => candidate.id === worktree.repoId)
if (repo?.connectionId) {
continue
}
return {
blocked: false,
worktreeId,
usesSessionWorktree: worktreeId === sessionWorktreeId
}
}
return {
blocked: true,
worktreeId: null,
usesSessionWorktree: false
}
}
export function resolveAiVaultSessionResumeActions(args: {
worktreeInfo: AiVaultSessionWorktreeInfo | null
activeWorktreeId: string | null
worktrees: readonly Worktree[]
repos: readonly Repo[]
}): AiVaultSessionResumeActions {
const sessionWorktreeId =
canJumpToAiVaultSessionWorktree(args.worktreeInfo) && args.worktreeInfo?.worktreeId
? args.worktreeInfo.worktreeId
: null
const sessionTargetId = resolveLocalResumeWorktreeId({
worktreeId: sessionWorktreeId,
worktrees: args.worktrees,
repos: args.repos
})
const activeTargetId = resolveLocalResumeWorktreeId({
worktreeId:
args.activeWorktreeId && args.activeWorktreeId !== sessionWorktreeId
? args.activeWorktreeId
: null,
worktrees: args.worktrees,
repos: args.repos
})
return {
worktree: {
worktreeId: sessionWorktreeId,
disabled: !sessionTargetId
},
newTab: {
worktreeId:
args.activeWorktreeId && args.activeWorktreeId !== sessionWorktreeId
? args.activeWorktreeId
: null,
disabled: !activeTargetId
}
}
}
function resolveLocalResumeWorktreeId(args: {
worktreeId: string | null
worktrees: readonly Worktree[]
repos: readonly Repo[]
}): string | null {
if (!args.worktreeId) {
return null
}
const worktree = args.worktrees.find((candidate) => candidate.id === args.worktreeId)
if (!worktree) {
return null
}
const repo = args.repos.find((candidate) => candidate.id === worktree.repoId)
if (repo?.connectionId) {
return null
}
return args.worktreeId
}
export function aiVaultSessionResumeLabel(
state: Pick<AiVaultSessionResumeState, 'usesSessionWorktree'>
): string {
if (state.usesSessionWorktree) {
return translate(
'auto.components.right.sidebar.AiVaultSessionDetails.resumeInWorktree',
'Resume in Worktree'
)
}
return translate(
'auto.components.right.sidebar.AiVaultSessionRow.resumeInNewTab',
'Resume in New Tab'
)
}
@@ -0,0 +1,229 @@
import { describe, expect, it } from 'vitest'
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
import type { Worktree } from '../../../../shared/types'
import {
aiVaultWorktreeCompactPath,
aiVaultWorktreeJumpTooltip,
canJumpToAiVaultSessionWorktree,
extractWorktreePathFromSessionTitle,
resolveAiVaultSessionWorktreeDisplay,
resolveAiVaultSessionWorktreeInfo,
shouldShowAiVaultWorktreeStatusBadge,
type AiVaultSessionWorktreeInfo
} from './ai-vault-session-worktree'
const baseSession: AiVaultSession = {
id: 'codex:session-1',
agent: 'codex',
sessionId: 'session-1',
title: 'Find the pane',
cwd: '/repo/orca/src',
branch: null,
model: null,
filePath: '/home/ada/.codex/session-1.jsonl',
codexHome: null,
createdAt: null,
updatedAt: '2026-06-24T10:00:00.000Z',
modifiedAt: '2026-06-24T10:00:00.000Z',
messageCount: 2,
totalTokens: 42,
previewMessages: [],
resumeCommand: "codex resume 'session-1'"
}
function makeWorktree(overrides: Partial<Worktree> = {}): Worktree {
const worktree: Worktree = {
id: 'repo-1::/repo/orca',
repoId: 'repo-1',
displayName: 'orca',
path: '/repo/orca',
head: 'abc123',
branch: 'main',
isBare: false,
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 1,
isMainWorktree: false
}
return { ...worktree, ...overrides }
}
describe('resolveAiVaultSessionWorktreeInfo', () => {
it('marks the selected owning worktree as current', () => {
const worktree = makeWorktree()
expect(
resolveAiVaultSessionWorktreeInfo({
session: baseSession,
worktrees: [worktree],
activeWorktreeId: worktree.id
})
).toMatchObject({
status: 'current',
label: 'orca',
path: '/repo/orca'
})
})
it('marks a known non-selected worktree as active', () => {
const worktree = makeWorktree()
expect(
resolveAiVaultSessionWorktreeInfo({
session: baseSession,
worktrees: [worktree],
activeWorktreeId: 'other'
})?.status
).toBe('active')
})
it('uses prior worktree paths to identify renamed active worktrees', () => {
const worktree = makeWorktree({
id: 'repo-1::/repo/orca-renamed',
path: '/repo/orca-renamed',
priorWorktreeIds: ['repo-1::/repo/orca']
})
expect(
resolveAiVaultSessionWorktreeInfo({
session: baseSession,
worktrees: [worktree],
activeWorktreeId: null
})
).toMatchObject({
status: 'active',
label: 'orca',
path: '/repo/orca'
})
})
it('falls back to unavailable when no known worktree owns the transcript cwd', () => {
expect(
resolveAiVaultSessionWorktreeInfo({
session: baseSession,
worktrees: [],
activeWorktreeId: null
})
).toMatchObject({
status: 'unavailable',
label: 'orca/src',
path: '/repo/orca/src'
})
})
it('matches WSL UNC worktree paths to Linux transcript cwd values', () => {
const worktree = makeWorktree({
path: '\\\\wsl.localhost\\Ubuntu\\home\\ada\\orca'
})
expect(
resolveAiVaultSessionWorktreeInfo({
session: { ...baseSession, cwd: '/home/ada/orca/src' },
worktrees: [worktree],
activeWorktreeId: null
})
).toMatchObject({
status: 'active',
label: 'orca',
path: '\\\\wsl.localhost\\Ubuntu\\home\\ada\\orca'
})
})
})
describe('canJumpToAiVaultSessionWorktree', () => {
it('allows current and active worktree targets', () => {
expect(canJumpToAiVaultSessionWorktree(makeWorktreeInfo('current'))).toBe(true)
expect(canJumpToAiVaultSessionWorktree(makeWorktreeInfo('active'))).toBe(true)
})
it('disables jump targets that are not active worktrees', () => {
expect(canJumpToAiVaultSessionWorktree(makeWorktreeInfo('archived'))).toBe(false)
expect(canJumpToAiVaultSessionWorktree(makeWorktreeInfo('unavailable'))).toBe(false)
expect(canJumpToAiVaultSessionWorktree(null)).toBe(false)
})
})
describe('extractWorktreePathFromSessionTitle', () => {
it('reads worktree paths embedded in session titles', () => {
expect(
extractWorktreePathFromSessionTitle(
'Inspect PR #6229 - Worktree: /Users/ada/projects/orca/fix-tabs'
)
).toBe('/Users/ada/projects/orca/fix-tabs')
expect(extractWorktreePathFromSessionTitle('Worktree: /tmp/orca-worker')).toBe(
'/tmp/orca-worker'
)
})
})
describe('resolveAiVaultSessionWorktreeDisplay', () => {
it('falls back to title and branch when cwd is missing', () => {
expect(
resolveAiVaultSessionWorktreeDisplay({
session: {
...baseSession,
cwd: null,
branch: null,
title: 'Fix tabs - Worktree: /Users/ada/projects/orca/fix-tabs'
},
worktrees: [makeWorktree()],
activeWorktreeId: null
})?.path
).toBe('/Users/ada/projects/orca/fix-tabs')
expect(
resolveAiVaultSessionWorktreeDisplay({
session: { ...baseSession, cwd: null, branch: 'chinese-translation-improvement' },
worktrees: [makeWorktree()],
activeWorktreeId: null
})?.label
).toBe('chinese-translation-improvement')
})
})
describe('aiVaultWorktreeCompactPath', () => {
it('keeps the last two path segments for dense detail rows', () => {
expect(aiVaultWorktreeCompactPath('/Users/ada/projects/orca/improve-agent-session')).toBe(
'orca/improve-agent-session'
)
})
})
describe('shouldShowAiVaultWorktreeStatusBadge', () => {
it('hides the generic active badge but keeps meaningful states', () => {
expect(shouldShowAiVaultWorktreeStatusBadge('active')).toBe(false)
expect(shouldShowAiVaultWorktreeStatusBadge('current')).toBe(true)
expect(shouldShowAiVaultWorktreeStatusBadge('archived')).toBe(true)
expect(shouldShowAiVaultWorktreeStatusBadge('unavailable')).toBe(true)
})
})
describe('aiVaultWorktreeJumpTooltip', () => {
it('explains active jump targets and disabled states', () => {
expect(aiVaultWorktreeJumpTooltip(makeWorktreeInfo('active'))).toBe('Jump to Worktree')
expect(aiVaultWorktreeJumpTooltip(makeWorktreeInfo('archived'))).toBe(
'This session is in an archived worktree.'
)
expect(aiVaultWorktreeJumpTooltip(makeWorktreeInfo('unavailable'))).toBe(
'No active worktree matches this session.'
)
expect(aiVaultWorktreeJumpTooltip(null)).toBe('No worktree was recorded for this session.')
})
})
function makeWorktreeInfo(
status: AiVaultSessionWorktreeInfo['status']
): AiVaultSessionWorktreeInfo {
return {
status,
label: 'orca',
path: '/repo/orca',
...(status === 'unavailable' ? {} : { worktreeId: 'repo-1::/repo/orca' })
}
}
@@ -0,0 +1,289 @@
import { useMemo } from 'react'
import { parseWslUncPath } from '../../../../shared/wsl-paths'
import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree-id'
import {
isPathInsideOrEqual,
isRuntimePathAbsolute,
normalizeRuntimePathForComparison,
normalizeRuntimePathSeparators
} from '../../../../shared/cross-platform-path'
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
import type { Worktree } from '../../../../shared/types'
import { translate } from '@/i18n/i18n'
export type AiVaultSessionWorktreeStatus = 'current' | 'active' | 'archived' | 'unavailable'
export type AiVaultSessionWorktreeInfo = {
status: AiVaultSessionWorktreeStatus
label: string
path: string
worktreeId?: string
}
type WorktreeCandidate = {
worktree: Worktree
path: string
status: Exclude<AiVaultSessionWorktreeStatus, 'current'>
source: 'current-path' | 'prior-path'
}
export function resolveAiVaultSessionWorktreeInfo({
session,
worktrees,
activeWorktreeId
}: {
session: AiVaultSession
worktrees: readonly Worktree[]
activeWorktreeId: string | null
}): AiVaultSessionWorktreeInfo | null {
if (!session.cwd) {
return null
}
const candidates = buildWorktreeCandidates(worktrees)
.filter((candidate) => isSessionInWorktreePath(candidate.path, session.cwd!))
.sort(compareWorktreeCandidates)
const best = candidates[0]
if (!best) {
return {
status: 'unavailable',
label: compactPathLabel(session.cwd),
path: session.cwd
}
}
const status =
best.worktree.id === activeWorktreeId
? 'current'
: best.worktree.isArchived
? 'archived'
: best.status
return {
status,
label: best.worktree.displayName || compactPathLabel(best.path),
path: best.path,
worktreeId: best.worktree.id
}
}
export function extractWorktreePathFromSessionTitle(title: string): string | null {
const trimmed = title.trim()
if (!trimmed) {
return null
}
const suffixMatch = trimmed.match(/\s-\s*Worktree:\s*(.+)$/i)
if (suffixMatch?.[1]) {
return suffixMatch[1].trim()
}
const inlineMatch = trimmed.match(/\bWorktree:\s*(.+)$/i)
return inlineMatch?.[1]?.trim() ?? null
}
export function resolveAiVaultSessionWorktreeDisplay(args: {
session: AiVaultSession
worktrees: readonly Worktree[]
activeWorktreeId: string | null
}): AiVaultSessionWorktreeInfo | null {
const resolved = resolveAiVaultSessionWorktreeInfo(args)
if (resolved) {
return resolved
}
const cwd = args.session.cwd?.trim()
if (cwd) {
return unavailableWorktreeInfo(cwd)
}
const titlePath = extractWorktreePathFromSessionTitle(args.session.title)
if (titlePath) {
return unavailableWorktreeInfo(titlePath)
}
const branch = args.session.branch?.trim()
if (branch) {
return {
status: 'unavailable',
label: branch,
path: branch
}
}
return null
}
export function useAiVaultSessionWorktreeMap({
sessions,
worktrees,
activeWorktreeId
}: {
sessions: readonly AiVaultSession[]
worktrees: readonly Worktree[]
activeWorktreeId: string | null
}): ReadonlyMap<string, AiVaultSessionWorktreeInfo> {
return useMemo(
() =>
new Map(
sessions.flatMap((session) => {
const worktreeInfo = resolveAiVaultSessionWorktreeDisplay({
session,
worktrees,
activeWorktreeId
})
return worktreeInfo ? [[session.id, worktreeInfo] as const] : []
})
),
[activeWorktreeId, sessions, worktrees]
)
}
export function canJumpToAiVaultSessionWorktree(
worktreeInfo: AiVaultSessionWorktreeInfo | null
): boolean {
return Boolean(
worktreeInfo?.worktreeId &&
worktreeInfo.status !== 'archived' &&
worktreeInfo.status !== 'unavailable'
)
}
export function aiVaultWorktreeJumpTooltip(
worktreeInfo: AiVaultSessionWorktreeInfo | null
): string {
if (canJumpToAiVaultSessionWorktree(worktreeInfo)) {
return translate(
'auto.components.right.sidebar.AiVaultSessionWorktree.jumpToWorktree',
'Jump to Worktree'
)
}
if (!worktreeInfo) {
return translate(
'auto.components.right.sidebar.AiVaultSessionWorktree.noRecordedWorktree',
'No worktree was recorded for this session.'
)
}
if (worktreeInfo.status === 'archived') {
return translate(
'auto.components.right.sidebar.AiVaultSessionWorktree.archivedJumpUnavailable',
'This session is in an archived worktree.'
)
}
if (worktreeInfo.status === 'unavailable') {
return translate(
'auto.components.right.sidebar.AiVaultSessionWorktree.noActiveWorktreeMatch',
'No active worktree matches this session.'
)
}
return translate(
'auto.components.right.sidebar.AiVaultSessionWorktree.noActiveWorktreeTarget',
'No active worktree is available.'
)
}
function buildWorktreeCandidates(worktrees: readonly Worktree[]): WorktreeCandidate[] {
const candidates: WorktreeCandidate[] = []
for (const worktree of worktrees) {
if (hasUsablePath(worktree.path)) {
candidates.push({
worktree,
path: worktree.path,
status: worktree.isArchived ? 'archived' : 'active',
source: 'current-path'
})
}
for (const priorWorktreeId of worktree.priorWorktreeIds ?? []) {
const parsed = splitWorktreeIdForFilesystem(priorWorktreeId)
if (!parsed || parsed.repoId !== worktree.repoId || !hasUsablePath(parsed.worktreePath)) {
continue
}
candidates.push({
worktree,
path: parsed.worktreePath,
status: worktree.isArchived ? 'archived' : 'active',
source: 'prior-path'
})
}
}
return candidates
}
function hasUsablePath(pathValue: string): boolean {
const trimmed = pathValue.trim()
return Boolean(trimmed && isRuntimePathAbsolute(trimmed))
}
function isSessionInWorktreePath(worktreePath: string, sessionCwd: string): boolean {
if (isPathInsideOrEqual(worktreePath, sessionCwd)) {
return true
}
const wslPath = parseWslUncPath(worktreePath)
return wslPath ? isPathInsideOrEqual(wslPath.linuxPath, sessionCwd) : false
}
function compareWorktreeCandidates(left: WorktreeCandidate, right: WorktreeCandidate): number {
const lengthDifference =
normalizeRuntimePathForComparison(right.path).length -
normalizeRuntimePathForComparison(left.path).length
if (lengthDifference !== 0) {
return lengthDifference
}
if (left.source === right.source) {
return 0
}
return left.source === 'current-path' ? -1 : 1
}
export function aiVaultWorktreeCompactPath(pathValue: string): string {
const parts = normalizeRuntimePathSeparators(pathValue).split('/').filter(Boolean)
if (parts.length >= 2) {
return parts.slice(-2).join('/')
}
return parts[0] ?? pathValue
}
export function shouldShowAiVaultWorktreeStatusBadge(
status: AiVaultSessionWorktreeStatus
): boolean {
// Why: "active" repeats the branch label without adding scan value in dense rows.
return status !== 'active'
}
function unavailableWorktreeInfo(pathValue: string): AiVaultSessionWorktreeInfo {
return {
status: 'unavailable',
label: compactPathLabel(pathValue),
path: pathValue
}
}
function compactPathLabel(pathValue: string): string {
return aiVaultWorktreeCompactPath(pathValue)
}
export function aiVaultWorktreeStatusLabel(status: AiVaultSessionWorktreeStatus): string {
if (status === 'current') {
return translate(
'auto.components.right.sidebar.AiVaultSessionWorktree.currentWorktree',
'Current worktree'
)
}
if (status === 'active') {
return translate(
'auto.components.right.sidebar.AiVaultSessionWorktree.activeWorktree',
'Active worktree'
)
}
if (status === 'archived') {
return translate(
'auto.components.right.sidebar.AiVaultSessionWorktree.archivedWorktree',
'Archived worktree'
)
}
return translate(
'auto.components.right.sidebar.AiVaultSessionWorktree.unavailableWorktree',
'Unavailable worktree'
)
}
@@ -6,7 +6,7 @@ import {
} from '../sidebar/worktree-list-virtual-rows'
export const VAULT_GROUP_HEADER_ROW_HEIGHT = 32
export const VAULT_SESSION_ROW_HEIGHT = 82
export const VAULT_SESSION_ROW_HEIGHT = 98
export type VaultVirtualRow = { type: 'group' | 'session' }
@@ -247,6 +247,58 @@ export function GeneralEditorSettingsSection({
/>
</SearchableSetting>
<SearchableSetting
title={translate(
'auto.components.settings.GeneralEditorSettingsSection.8f1afdfbd8',
'Diff Word Wrap'
)}
description={translate(
'auto.components.settings.GeneralEditorSettingsSection.4aa4d9fb73',
'Wrap long lines in diff editors instead of requiring horizontal scrolling.'
)}
keywords={['diff', 'word wrap', 'wrap', 'markdown', 'long lines']}
className="flex items-center justify-between gap-4 py-2"
>
<div className="min-w-0 flex-1 space-y-0.5">
<Label>
{translate(
'auto.components.settings.GeneralEditorSettingsSection.8f1afdfbd8',
'Diff Word Wrap'
)}
</Label>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.GeneralEditorSettingsSection.4aa4d9fb73',
'Wrap long lines in diff editors instead of requiring horizontal scrolling.'
)}
</p>
</div>
<SettingsSegmentedControl
ariaLabel={translate(
'auto.components.settings.GeneralEditorSettingsSection.8f1afdfbd8',
'Diff Word Wrap'
)}
value={settings.diffWordWrap ? 'on' : 'off'}
onChange={(option) => updateSettings({ diffWordWrap: option === 'on' })}
options={[
{
value: 'off',
label: translate(
'auto.components.settings.GeneralEditorSettingsSection.bf16ef0af2',
'Off'
)
},
{
value: 'on',
label: translate(
'auto.components.settings.GeneralEditorSettingsSection.3f6892f307',
'On'
)
}
]}
/>
</SearchableSetting>
<SearchableSetting
title={translate(
'auto.components.settings.GeneralEditorSettingsSection.1de48ad940',
@@ -9,7 +9,7 @@ export { getMobileSettingsPaneSearchEntries }
const ORCA_IOS_APP_STORE_URL = 'https://apps.apple.com/app/orca-ide/id6766130217'
const ORCA_ANDROID_APK_URL =
'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk'
'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk'
export function MobileSettingsPane(): React.JSX.Element {
return (
@@ -0,0 +1,143 @@
// @vitest-environment happy-dom
import React, { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Repo } from '../../../../shared/types'
import { RepositoryWorktreeDefaultsSection } from './RepositoryWorktreeDefaultsSection'
vi.mock('../../store', () => ({
useAppStore: (selector: (state: { settingsSearchQuery: string }) => unknown) =>
selector({ settingsSearchQuery: '' })
}))
vi.mock('./BaseRefPicker', () => ({
BaseRefPicker: () => null
}))
const BASE_REPO: Repo = {
id: 'repo-1',
path: '/home/user/project',
displayName: 'My Project',
badgeColor: '#000000',
addedAt: 0
}
let container: HTMLDivElement
let root: Root
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(() => {
act(() => {
root.unmount()
})
container.remove()
})
function render(repo: Repo, updateRepo: (repoId: string, updates: object) => void): void {
act(() => {
root.render(
React.createElement(RepositoryWorktreeDefaultsSection, {
repo,
settings: null,
updateRepo,
forceVisible: true
})
)
})
}
function getWorktreePathInput(): HTMLInputElement {
const input = container.querySelector<HTMLInputElement>('input')
if (!input) {
throw new Error('worktree path input not found')
}
return input
}
function setNativeValue(input: HTMLInputElement, text: string): void {
// Why: React reads controlled-input changes via the native value setter;
// assigning input.value directly is swallowed by React's value tracking.
const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
setValue?.call(input, text)
}
function typeText(input: HTMLInputElement, text: string): void {
act(() => {
setNativeValue(input, text)
input.dispatchEvent(new Event('input', { bubbles: true }))
})
}
function blurInput(input: HTMLInputElement): void {
// Why: React delegates onBlur via focusout (which bubbles) not blur (which
// doesn't), so dispatching focusout is required to trigger the React handler.
act(() => {
input.dispatchEvent(new FocusEvent('focusout', { bubbles: true }))
})
}
describe('RepositoryWorktreeDefaultsSection — worktree path', () => {
it('does not call updateRepo while the user is typing', () => {
const updateRepo = vi.fn()
render(BASE_REPO, updateRepo)
const input = getWorktreePathInput()
typeText(input, './w')
typeText(input, './wo')
typeText(input, './wor')
typeText(input, './worktree')
expect(updateRepo).not.toHaveBeenCalled()
})
it('calls updateRepo with the final value on blur', () => {
const updateRepo = vi.fn()
render(BASE_REPO, updateRepo)
const input = getWorktreePathInput()
typeText(input, ' ./worktree ')
blurInput(input)
expect(updateRepo).toHaveBeenCalledTimes(1)
expect(updateRepo).toHaveBeenCalledWith('repo-1', { worktreeBasePath: './worktree' })
})
it('does not call updateRepo when the normalized value is unchanged on blur', () => {
const updateRepo = vi.fn()
render({ ...BASE_REPO, worktreeBasePath: './worktree' }, updateRepo)
const input = getWorktreePathInput()
typeText(input, ' ./worktree ')
blurInput(input)
expect(updateRepo).not.toHaveBeenCalled()
})
it('calls updateRepo with undefined when the field is cleared', () => {
const updateRepo = vi.fn()
render({ ...BASE_REPO, worktreeBasePath: '../worktrees' }, updateRepo)
const input = getWorktreePathInput()
typeText(input, '')
blurInput(input)
expect(updateRepo).toHaveBeenCalledWith('repo-1', { worktreeBasePath: undefined })
})
it('calls updateRepo with undefined when the value is whitespace-only', () => {
const updateRepo = vi.fn()
render({ ...BASE_REPO, worktreeBasePath: '../worktrees' }, updateRepo)
const input = getWorktreePathInput()
typeText(input, ' ')
blurInput(input)
expect(updateRepo).toHaveBeenCalledWith('repo-1', { worktreeBasePath: undefined })
})
})
@@ -83,9 +83,16 @@ export function RepositoryWorktreeDefaultsSection({
repoId={repo.id}
storeValue={repo.worktreeBasePath ?? ''}
placeholder={settings?.workspaceDir ?? ''}
onTextChange={(text) =>
updateRepo(repo.id, { worktreeBasePath: text.trim() ? text : undefined })
}
onTextChange={() => {}}
onBlur={(e) => {
const worktreeBasePath = e.currentTarget.value.trim() || undefined
// Why: even an unchanged worktreeBasePath update asks main to
// prepare the root, which can touch the filesystem.
if (worktreeBasePath === (repo.worktreeBasePath?.trim() || undefined)) {
return
}
updateRepo(repo.id, { worktreeBasePath })
}}
className="h-9 text-sm"
/>
<p className="text-xs text-muted-foreground">
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockStateValues: unknown[] = []
let mockStateIndex = 0
let mockSettingsSearchQuery = ''
function resetMockState() {
mockStateIndex = 0
@@ -30,7 +31,7 @@ vi.mock('react', async () => {
vi.mock('../../store', () => ({
useAppStore: (selector: (state: { settingsSearchQuery: string }) => unknown) =>
selector({ settingsSearchQuery: '' })
selector({ settingsSearchQuery: mockSettingsSearchQuery })
}))
vi.mock('@/lib/keyboard-layout/use-effective-mac-option-as-alt', () => ({
@@ -105,13 +106,7 @@ vi.mock('./SettingsFormControls', () => ({
}))
vi.mock('./TerminalThemeSections', () => ({
DarkTerminalThemeSection: function DarkTerminalThemeSection() {
return null
},
LightTerminalThemeSection: function LightTerminalThemeSection() {
return null
},
TerminalThemeImportSection: function TerminalThemeImportSection() {
TerminalThemeCatalogSection: function TerminalThemeCatalogSection() {
return null
}
}))
@@ -255,13 +250,13 @@ function findButtons(node: unknown): { text: string; onClick: (() => void) | und
return buttons
}
function findDarkTerminalThemeSection(node: unknown): ReactElementLike | null {
function findTerminalThemeCatalogSection(node: unknown): ReactElementLike | null {
if (node == null) {
return null
}
if (Array.isArray(node)) {
for (const child of node) {
const found = findDarkTerminalThemeSection(child)
const found = findTerminalThemeCatalogSection(child)
if (found) {
return found
}
@@ -270,11 +265,11 @@ function findDarkTerminalThemeSection(node: unknown): ReactElementLike | null {
}
const el = node as ReactElementLike
const typeName = typeof el.type === 'function' ? el.type.name : String(el.type)
if (typeName === 'DarkTerminalThemeSection') {
if (typeName === 'TerminalThemeCatalogSection') {
return el
}
if (el.props?.children) {
return findDarkTerminalThemeSection(el.props.children)
return findTerminalThemeCatalogSection(el.props.children)
}
return null
}
@@ -303,30 +298,6 @@ function findGhosttyImportModal(node: unknown): ReactElementLike | null {
return null
}
function findTerminalThemeImportSection(node: unknown): ReactElementLike | null {
if (node == null) {
return null
}
if (Array.isArray(node)) {
for (const child of node) {
const found = findTerminalThemeImportSection(child)
if (found) {
return found
}
}
return null
}
const el = node as ReactElementLike
const typeName = typeof el.type === 'function' ? el.type.name : String(el.type)
if (typeName === 'TerminalThemeImportSection') {
return el
}
if (el.props?.children) {
return findTerminalThemeImportSection(el.props.children)
}
return null
}
function findWarpThemeImportModal(node: unknown): ReactElementLike | null {
if (node == null) {
return null
@@ -354,6 +325,7 @@ function findWarpThemeImportModal(node: unknown): ReactElementLike | null {
describe('TerminalAppearanceSection ghostty import wiring', () => {
beforeEach(() => {
mockStateValues.length = 0
mockSettingsSearchQuery = ''
resetMockState()
vi.unstubAllGlobals()
vi.stubGlobal('window', { location: { pathname: '/index.html' } })
@@ -378,7 +350,7 @@ describe('TerminalAppearanceSection ghostty import wiring', () => {
expect(ghosttyMock.handleClick).toHaveBeenCalled()
})
it('renders the shared theme import section above the theme pickers on desktop', () => {
it('passes shared theme import controls into the theme catalog on desktop', () => {
const element = TerminalAppearanceSection({
settings: {} as never,
updateSettings: () => {},
@@ -388,14 +360,65 @@ describe('TerminalAppearanceSection ghostty import wiring', () => {
warpThemes: warpThemesMock
})
// Why: imports land in one pool shared by both pickers, so the buttons
// live in their own section rather than inside the Dark Theme section.
const buttons = findButtons(element)
expect(buttons.some((button) => button.text === 'Import themes from Warp')).toBe(false)
expect(buttons.some((button) => button.text === 'Import from Warp')).toBe(false)
const importSection = findTerminalThemeImportSection(element)
expect(importSection?.props.warpThemes).toBe(warpThemesMock)
expect(findDarkTerminalThemeSection(element)).not.toBeNull()
const catalog = findTerminalThemeCatalogSection(element)
expect(catalog?.props.warpThemes).toBe(warpThemesMock)
expect(catalog?.props.showThemeImport).toBe(true)
})
it('routes dark and light theme searches to the matching catalog target', () => {
mockSettingsSearchQuery = 'Light Divider Color'
const lightElement = TerminalAppearanceSection({
settings: {} as never,
updateSettings: () => {},
systemPrefersDark: true,
terminalFontSuggestions: [],
ghostty: ghosttyMock,
warpThemes: warpThemesMock
})
expect(findTerminalThemeCatalogSection(lightElement)?.props.preferredTarget).toBe('light')
mockSettingsSearchQuery = 'Dark Theme'
resetMockState()
const darkElement = TerminalAppearanceSection({
settings: {} as never,
updateSettings: () => {},
systemPrefersDark: true,
terminalFontSuggestions: [],
ghostty: ghosttyMock,
warpThemes: warpThemesMock
})
expect(findTerminalThemeCatalogSection(darkElement)?.props.preferredTarget).toBe('dark')
mockSettingsSearchQuery = 'dark'
resetMockState()
const darkAliasElement = TerminalAppearanceSection({
settings: {} as never,
updateSettings: () => {},
systemPrefersDark: true,
terminalFontSuggestions: [],
ghostty: ghosttyMock,
warpThemes: warpThemesMock
})
expect(findTerminalThemeCatalogSection(darkAliasElement)?.props.preferredTarget).toBe('dark')
mockSettingsSearchQuery = 'dark terminal theme'
resetMockState()
const darkPhraseElement = TerminalAppearanceSection({
settings: {} as never,
updateSettings: () => {},
systemPrefersDark: true,
terminalFontSuggestions: [],
ghostty: ghosttyMock,
warpThemes: warpThemesMock
})
expect(findTerminalThemeCatalogSection(darkPhraseElement)?.props.preferredTarget).toBe('dark')
})
it('hides the theme import affordance on paired web clients', () => {
@@ -413,7 +436,7 @@ describe('TerminalAppearanceSection ghostty import wiring', () => {
warpThemes: warpThemesMock
})
expect(findTerminalThemeImportSection(element)).toBeNull()
expect(findTerminalThemeCatalogSection(element)?.props.showThemeImport).toBe(false)
expect(findWarpThemeImportModal(element)).toBeNull()
})
@@ -1,6 +1,10 @@
import { useState } from 'react'
import type { GlobalSettings } from '../../../../shared/types'
import { matchesSettingsSearch } from './settings-search'
import {
matchesSettingsSearch,
scoreSettingsSearch,
type SettingsSearchEntry
} from './settings-search'
import { useAppStore } from '../../store'
import {
getTerminalCursorSearchEntries,
@@ -8,16 +12,13 @@ import {
getTerminalGhosttyImportSearchEntries,
getTerminalLightThemeSearchEntries,
getTerminalPaneAppearanceSearchEntries,
getTerminalThemeTargetSearchEntries,
getTerminalTypographySearchEntries,
getTerminalWarpImportSearchEntries,
getTerminalWindowSearchEntries,
getTerminalYamlImportSearchEntries
} from './terminal-search'
import {
DarkTerminalThemeSection,
LightTerminalThemeSection,
TerminalThemeImportSection
} from './TerminalThemeSections'
import { TerminalThemeCatalogSection } from './TerminalThemeSections'
import { TerminalWindowSection } from './TerminalWindowSection'
import { TerminalTypographyAppearanceSection } from './TerminalTypographyAppearanceSection'
import { TerminalCursorAppearanceSection } from './TerminalCursorAppearanceSection'
@@ -37,6 +38,26 @@ type TerminalAppearanceSectionProps = {
warpThemes: UseWarpThemeImportReturn
}
type TerminalThemeTarget = 'dark' | 'light'
function scoreThemeTargetIntent(searchQuery: string, entries: SettingsSearchEntry[]): number {
// Why: descriptions mention dark/light incidentally; target intent should come from labels and aliases.
return scoreSettingsSearch(
searchQuery,
entries.map(({ title, keywords }) => ({ title, keywords }))
)
}
function getPreferredThemeTarget(
darkThemeSearchScore: number,
lightThemeSearchScore: number
): TerminalThemeTarget | undefined {
if (darkThemeSearchScore === lightThemeSearchScore) {
return undefined
}
return darkThemeSearchScore > lightThemeSearchScore ? 'dark' : 'light'
}
export function TerminalAppearanceSection({
settings,
updateSettings,
@@ -46,10 +67,28 @@ export function TerminalAppearanceSection({
warpThemes
}: TerminalAppearanceSectionProps): React.JSX.Element {
const searchQuery = useAppStore((state) => state.settingsSearchQuery)
const [themeSearchDark, setThemeSearchDark] = useState('')
const [themeSearchLight, setThemeSearchLight] = useState('')
const [themeSearch, setThemeSearch] = useState('')
const [previewFontFamily, setPreviewFontFamily] = useState<string | null>(null)
const showWarpThemeImport = !isWebClientLocation()
const darkThemeSearchEntries = getTerminalDarkThemeSearchEntries()
const lightThemeSearchEntries = getTerminalLightThemeSearchEntries()
const darkThemeSearchScore = scoreSettingsSearch(searchQuery, darkThemeSearchEntries)
const lightThemeSearchScore = scoreSettingsSearch(searchQuery, lightThemeSearchEntries)
const darkThemeTargetScore = scoreThemeTargetIntent(searchQuery, darkThemeSearchEntries)
const lightThemeTargetScore = scoreThemeTargetIntent(searchQuery, lightThemeSearchEntries)
const darkThemeMatches = darkThemeSearchScore > 0
const lightThemeMatches = lightThemeSearchScore > 0
const themeTargetMatches = matchesSettingsSearch(
searchQuery,
getTerminalThemeTargetSearchEntries()
)
const themeImportMatches =
showWarpThemeImport &&
(matchesSettingsSearch(searchQuery, getTerminalWarpImportSearchEntries()) ||
matchesSettingsSearch(searchQuery, getTerminalYamlImportSearchEntries()))
const showTerminalThemeCatalog =
darkThemeMatches || lightThemeMatches || themeTargetMatches || themeImportMatches
const preferredThemeTarget = getPreferredThemeTarget(darkThemeTargetScore, lightThemeTargetScore)
const visibleSections = [
matchesSettingsSearch(searchQuery, getTerminalGhosttyImportSearchEntries()) ||
@@ -82,31 +121,19 @@ export function TerminalAppearanceSection({
matchesSettingsSearch(searchQuery, getTerminalWindowSearchEntries()) ? (
<TerminalWindowSection key="window" settings={settings} updateSettings={updateSettings} />
) : null,
showWarpThemeImport &&
(matchesSettingsSearch(searchQuery, getTerminalWarpImportSearchEntries()) ||
matchesSettingsSearch(searchQuery, getTerminalYamlImportSearchEntries())) ? (
<TerminalThemeImportSection key="theme-import" warpThemes={warpThemes} />
) : null,
matchesSettingsSearch(searchQuery, getTerminalDarkThemeSearchEntries()) ? (
<DarkTerminalThemeSection
key="dark-theme"
showTerminalThemeCatalog ? (
<TerminalThemeCatalogSection
key={`theme-catalog-${preferredThemeTarget ?? 'manual'}`}
settings={settings}
systemPrefersDark={systemPrefersDark}
themeSearchDark={themeSearchDark}
setThemeSearchDark={setThemeSearchDark}
themeSearch={themeSearch}
setThemeSearch={setThemeSearch}
updateSettings={updateSettings}
previewFontFamily={previewFontFamily}
importedHighlightSignal={warpThemes.importSignal}
/>
) : null,
matchesSettingsSearch(searchQuery, getTerminalLightThemeSearchEntries()) ? (
<LightTerminalThemeSection
key="light-theme"
settings={settings}
themeSearchLight={themeSearchLight}
setThemeSearchLight={setThemeSearchLight}
updateSettings={updateSettings}
previewFontFamily={previewFontFamily}
warpThemes={warpThemes}
showThemeImport={showWarpThemeImport}
preferredTarget={preferredThemeTarget}
/>
) : null
].filter(Boolean)
@@ -122,10 +122,7 @@ vi.mock('./SettingsFormControls', () => ({
}))
vi.mock('./TerminalThemeSections', () => ({
DarkTerminalThemeSection: function DarkTerminalThemeSection() {
return null
},
LightTerminalThemeSection: function LightTerminalThemeSection() {
TerminalThemeCatalogSection: function TerminalThemeCatalogSection() {
return null
}
}))
@@ -1,18 +1,29 @@
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { GlobalSettings } from '../../../../shared/types'
import type { UseWarpThemeImportReturn } from './useWarpThemeImport'
let themeTarget: 'dark' | 'light' | undefined = 'dark'
vi.mock('react', async () => {
const actual = await vi.importActual<typeof import('react')>('react') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
return {
...actual,
useState: <T>(initial: T) => [
themeTarget ?? initial,
(value: T) => {
themeTarget = value as 'dark' | 'light'
}
]
}
})
vi.mock('./TerminalSettingsPreview', () => ({
TerminalSettingsPreview: function TerminalSettingsPreview() {
return null
}
}))
import {
DarkTerminalThemeSection,
LightTerminalThemeSection,
TerminalThemeImportSection
} from './TerminalThemeSections'
import { TerminalThemeCatalogSection } from './TerminalThemeSections'
type ReactElementLike = {
type: unknown
@@ -37,48 +48,43 @@ const warpThemesMock: UseWarpThemeImportReturn = {
handleOpenChange: vi.fn()
}
function findButtonTexts(node: unknown): string[] {
if (node == null || typeof node === 'string' || typeof node === 'number') {
return []
}
if (Array.isArray(node)) {
return node.flatMap(findButtonTexts)
}
const element = node as ReactElementLike
const typeName = typeof element.type === 'function' ? element.type.name : String(element.type)
if (typeName === 'WarpThemeImportButton') {
return ['Import themes from Warp']
}
if (typeName === 'YamlThemeImportButton') {
return ['Import from YAML']
}
return [...findButtonTexts(element.props?.children), ...findButtonTexts(element.props?.action)]
}
function renderDarkSection(): React.JSX.Element {
return DarkTerminalThemeSection({
settings: {
terminalThemeDark: 'Ghostty Default Style Dark',
terminalDividerColorDark: '#3f3f46'
} as GlobalSettings,
systemPrefersDark: true,
themeSearchDark: '',
setThemeSearchDark: () => {},
updateSettings: () => {},
previewFontFamily: null,
importedHighlightSignal: 0
})
}
function makeSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings {
return {
terminalUseSeparateLightTheme: false,
terminalThemeDark: 'Ghostty Default Style Dark',
terminalThemeLight: 'Builtin Tango Light',
terminalDividerColorDark: '#3f3f46',
terminalDividerColorLight: '#d4d4d8',
terminalCustomThemes: [],
...overrides
} as GlobalSettings
}
function renderCatalog(
settings = makeSettings(),
updateSettings = vi.fn(),
target?: 'dark' | 'light',
preferredTarget?: 'dark' | 'light'
): React.JSX.Element {
themeTarget = target
return TerminalThemeCatalogSection({
settings,
systemPrefersDark: true,
themeSearch: '',
setThemeSearch: () => {},
updateSettings,
previewFontFamily: null,
importedHighlightSignal: 7,
warpThemes: warpThemesMock,
showThemeImport: true,
preferredTarget
})
}
function getTypeName(node: ReactElementLike): string {
return typeof node.type === 'function' ? node.type.name : String(node.type)
}
function countElementsByTypeName(node: unknown, typeName: string): number {
if (node == null || typeof node === 'string' || typeof node === 'number') {
return 0
@@ -88,45 +94,239 @@ function countElementsByTypeName(node: unknown, typeName: string): number {
}
const element = node as ReactElementLike
const currentTypeName =
typeof element.type === 'function' ? element.type.name : String(element.type)
const childCount = countElementsByTypeName(element.props?.children, typeName)
return currentTypeName === typeName ? childCount + 1 : childCount
return getTypeName(element) === typeName ? childCount + 1 : childCount
}
function renderLightSection(settings: GlobalSettings): React.JSX.Element {
return LightTerminalThemeSection({
settings,
themeSearchLight: '',
setThemeSearchLight: () => {},
updateSettings: () => {},
previewFontFamily: null
})
function findElementByTypeName(node: unknown, typeName: string): ReactElementLike | null {
if (node == null || typeof node === 'string' || typeof node === 'number') {
return null
}
if (Array.isArray(node)) {
for (const child of node) {
const found = findElementByTypeName(child, typeName)
if (found) {
return found
}
}
return null
}
const element = node as ReactElementLike
if (getTypeName(element) === typeName) {
return element
}
return findElementByTypeName(element.props?.children, typeName)
}
describe('LightTerminalThemeSection preview lifecycle', () => {
it('does not mount the terminal preview while separate light theme is disabled', () => {
const element = renderLightSection(makeSettings({ terminalUseSeparateLightTheme: false }))
function findElementByClassSubstring(
node: unknown,
classNameSubstring: string
): ReactElementLike | null {
if (node == null || typeof node === 'string' || typeof node === 'number') {
return null
}
if (Array.isArray(node)) {
for (const child of node) {
const found = findElementByClassSubstring(child, classNameSubstring)
if (found) {
return found
}
}
return null
}
expect(countElementsByTypeName(element, 'TerminalSettingsPreview')).toBe(0)
const element = node as ReactElementLike
if (
typeof element.props?.className === 'string' &&
element.props.className.includes(classNameSubstring)
) {
return element
}
return findElementByClassSubstring(element.props?.children, classNameSubstring)
}
function findButtonTexts(node: unknown): string[] {
if (node == null || typeof node === 'string' || typeof node === 'number') {
return []
}
if (Array.isArray(node)) {
return node.flatMap(findButtonTexts)
}
const element = node as ReactElementLike
const typeName = getTypeName(element)
if (typeName === 'WarpThemeImportButton') {
return ['Import from Warp']
}
if (typeName === 'YamlThemeImportButton') {
return ['Import from YAML']
}
return [...findButtonTexts(element.props?.children), ...findButtonTexts(element.props?.action)]
}
describe('TerminalThemeCatalogSection', () => {
beforeEach(() => {
themeTarget = 'dark'
vi.clearAllMocks()
})
it('mounts the terminal preview when separate light theme is enabled', () => {
const element = renderLightSection(makeSettings({ terminalUseSeparateLightTheme: true }))
it('renders one theme picker and one preview for the active target', () => {
const element = renderCatalog(makeSettings({ terminalUseSeparateLightTheme: false }), vi.fn())
expect(countElementsByTypeName(element, 'ThemePicker')).toBe(1)
expect(countElementsByTypeName(element, 'TerminalSettingsPreview')).toBe(1)
expect(findElementByTypeName(element, 'TerminalSettingsPreview')?.props?.modeOverride).toBe(
'dark'
)
})
it('keeps the light target enabled while separate light theme is disabled', () => {
const element = renderCatalog(makeSettings({ terminalUseSeparateLightTheme: false }), vi.fn())
const targetControl = findElementByTypeName(element, 'SettingsSegmentedControl')
const options = targetControl?.props?.options as readonly {
value: string
disabled?: boolean
}[]
expect(options.find((option) => option.value === 'light')?.disabled).toBeUndefined()
})
it('uses the preferred target when opened from a light-specific search', () => {
const element = renderCatalog(
makeSettings({ terminalUseSeparateLightTheme: true }),
vi.fn(),
undefined,
'light'
)
const picker = findElementByTypeName(element, 'ThemePicker')
const preview = findElementByTypeName(element, 'TerminalSettingsPreview')
expect(picker?.props?.selectedTheme).toBe('Builtin Tango Light')
expect(preview?.props?.modeOverride).toBe('light')
})
it('updates the dark theme from the catalog when the dark target is active', () => {
const updateSettings = vi.fn()
const element = renderCatalog(makeSettings(), updateSettings, 'dark')
const picker = findElementByTypeName(element, 'ThemePicker')
const selectTheme = picker?.props?.onSelectTheme as (theme: string) => void
selectTheme('Builtin Solarized Dark')
expect(updateSettings).toHaveBeenCalledWith({ terminalThemeDark: 'Builtin Solarized Dark' })
})
it('updates the light theme from the catalog when the light target is active', () => {
const updateSettings = vi.fn()
const element = renderCatalog(
makeSettings({ terminalUseSeparateLightTheme: true }),
updateSettings,
'light'
)
const picker = findElementByTypeName(element, 'ThemePicker')
const selectTheme = picker?.props?.onSelectTheme as (theme: string) => void
selectTheme('Builtin Tango Light')
expect(updateSettings).toHaveBeenCalledWith({ terminalThemeLight: 'Builtin Tango Light' })
})
it('turns match dark mode off to customize light mode', () => {
const updateSettings = vi.fn()
const element = renderCatalog(
makeSettings({ terminalUseSeparateLightTheme: false }),
updateSettings,
'light'
)
const matchDarkModeSwitch = findElementByTypeName(element, 'SettingsSwitchRow')
const toggleMatchDarkMode = matchDarkModeSwitch?.props?.onChange as () => void
expect(matchDarkModeSwitch?.props?.label).toBe('Match dark mode')
expect(matchDarkModeSwitch?.props?.description).toBe(
'Share the dark terminal theme and divider color in light mode.'
)
expect(matchDarkModeSwitch?.props?.checked).toBe(true)
toggleMatchDarkMode()
expect(updateSettings).toHaveBeenCalledWith({ terminalUseSeparateLightTheme: true })
})
it('turns match dark mode on from the customized light state', () => {
const updateSettings = vi.fn()
const element = renderCatalog(
makeSettings({ terminalUseSeparateLightTheme: true }),
updateSettings,
'light'
)
const matchDarkModeSwitch = findElementByTypeName(element, 'SettingsSwitchRow')
const toggleMatchDarkMode = matchDarkModeSwitch?.props?.onChange as () => void
expect(matchDarkModeSwitch?.props?.checked).toBe(false)
toggleMatchDarkMode()
expect(updateSettings).toHaveBeenCalledWith({ terminalUseSeparateLightTheme: false })
})
it('collapses light customization with a transition while matching dark mode', () => {
const element = renderCatalog(
makeSettings({ terminalUseSeparateLightTheme: false }),
vi.fn(),
'light'
)
const preview = findElementByTypeName(element, 'TerminalSettingsPreview')
const matchDarkModeSwitch = findElementByTypeName(element, 'SettingsSwitchRow')
const transitionRegion = findElementByClassSubstring(
element,
'transition-[grid-template-rows,padding-top]'
)
expect(matchDarkModeSwitch?.props?.label).toBe('Match dark mode')
expect(transitionRegion?.props?.className).toContain('grid-rows-[0fr]')
expect(transitionRegion?.props?.['aria-hidden']).toBe(true)
expect(transitionRegion?.props?.inert).toBe(true)
expect(countElementsByTypeName(element, 'ThemePicker')).toBe(1)
expect(countElementsByTypeName(element, 'ColorField')).toBe(1)
expect(preview?.props?.modeOverride).toBe('light')
})
it('uses the active target for divider color updates', () => {
const updateSettings = vi.fn()
const lightElement = renderCatalog(
makeSettings({ terminalUseSeparateLightTheme: true }),
updateSettings,
'light'
)
const lightColorField = findElementByTypeName(lightElement, 'ColorField')
const updateLightDividerColor = lightColorField?.props?.onChange as (value: string) => void
updateLightDividerColor('#ffffff')
expect(updateSettings).toHaveBeenCalledWith({ terminalDividerColorLight: '#ffffff' })
updateSettings.mockClear()
const darkElement = renderCatalog(makeSettings(), updateSettings, 'dark')
const darkColorField = findElementByTypeName(darkElement, 'ColorField')
const updateDarkDividerColor = darkColorField?.props?.onChange as (value: string) => void
updateDarkDividerColor('#000000')
expect(updateSettings).toHaveBeenCalledWith({ terminalDividerColorDark: '#000000' })
})
it('passes imported theme highlight signals into the shared picker', () => {
const element = renderCatalog()
const picker = findElementByTypeName(element, 'ThemePicker')
expect(picker?.props?.importedHighlightSignal).toBe(7)
})
})
describe('TerminalThemeImportSection', () => {
it('renders the Warp and YAML import buttons in the shared import section', () => {
const buttonTexts = findButtonTexts(TerminalThemeImportSection({ warpThemes: warpThemesMock }))
describe('Terminal theme imports', () => {
it('renders the Warp and YAML import buttons inside the combined theme catalog', () => {
const buttonTexts = findButtonTexts(renderCatalog())
expect(buttonTexts).toContain('Import themes from Warp')
expect(buttonTexts).toContain('Import from YAML')
})
it('keeps the import buttons out of the mode-specific theme sections', () => {
expect(findButtonTexts(renderDarkSection())).toEqual([])
expect(buttonTexts).toEqual(['Import from Warp', 'Import from YAML'])
})
})
@@ -1,6 +1,12 @@
import type { Dispatch, SetStateAction } from 'react'
import { useState, type Dispatch, type SetStateAction } from 'react'
import type { GlobalSettings } from '../../../../shared/types'
import { ColorField, SettingsSubsectionHeader, ThemePicker } from './SettingsFormControls'
import {
ColorField,
SettingsSegmentedControl,
SettingsSwitchRow,
SettingsSubsectionHeader,
ThemePicker
} from './SettingsFormControls'
import { SearchableSetting } from './SearchableSetting'
import { TerminalSettingsPreview } from './TerminalSettingsPreview'
import { WarpThemeImportButton } from './WarpThemeImportButton'
@@ -8,292 +14,258 @@ import { YamlThemeImportButton } from './YamlThemeImportButton'
import type { UseWarpThemeImportReturn } from './useWarpThemeImport'
import { getAvailableTerminalThemeOptions } from '@/lib/terminal-theme'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
type DarkTerminalThemeSectionProps = {
type TerminalThemeTarget = 'dark' | 'light'
type TerminalThemeCatalogSectionProps = {
settings: GlobalSettings
systemPrefersDark: boolean
themeSearchDark: string
setThemeSearchDark: Dispatch<SetStateAction<string>>
themeSearch: string
setThemeSearch: Dispatch<SetStateAction<string>>
updateSettings: (updates: Partial<GlobalSettings>) => void
previewFontFamily: string | null
importedHighlightSignal: number
}
type LightTerminalThemeSectionProps = {
settings: GlobalSettings
themeSearchLight: string
setThemeSearchLight: Dispatch<SetStateAction<string>>
updateSettings: (updates: Partial<GlobalSettings>) => void
previewFontFamily: string | null
}
/** Shared import affordance for terminal themes. Why: imported themes land in
* one pool used by both the dark and light pickers, so the buttons live above
* both sections rather than implying a mode-specific import. */
export function TerminalThemeImportSection({
warpThemes
}: {
warpThemes: UseWarpThemeImportReturn
}): React.JSX.Element {
return (
<section className="space-y-3">
<SettingsSubsectionHeader
title={translate(
'auto.components.settings.TerminalThemeSections.import_themes_title',
'Import Themes'
)}
description={translate(
'auto.components.settings.TerminalThemeSections.import_themes_description',
'Imported themes are available in both the dark and light theme pickers.'
)}
/>
<div className="flex flex-wrap items-center gap-2">
<WarpThemeImportButton warpThemes={warpThemes} />
<YamlThemeImportButton warpThemes={warpThemes} />
</div>
</section>
)
showThemeImport: boolean
preferredTarget?: TerminalThemeTarget
}
export function DarkTerminalThemeSection({
export function TerminalThemeCatalogSection({
settings,
systemPrefersDark,
themeSearchDark,
setThemeSearchDark,
themeSearch,
setThemeSearch,
updateSettings,
previewFontFamily,
importedHighlightSignal
}: DarkTerminalThemeSectionProps): React.JSX.Element {
importedHighlightSignal,
warpThemes,
showThemeImport,
preferredTarget
}: TerminalThemeCatalogSectionProps): React.JSX.Element {
const [target, setTarget] = useState<TerminalThemeTarget>(preferredTarget ?? 'dark')
const themeOptions = getAvailableTerminalThemeOptions(settings)
const isLightTarget = target === 'light'
const matchDarkMode = !settings.terminalUseSeparateLightTheme
const lightModeMatchesDark = isLightTarget && matchDarkMode
const showCustomControls = !lightModeMatchesDark
const selectedTheme = isLightTarget ? settings.terminalThemeLight : settings.terminalThemeDark
const pickerTitle = isLightTarget
? translate('auto.components.settings.TerminalThemeSections.8273bc75d7', 'Light Theme')
: translate('auto.components.settings.TerminalThemeSections.9499ad1dc4', 'Dark Theme')
const pickerDescription = isLightTarget
? translate(
'auto.components.settings.TerminalThemeSections.d56af60e6f',
'Choose the theme used when Orca is in light mode.'
)
: translate(
'auto.components.settings.TerminalThemeSections.7add204bd5',
'Choose the terminal theme used in dark mode.'
)
const dividerTitle = isLightTarget
? translate('auto.components.settings.TerminalThemeSections.ec2e33ad80', 'Light Divider Color')
: translate('auto.components.settings.TerminalThemeSections.b739d2abfe', 'Dark Divider Color')
const dividerDescription = isLightTarget
? translate(
'auto.components.settings.TerminalThemeSections.5e0c24b5c8',
'Controls the split divider line between panes in light mode.'
)
: translate(
'auto.components.settings.TerminalThemeSections.cbe56a0f79',
'Controls the split divider line between panes in dark mode.'
)
return (
<section className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
<div className="space-y-6">
<SettingsSubsectionHeader
title={translate(
'auto.components.settings.TerminalThemeSections.9499ad1dc4',
'Dark Theme'
)}
description={translate(
'auto.components.settings.TerminalThemeSections.f012172e21',
'Choose the theme used for terminal panes in dark mode.'
)}
/>
<SearchableSetting
title={translate(
'auto.components.settings.TerminalThemeSections.9499ad1dc4',
'Dark Theme'
)}
description={translate(
'auto.components.settings.TerminalThemeSections.7add204bd5',
'Choose the terminal theme used in dark mode.'
)}
keywords={['terminal', 'theme', 'dark', 'preview']}
>
<ThemePicker
label={translate(
'auto.components.settings.TerminalThemeSections.9499ad1dc4',
'Dark Theme'
)}
description={translate(
'auto.components.settings.TerminalThemeSections.7add204bd5',
'Choose the terminal theme used in dark mode.'
)}
selectedTheme={settings.terminalThemeDark}
themeOptions={themeOptions}
query={themeSearchDark}
onQueryChange={setThemeSearchDark}
onSelectTheme={(theme) => updateSettings({ terminalThemeDark: theme })}
importedHighlightSignal={importedHighlightSignal}
/>
</SearchableSetting>
<SearchableSetting
title={translate(
'auto.components.settings.TerminalThemeSections.b739d2abfe',
'Dark Divider Color'
)}
description={translate(
'auto.components.settings.TerminalThemeSections.cbe56a0f79',
'Controls the split divider line between panes in dark mode.'
)}
keywords={['terminal', 'divider', 'dark', 'color']}
>
<ColorField
label={translate(
'auto.components.settings.TerminalThemeSections.b739d2abfe',
'Dark Divider Color'
)}
description={translate(
'auto.components.settings.TerminalThemeSections.cbe56a0f79',
'Controls the split divider line between panes in dark mode.'
)}
value={settings.terminalDividerColorDark}
fallback="#3f3f46"
onChange={(value) => updateSettings({ terminalDividerColorDark: value })}
/>
</SearchableSetting>
</div>
<TerminalSettingsPreview
<section className="space-y-5">
<SettingsSubsectionHeader
title={translate(
'auto.components.settings.TerminalThemeSections.bc8e8a251a',
'Dark Mode Preview'
)}
settings={settings}
systemPrefersDark={systemPrefersDark}
previewFontFamily={previewFontFamily}
modeOverride="dark"
/>
</section>
)
}
export function LightTerminalThemeSection({
settings,
themeSearchLight,
setThemeSearchLight,
updateSettings,
previewFontFamily
}: LightTerminalThemeSectionProps): React.JSX.Element {
const themeOptions = getAvailableTerminalThemeOptions(settings)
return (
<section className="space-y-4">
<SearchableSetting
title={translate(
'auto.components.settings.TerminalThemeSections.d76f60c9cc',
'Use Separate Theme In Light Mode'
'auto.components.settings.TerminalThemeSections.catalog_title',
'Terminal Themes'
)}
description={translate(
'auto.components.settings.TerminalThemeSections.b584287e84',
'When disabled, light mode reuses the dark terminal theme.'
'auto.components.settings.TerminalThemeSections.catalog_description',
'Choose terminal themes and divider colors for dark and light mode.'
)}
keywords={['terminal', 'light mode', 'theme']}
className="flex items-center justify-between gap-4 py-2"
>
<div className="space-y-0.5">
<p className="text-sm font-medium">
{translate(
'auto.components.settings.TerminalThemeSections.d76f60c9cc',
'Use Separate Theme In Light Mode'
)}
</p>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.TerminalThemeSections.b584287e84',
'When disabled, light mode reuses the dark terminal theme.'
)}
</p>
</div>
<button
role="switch"
aria-checked={settings.terminalUseSeparateLightTheme}
onClick={() =>
updateSettings({
terminalUseSeparateLightTheme: !settings.terminalUseSeparateLightTheme
})
}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
settings.terminalUseSeparateLightTheme ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
settings.terminalUseSeparateLightTheme ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
</SearchableSetting>
/>
{settings.terminalUseSeparateLightTheme ? (
<div className="grid overflow-hidden pt-2">
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
<div className="space-y-6">
<div className="space-y-1">
<h3 className="text-sm font-semibold">
{showThemeImport ? (
<div className="flex flex-wrap items-center gap-2">
<WarpThemeImportButton warpThemes={warpThemes} />
<YamlThemeImportButton warpThemes={warpThemes} />
</div>
) : null}
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
<div>
<div className="space-y-3">
<SearchableSetting
title={translate(
'auto.components.settings.TerminalThemeSections.target_title',
'Theme Mode'
)}
keywords={['terminal', 'theme', 'dark', 'light']}
forceVisible
>
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">
{translate(
'auto.components.settings.TerminalThemeSections.8273bc75d7',
'Light Theme'
)}
</h3>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.TerminalThemeSections.74b15574c8',
'Configure the optional light-mode terminal appearance.'
'auto.components.settings.TerminalThemeSections.target_title',
'Theme Mode'
)}
</p>
<SettingsSegmentedControl
value={target}
onChange={setTarget}
ariaLabel={translate(
'auto.components.settings.TerminalThemeSections.target_aria',
'Terminal theme mode'
)}
equalWidth
options={[
{
value: 'dark',
label: translate(
'auto.components.settings.TerminalThemeSections.target_dark',
'Dark'
)
},
{
value: 'light',
label: translate(
'auto.components.settings.TerminalThemeSections.target_light',
'Light'
)
}
]}
/>
</div>
</SearchableSetting>
{isLightTarget ? (
<SearchableSetting
title={translate(
'auto.components.settings.TerminalThemeSections.8273bc75d7',
'Light Theme'
'auto.components.settings.TerminalThemeSections.match_dark_mode',
'Match dark mode'
)}
description={translate(
'auto.components.settings.TerminalThemeSections.d56af60e6f',
'Choose the theme used when Orca is in light mode.'
)}
keywords={['terminal', 'theme', 'light', 'preview']}
keywords={['terminal', 'light mode', 'theme', 'match dark']}
forceVisible
>
<ThemePicker
<SettingsSwitchRow
label={translate(
'auto.components.settings.TerminalThemeSections.8273bc75d7',
'Light Theme'
'auto.components.settings.TerminalThemeSections.match_dark_mode',
'Match dark mode'
)}
description={translate(
'auto.components.settings.TerminalThemeSections.d56af60e6f',
'Choose the theme used when Orca is in light mode.'
'auto.components.settings.TerminalThemeSections.match_dark_mode_description',
'Share the dark terminal theme and divider color in light mode.'
)}
selectedTheme={settings.terminalThemeLight}
checked={matchDarkMode}
onChange={() =>
// The legacy setting stores the inverse; the UI exposes the matching concept.
updateSettings({
terminalUseSeparateLightTheme: !settings.terminalUseSeparateLightTheme
})
}
/>
</SearchableSetting>
) : null}
</div>
<div
className={cn(
'grid overflow-hidden transition-[grid-template-rows,padding-top] duration-200 ease-out',
showCustomControls ? 'grid-rows-[1fr] pt-6' : 'grid-rows-[0fr] pt-0'
)}
aria-hidden={!showCustomControls}
inert={!showCustomControls}
>
<div
className={cn(
'min-h-0 space-y-6 transition-[opacity,transform] duration-150 ease-out',
showCustomControls
? 'translate-y-0 opacity-100'
: 'pointer-events-none -translate-y-1 opacity-0'
)}
>
<SearchableSetting
title={pickerTitle}
description={pickerDescription}
keywords={['terminal', 'theme', 'dark', 'light', 'preview']}
forceVisible
>
<ThemePicker
label={pickerTitle}
description={pickerDescription}
selectedTheme={selectedTheme}
themeOptions={themeOptions}
query={themeSearchLight}
onQueryChange={setThemeSearchLight}
onSelectTheme={(theme) => updateSettings({ terminalThemeLight: theme })}
query={themeSearch}
onQueryChange={setThemeSearch}
onSelectTheme={(theme) =>
updateSettings(
isLightTarget ? { terminalThemeLight: theme } : { terminalThemeDark: theme }
)
}
importedHighlightSignal={importedHighlightSignal}
/>
</SearchableSetting>
<SearchableSetting
title={translate(
'auto.components.settings.TerminalThemeSections.ec2e33ad80',
'Light Divider Color'
)}
description={translate(
'auto.components.settings.TerminalThemeSections.5e0c24b5c8',
'Controls the split divider line between panes in light mode.'
)}
keywords={['terminal', 'divider', 'light', 'color']}
title={dividerTitle}
description={dividerDescription}
keywords={['terminal', 'divider', 'dark', 'light', 'color']}
forceVisible
>
<ColorField
label={translate(
'auto.components.settings.TerminalThemeSections.ec2e33ad80',
'Light Divider Color'
)}
description={translate(
'auto.components.settings.TerminalThemeSections.5e0c24b5c8',
'Controls the split divider line between panes in light mode.'
)}
value={settings.terminalDividerColorLight}
fallback="#d4d4d8"
onChange={(value) => updateSettings({ terminalDividerColorLight: value })}
label={dividerTitle}
description={dividerDescription}
value={
isLightTarget
? settings.terminalDividerColorLight
: settings.terminalDividerColorDark
}
fallback={isLightTarget ? '#d4d4d8' : '#3f3f46'}
onChange={(value) =>
updateSettings(
isLightTarget
? { terminalDividerColorLight: value }
: { terminalDividerColorDark: value }
)
}
/>
</SearchableSetting>
</div>
<TerminalSettingsPreview
title={translate(
'auto.components.settings.TerminalThemeSections.db210115c5',
'Light Mode Preview'
)}
settings={settings}
systemPrefersDark={false}
previewFontFamily={previewFontFamily}
modeOverride="light"
/>
</div>
</div>
) : null}
<TerminalSettingsPreview
title={
isLightTarget
? translate(
'auto.components.settings.TerminalThemeSections.db210115c5',
'Light Mode Preview'
)
: translate(
'auto.components.settings.TerminalThemeSections.bc8e8a251a',
'Dark Mode Preview'
)
}
description={
isLightTarget
? translate(
'auto.components.settings.TerminalThemeSections.light_preview_description',
'Shows the effective light terminal appearance.'
)
: translate(
'auto.components.settings.TerminalThemeSections.dark_preview_description',
'Shows the effective dark terminal appearance.'
)
}
settings={settings}
systemPrefersDark={systemPrefersDark}
previewFontFamily={previewFontFamily}
modeOverride={target}
/>
</div>
</section>
)
}
@@ -18,7 +18,7 @@ export function WarpThemeImportButton({
onClick={() => void warpThemes.handleClick()}
>
<WarpIcon className="size-4" />
{translate('auto.components.settings.WarpThemeImportModal.title', 'Import themes from Warp')}
{translate('auto.components.settings.WarpThemeImportModal.title', 'Import from Warp')}
</Button>
)
}
@@ -86,7 +86,7 @@ export function WarpThemeImportModal({
)
: translate(
'auto.components.settings.WarpThemeImportModal.title',
'Import themes from Warp'
'Import from Warp'
)}
</DialogTitle>
<DialogDescription className="text-xs">
@@ -122,12 +122,32 @@ describe('getTerminalPaneSearchEntries', () => {
)
})
it.each([
'dark',
'light',
'divider',
'preview',
'theme',
'dark terminal theme',
'target',
'editing',
'Match dark mode',
'Customize Light Mode',
'Match dark mode terminal theme',
'Use Separate Theme In Light Mode',
'import',
'Warp',
'YAML'
])('matches terminal appearance search for %s', (query) => {
expect(matchesSettingsSearch(query, getAppearancePaneSearchEntries())).toBe(true)
})
it('omits the Warp import appearance entry when desktop-only controls are hidden', () => {
const desktopEntries = getAppearancePaneSearchEntries({ showWarpImport: true })
const webEntries = getAppearancePaneSearchEntries({ showWarpImport: false })
expect(desktopEntries.some((entry) => entry.title === 'Import themes from Warp')).toBe(true)
expect(webEntries.some((entry) => entry.title === 'Import themes from Warp')).toBe(false)
expect(desktopEntries.some((entry) => entry.title === 'Import from Warp')).toBe(true)
expect(webEntries.some((entry) => entry.title === 'Import from Warp')).toBe(false)
expect(webEntries.some((entry) => entry.title === 'Import from Ghostty')).toBe(true)
})
@@ -12,6 +12,7 @@ import {
import {
getTerminalDarkThemeSearchEntries,
getTerminalLightThemeSearchEntries,
getTerminalThemeTargetSearchEntries,
getTerminalWarpImportSearchEntries,
getTerminalYamlImportSearchEntries
} from './terminal-theme-search'
@@ -44,6 +45,7 @@ export {
export {
getTerminalDarkThemeSearchEntries,
getTerminalLightThemeSearchEntries,
getTerminalThemeTargetSearchEntries,
getTerminalWarpImportSearchEntries,
getTerminalYamlImportSearchEntries
} from './terminal-theme-search'
@@ -68,6 +70,7 @@ const getTerminalAppearanceSearchEntriesWithoutWarp = createLocalizedCatalog(
...getTerminalTypographySearchEntries(),
...getTerminalCursorSearchEntries(),
...getTerminalPaneAppearanceSearchEntries(),
...getTerminalThemeTargetSearchEntries(),
...getTerminalDarkThemeSearchEntries(),
...getTerminalLightThemeSearchEntries(),
...getTerminalWindowSearchEntries(),
@@ -12,7 +12,9 @@ export const getTerminalDarkThemeSearchEntries = createLocalizedCatalog(() => [
keywords: [
...translateSearchKeyword('auto.components.settings.terminal.search.f66a7cf715', 'terminal'),
...translateSearchKeyword('auto.components.settings.terminal.search.0ce176909a', 'theme'),
...translateSearchKeyword('auto.components.settings.terminal.search.f785374072', 'dark'),
...translateSearchKeyword('auto.components.settings.terminal.search.f785374072', 'dark', {
aliases: ['dark terminal theme']
}),
...translateSearchKeyword('auto.components.settings.terminal.search.7718d70356', 'preview')
]
},
@@ -31,15 +33,33 @@ export const getTerminalDarkThemeSearchEntries = createLocalizedCatalog(() => [
}
])
export const getTerminalThemeTargetSearchEntries = createLocalizedCatalog(() => [
{
title: translate('auto.components.settings.terminal.search.theme_target.title', 'Theme Mode'),
keywords: [
...translateSearchKeyword('auto.components.settings.terminal.search.f66a7cf715', 'terminal'),
...translateSearchKeyword('auto.components.settings.terminal.search.0ce176909a', 'theme'),
...translateSearchKeyword(
'auto.components.settings.terminal.search.theme_target.keyword_target',
'target'
),
...translateSearchKeyword(
'auto.components.settings.terminal.search.theme_target.keyword_editing',
'editing'
)
]
}
])
export const getTerminalLightThemeSearchEntries = createLocalizedCatalog(() => [
{
title: translate(
'auto.components.settings.terminal.search.232e532169',
'Use Separate Theme In Light Mode'
'auto.components.settings.terminal.search.match_dark_mode_title',
'Match dark mode'
),
description: translate(
'auto.components.settings.terminal.search.f268092ee3',
'When disabled, light mode reuses the dark terminal theme.'
'Light mode can use its own terminal theme.'
),
keywords: [
...translateSearchKeyword('auto.components.settings.terminal.search.f66a7cf715', 'terminal'),
@@ -47,6 +67,18 @@ export const getTerminalLightThemeSearchEntries = createLocalizedCatalog(() => [
'auto.components.settings.terminal.search.da864e6cec',
'light mode'
),
...translateSearchKeyword(
'auto.components.settings.terminal.search.match_dark_mode_title',
'Match dark mode',
{
aliases: [
'Customize Light Mode',
'Use separate light theme',
'Use Separate Theme In Light Mode',
'Match dark mode terminal theme'
]
}
),
...translateSearchKeyword('auto.components.settings.terminal.search.0ce176909a', 'theme')
]
},
@@ -58,7 +90,9 @@ export const getTerminalLightThemeSearchEntries = createLocalizedCatalog(() => [
),
keywords: [
...translateSearchKeyword('auto.components.settings.terminal.search.f66a7cf715', 'terminal'),
...translateSearchKeyword('auto.components.settings.terminal.search.0ce176909a', 'theme'),
...translateSearchKeyword('auto.components.settings.terminal.search.0ce176909a', 'theme', {
aliases: ['light terminal theme']
}),
...translateSearchKeyword('auto.components.settings.terminal.search.411229c636', 'light'),
...translateSearchKeyword('auto.components.settings.terminal.search.7718d70356', 'preview')
]
@@ -103,7 +137,7 @@ export const getTerminalWarpImportSearchEntries = createLocalizedCatalog(() => [
{
title: translate(
'auto.components.settings.terminal.search.warp_import.title',
'Import themes from Warp'
'Import from Warp'
),
description: translate(
'auto.components.settings.terminal.search.warp_import.description',
@@ -115,7 +149,11 @@ export const getTerminalWarpImportSearchEntries = createLocalizedCatalog(() => [
translate('auto.components.settings.terminal.search.f66a7cf715', 'terminal'),
translate('auto.components.settings.terminal.search.0ce176909a', 'theme'),
translate('auto.components.settings.terminal.search.warp_import.keyword_themes', 'themes'),
translate('auto.components.settings.terminal.search.warp_import.keyword_yaml', 'yaml')
translate('auto.components.settings.terminal.search.warp_import.keyword_yaml', 'yaml'),
translate(
'auto.components.settings.terminal.search.warp_import.keyword_legacy_title',
'Import themes from Warp'
)
]
}
])
@@ -29,6 +29,7 @@ import type { AppState } from '@/store/types'
import {
getAllWorktreesFromState,
useAllWorktrees,
useProjectHostSetupProjection,
useRepoMap,
useWorktreeMap
} from '@/store/selectors'
@@ -5332,11 +5333,13 @@ const WorktreeList = React.memo(function WorktreeList({
// Why: manual repo header order is bound to state.repos. Recent/Smart derive
// header order from the sorted visible worktree stream instead.
const repos = useAppStore((s) => s.repos)
const projects = useAppStore((s) => s.projects)
const projectHostSetups = useAppStore((s) => s.projectHostSetups)
const projectHostSetupProjection = useProjectHostSetupProjection()
const projectGrouping = useMemo(
() => ({ projects, projectHostSetups }),
[projectHostSetups, projects]
() => ({
projects: projectHostSetupProjection.projects,
projectHostSetups: projectHostSetupProjection.setups
}),
[projectHostSetupProjection]
)
const projectGroups = useAppStore((s) => s.projectGroups ?? EMPTY_PROJECT_GROUPS)
const folderWorkspaces = useAppStore((s) => s.folderWorkspaces)
@@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { getExecutionHostLabel } from '../../../../shared/execution-host'
import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection'
import {
ALL_GROUP_META,
buildRows,
@@ -361,6 +362,105 @@ describe('buildRows with pinned worktrees', () => {
])
})
it('renders same-project records with git remote identity as one mixed-host project header', () => {
const localRepo: Repo = {
...repo,
id: 'local-sample-app',
path: '/Users/alice/work/sample-app',
displayName: 'sample-app',
gitRemoteIdentity: {
canonicalKey: 'git.company.test/team/sample-app',
remoteName: 'origin',
remoteUrl: 'git@git.company.test:team/sample-app.git'
}
}
const sshRepo: Repo = {
...repo,
id: 'ssh-sample-app',
path: '/home/alice/src/sample-app',
displayName: 'sample-app',
connectionId: 'build server',
gitRemoteIdentity: {
canonicalKey: 'git.company.test/team/sample-app',
remoteName: 'origin',
remoteUrl: 'https://git.company.test/team/sample-app.git'
}
}
const runtimeRepo: Repo = {
...repo,
id: 'runtime-sample-app',
path: '/workspace/sample-app',
displayName: 'sample-app',
executionHostId: 'runtime:dev-container',
gitRemoteIdentity: {
canonicalKey: 'git.company.test/team/sample-app',
remoteName: 'origin',
remoteUrl: 'ssh://git@git.company.test/team/sample-app.git'
}
}
const localWorktree: Worktree = {
...worktree,
id: 'wt-local-sample-app',
repoId: localRepo.id,
path: '/Users/alice/work/sample-app-feature'
}
const sshWorktree: Worktree = {
...worktree,
id: 'wt-ssh-sample-app',
repoId: sshRepo.id,
path: '/home/alice/src/sample-app-feature'
}
const runtimeWorktree: Worktree = {
...worktree,
id: 'wt-runtime-sample-app',
repoId: runtimeRepo.id,
path: '/workspace/sample-app-feature'
}
const projection = projectHostSetupProjectionFromRepos([localRepo, sshRepo, runtimeRepo])
const rows = buildRows(
'repo',
[localWorktree, sshWorktree, runtimeWorktree],
new Map([
[localRepo.id, localRepo],
[sshRepo.id, sshRepo],
[runtimeRepo.id, runtimeRepo]
]),
null,
new Set(),
undefined,
undefined,
undefined,
{},
new Map([
[localWorktree.id, localWorktree],
[sshWorktree.id, sshWorktree],
[runtimeWorktree.id, runtimeWorktree]
]),
false,
undefined,
[],
new Set(),
new Map(),
[],
{
projects: projection.projects,
projectHostSetups: projection.setups
}
)
expect(rows).toMatchObject([
{
type: 'header',
key: 'project:git:git.company.test/team/sample-app',
label: 'sample-app',
count: 3
},
{ type: 'item', worktree: { id: localWorktree.id }, hostContextLabel: LOCAL_HOST_LABEL },
{ type: 'item', worktree: { id: sshWorktree.id }, hostContextLabel: 'build server' },
{ type: 'item', worktree: { id: runtimeWorktree.id }, hostContextLabel: 'dev-container' }
])
})
it('orders project identity headers by the manual repo order anchor', () => {
const analyticsProject: Project = {
...project,
@@ -2506,6 +2606,13 @@ describe('WorktreeList header styles', () => {
expect(source).toContain('headerKey: row.key')
expect(source).toContain('color={repoHeaderColor}')
})
it('adapts projected setup rows for sidebar project grouping', () => {
const source = readWorktreeListSource()
expect(source).toContain('const projectHostSetupProjection = useProjectHostSetupProjection()')
expect(source).toContain('projectHostSetups: projectHostSetupProjection.setups')
})
})
describe('buildRows pending creations', () => {
@@ -21,6 +21,11 @@ import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion
import { splitTerminalPaneWithInheritedCwd } from './terminal-pane-split-with-inherited-cwd'
import { useAppStore } from '@/store'
import { recordTerminalUserInputForLeaf } from './terminal-input-activity'
import {
markTerminalFollowOutput,
markTerminalPinnedViewport,
syncTerminalScrollIntentFromViewport
} from '@/lib/pane-manager/terminal-scroll-intent'
export function recordKeyboardCreatedTerminalPaneSplit(
createdPane: unknown,
@@ -314,9 +319,13 @@ export function useTerminalKeyboardShortcuts({
return
}
if (action.position === 'top') {
markTerminalPinnedViewport(pane.terminal)
pane.terminal.scrollToLine(0)
syncTerminalScrollIntentFromViewport(pane.terminal)
} else {
markTerminalFollowOutput(pane.terminal)
pane.terminal.scrollToBottom()
syncTerminalScrollIntentFromViewport(pane.terminal)
}
return
}
@@ -6255,6 +6255,12 @@ describe('connectPanePty', () => {
const pane = createPane(1)
pane.terminal.buffer.active.viewportY = 42
pane.terminal.buffer.active.baseY = 100
pane.terminal.write.mockImplementation((data: string, callback?: () => void) => {
if (data.includes('snapshot-state')) {
pane.terminal.buffer.active.viewportY = 0
}
callback?.()
})
const manager = createManager(1)
const deps = createDeps({
isVisibleRef: { current: false }
@@ -58,8 +58,11 @@ import {
} from '@/lib/pane-manager/pane-terminal-output-scheduler'
import { recordAgentHibernationPaneOutput } from '@/lib/agent-hibernation-output-activity'
import { isLocalNativeWindowsConpty } from '@/lib/pane-manager/windows-pty-compatibility'
import { recordTerminalOutput, restoreScrollStateAfterLayout } from '@/lib/pane-manager/pane-scroll'
import type { ScrollState } from '@/lib/pane-manager/pane-manager-types'
import { recordTerminalOutput } from '@/lib/pane-manager/pane-scroll'
import {
captureTerminalWriteScrollIntent,
enforceTerminalWriteScrollIntent
} from '@/lib/pane-manager/terminal-scroll-intent'
import { createBrowserUuid } from '@/lib/browser-uuid'
import { makePaneKey, parseLegacyNumericPaneKey } from '../../../../shared/stable-pane-id'
import { createTerminalCommandLifecycle } from './terminal-command-lifecycle'
@@ -3114,40 +3117,13 @@ export function connectPanePty(
})
}
function captureScrollStateForSnapshotReplay(): ScrollState | null {
const buf = pane.terminal.buffer?.active
if (!buf) {
return null
}
const viewportY = buf.viewportY
const baseY = buf.baseY
if (!Number.isFinite(viewportY) || !Number.isFinite(baseY)) {
return null
}
return {
bufferType: buf.type,
wasAtBottom: viewportY >= baseY,
viewportY,
baseY
}
}
function restoreScrollStateAfterSnapshotReplay(state: ScrollState | null): void {
if (!state || state.wasAtBottom) {
return
}
// Why: hidden-backlog replay clears xterm after visibility scroll restore;
// re-apply a scrolled-up viewport so recovery does not jump to bottom.
restoreScrollStateAfterLayout(pane.terminal, state)
}
function applyMainBufferSnapshot(snapshot: {
data: string
cols: number
rows: number
seq?: number
}): void {
const scrollState = captureScrollStateForSnapshotReplay()
const scrollIntent = captureTerminalWriteScrollIntent(pane.terminal)
const colsBeforeReplay = pane.terminal.cols
const rowsBeforeReplay = pane.terminal.rows
const hasSnapshotDimensions =
@@ -3191,7 +3167,9 @@ export function connectPanePty(
}
scheduleReattachIdleAgentCursorReset()
}
restoreScrollStateAfterSnapshotReplay(scrollState)
// Why: snapshot replay clears and rebuilds xterm state; re-apply the
// user's scroll intent once so hidden catch-up cannot repin the viewport.
enforceTerminalWriteScrollIntent(pane.terminal, scrollIntent)
}
function requestHiddenOutputRestoreIfNeeded(opts?: { bypassScheduler?: boolean }): boolean {
@@ -5,7 +5,7 @@ import {
flushTerminalOutput,
requestTerminalBacklogRecovery
} from '@/lib/pane-manager/pane-terminal-output-scheduler'
import { restoreScrollStateAfterLayout } from '@/lib/pane-manager/pane-scroll'
import { enforceTerminalCurrentScrollIntent } from '@/lib/pane-manager/terminal-scroll-intent'
import { fitAndFocusPanes, fitPanes, focusActivePane } from './pane-helpers'
const VISIBLE_RESUME_FLUSH_CHARS = 256 * 1024
@@ -47,7 +47,7 @@ export function resumeTerminalVisibility({
// 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 = captureViewportPositions(!wasVisible)
captureViewportPositions(!wasVisible)
withSuppressedScrollTracking(() => {
if (shouldUseLightTabResume) {
// Why: intra-worktree tab switches only toggle the overlay. Keeping
@@ -61,7 +61,7 @@ export function resumeTerminalVisibility({
} else {
resumeTerminalVisibilityHeavy(manager, isActive)
}
restoreTerminalViewportPositions(manager, viewportPositions)
enforceTerminalViewportIntents(manager)
if (!shouldUseLightTabResume) {
// Why: this clear wipes the glyph atlas shared with other same-config
// terminals; the global reset rebuilds their render models too.
@@ -137,14 +137,8 @@ function resumeTerminalVisibilityHeavy(manager: PaneManager, isActive: boolean):
}
}
function restoreTerminalViewportPositions(
manager: PaneManager,
viewportPositions: Map<number, ScrollState>
): void {
function enforceTerminalViewportIntents(manager: PaneManager): void {
for (const pane of manager.getPanes()) {
const position = viewportPositions.get(pane.id)
if (position) {
restoreScrollStateAfterLayout(pane.terminal, position)
}
enforceTerminalCurrentScrollIntent(pane.terminal)
}
}
@@ -17,6 +17,7 @@ const mocks = vi.hoisted(() => ({
flushTerminalOutput: vi.fn(),
getTerminalOutputEpoch: vi.fn(() => 0),
handleTerminalFileDrop: vi.fn(),
enforceTerminalCurrentScrollIntent: vi.fn(),
pasteTerminalText: vi.fn(),
recordTerminalUserInputForLeaf: vi.fn(),
requestTerminalBacklogRecovery: vi.fn(),
@@ -75,6 +76,10 @@ vi.mock('@/lib/pane-manager/pane-scroll', () => ({
restoreScrollStateAfterLayout: mocks.restoreScrollStateAfterLayout
}))
vi.mock('@/lib/pane-manager/terminal-scroll-intent', () => ({
enforceTerminalCurrentScrollIntent: mocks.enforceTerminalCurrentScrollIntent
}))
vi.mock('./terminal-drop-handler', () => ({
handleTerminalFileDrop: mocks.handleTerminalFileDrop
}))
@@ -234,6 +239,9 @@ describe('useTerminalPaneGlobalEffects', () => {
mocks.restoreScrollStateAfterLayout.mockImplementation((terminal: { name: string }) => {
order.push(`restore:${terminal.name}`)
})
mocks.enforceTerminalCurrentScrollIntent.mockImplementation((terminal: { name: string }) => {
order.push(`intent:${terminal.name}`)
})
mocks.fitAndFocusPanes.mockImplementation(() => order.push('fit-focus'))
// Why: the resume path resets atlases through the live-manager registry
@@ -267,10 +275,11 @@ describe('useTerminalPaneGlobalEffects', () => {
'flush:terminal-b',
'resume',
'fit-focus',
'restore:terminal-a',
'restore:terminal-b',
'intent:terminal-a',
'intent:terminal-b',
'reset-atlas'
])
expect(mocks.restoreScrollStateAfterLayout).not.toHaveBeenCalled()
expect(mocks.flushTerminalOutput).toHaveBeenNthCalledWith(1, terminalA, {
maxChars: 256 * 1024
})
@@ -563,7 +572,7 @@ describe('useTerminalPaneGlobalEffects', () => {
expect(window.api.pty.setActiveRendererPty).toHaveBeenCalledWith('pty-active', true)
})
it('restores from the pre-hide scroll state when hidden layout changes the viewport', () => {
it('enforces scroll intent after hidden layout changes the viewport', () => {
const terminalA = { name: 'terminal-a' }
const manager = {
getPanes: vi.fn(() => [{ id: 1, terminal: terminalA }]),
@@ -618,7 +627,8 @@ describe('useTerminalPaneGlobalEffects', () => {
expect(mocks.captureScrollState).toHaveBeenCalledTimes(2)
expect(manager.suspendRendering).toHaveBeenCalledTimes(1)
expect(mocks.restoreScrollStateAfterLayout).toHaveBeenLastCalledWith(terminalA, preHideState)
expect(mocks.restoreScrollStateAfterLayout).not.toHaveBeenCalled()
expect(mocks.enforceTerminalCurrentScrollIntent).toHaveBeenLastCalledWith(terminalA)
})
it('clears WebGL texture atlases when the active visible terminal regains focus', () => {
@@ -71,6 +71,10 @@ import { getConnectionId } from '@/lib/connection-context'
import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'
import { isPaneReplaying, type ReplayingPanesRef } from './replay-guard'
import { fitAndFocusPanes, fitPanes } from './pane-helpers'
import {
markTerminalPinnedViewport,
syncTerminalScrollIntentSoon
} from '@/lib/pane-manager/terminal-scroll-intent'
import { registerRuntimeTerminalTab, scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph'
import { e2eConfig } from '@/lib/e2e-config'
import {
@@ -720,6 +724,15 @@ export function useTerminalPaneLifecycle({
return false
}
if (e.type === 'keydown') {
if (e.key === 'PageUp' || e.key === 'Home') {
markTerminalPinnedViewport(pane.terminal)
syncTerminalScrollIntentSoon(pane.terminal, { preservePinnedAtBottom: true })
} else if (e.key === 'PageDown' || e.key === 'End') {
syncTerminalScrollIntentSoon(pane.terminal)
}
}
return !shouldBypassXtermKeyboardEvent(e, {
isMac,
hasSelection: pane.terminal.hasSelection()
@@ -11,7 +11,9 @@ const mocks = vi.hoisted(() => ({
baseY: 0
})),
flushTerminalOutput: vi.fn(),
getTerminalOutputEpoch: vi.fn(() => 1)
getTerminalOutputEpoch: vi.fn(() => 1),
getTerminalScrollIntentKind: vi.fn(() => 'followOutput'),
markTerminalFollowOutput: vi.fn()
}))
const reactRefState = vi.hoisted(() => ({
@@ -68,6 +70,11 @@ vi.mock('@/lib/pane-manager/pane-scroll', () => ({
getTerminalOutputEpoch: mocks.getTerminalOutputEpoch
}))
vi.mock('@/lib/pane-manager/terminal-scroll-intent', () => ({
getTerminalScrollIntentKind: mocks.getTerminalScrollIntentKind,
markTerminalFollowOutput: mocks.markTerminalFollowOutput
}))
describe('useTerminalScrollVisibilityMemory', () => {
const originalRequestAnimationFrame = globalThis.requestAnimationFrame
const originalCancelAnimationFrame = globalThis.cancelAnimationFrame
@@ -75,6 +82,7 @@ describe('useTerminalScrollVisibilityMemory', () => {
beforeEach(() => {
resetHookRefs()
vi.clearAllMocks()
mocks.getTerminalScrollIntentKind.mockReturnValue('followOutput')
})
afterEach(() => {
@@ -121,6 +129,42 @@ describe('useTerminalScrollVisibilityMemory', () => {
maxChars: 256 * 1024
})
expect(terminal.scrollToBottom).toHaveBeenCalled()
expect(mocks.markTerminalFollowOutput).toHaveBeenCalledWith(terminal)
})
it('does not turn a pinned viewport into follow-output when pending focus requests catch up', () => {
mocks.getTerminalScrollIntentKind.mockReturnValue('pinnedViewport')
const terminal = {
onScroll: vi.fn(() => ({ dispose: vi.fn() })),
scrollToBottom: vi.fn()
}
const manager = {
getPanes: vi.fn(() => [{ id: 1, terminal }])
}
const animationFrames: FrameRequestCallback[] = []
globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => {
animationFrames.push(callback)
return animationFrames.length
})
beginHookRender()
const visibilityMemory = useTerminalScrollVisibilityMemory({
managerRef: { current: manager as never },
isVisibleRef: { current: true },
visibleResumeCompleteRef: { current: true },
paneCount: 1
})
visibilityMemory.scheduleFollowOutputIfNeeded(1)
animationFrames.shift()?.(16)
animationFrames.shift()?.(32)
expect(mocks.flushTerminalOutput).toHaveBeenCalledWith(terminal, {
maxChars: 256 * 1024
})
expect(mocks.cancelDeferredScrollRestore).not.toHaveBeenCalled()
expect(mocks.markTerminalFollowOutput).not.toHaveBeenCalled()
expect(terminal.scrollToBottom).not.toHaveBeenCalled()
})
it('cancels pending follow-output frames on cleanup', () => {
@@ -1,11 +1,15 @@
import { useCallback, useEffect, useRef } from 'react'
import type { IDisposable, Terminal } from '@xterm/xterm'
import type { Terminal } from '@xterm/xterm'
import { flushTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler'
import {
cancelDeferredScrollRestore,
captureScrollState,
getTerminalOutputEpoch
} from '@/lib/pane-manager/pane-scroll'
import {
getTerminalScrollIntentKind,
markTerminalFollowOutput
} from '@/lib/pane-manager/terminal-scroll-intent'
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
import type { ScrollState } from '@/lib/pane-manager/pane-manager-types'
@@ -37,7 +41,6 @@ export function useTerminalScrollVisibilityMemory({
paneCount
}: UseTerminalScrollVisibilityMemoryArgs): TerminalScrollVisibilityMemory {
const visibleScrollSnapshotsRef = useRef<Map<number, VisibleScrollSnapshot>>(new Map())
const scrollDisposablesRef = useRef<Map<number, IDisposable>>(new Map())
const suppressScrollTrackingRef = useRef(false)
const pendingFollowOutputPaneIdsRef = useRef<Set<number>>(new Set())
const followOutputFrameIdsRef = useRef<number[]>([])
@@ -117,10 +120,13 @@ export function useTerminalScrollVisibilityMemory({
const currentEpoch = getTerminalOutputEpoch(pane.terminal)
const hasNewOutput = previous ? currentEpoch > previous.outputEpoch : currentEpoch > 0
if (hasNewOutput) {
cancelDeferredScrollRestore(pane.terminal)
pane.terminal.scrollToBottom()
if (getTerminalScrollIntentKind(pane.terminal) === 'followOutput') {
cancelDeferredScrollRestore(pane.terminal)
markTerminalFollowOutput(pane.terminal)
pane.terminal.scrollToBottom()
didScroll = true
}
rememberVisibleScrollSnapshot(pane.id, pane.terminal)
didScroll = true
}
pending.delete(pane.id)
}
@@ -164,46 +170,15 @@ export function useTerminalScrollVisibilityMemory({
if (!manager) {
return
}
const disposables = scrollDisposablesRef.current
const panes = manager.getPanes()
const livePaneIds = new Set(panes.map((pane) => pane.id))
for (const [paneId, disposable] of disposables) {
for (const paneId of visibleScrollSnapshotsRef.current.keys()) {
if (!livePaneIds.has(paneId)) {
disposable.dispose()
disposables.delete(paneId)
visibleScrollSnapshotsRef.current.delete(paneId)
pendingFollowOutputPaneIdsRef.current.delete(paneId)
}
}
for (const pane of panes) {
if (disposables.has(pane.id)) {
continue
}
const onScroll = (
pane.terminal as Terminal & {
onScroll?: (listener: (position: number) => void) => IDisposable
}
).onScroll
if (typeof onScroll !== 'function') {
continue
}
disposables.set(
pane.id,
onScroll.call(pane.terminal, () => {
if (!isVisibleRef.current || suppressScrollTrackingRef.current) {
return
}
rememberVisibleScrollSnapshot(pane.id, pane.terminal)
})
)
}
return () => {
for (const disposable of disposables.values()) {
disposable.dispose()
}
disposables.clear()
}
}, [isVisibleRef, managerRef, paneCount, rememberVisibleScrollSnapshot])
}, [managerRef, paneCount])
return {
captureViewportPositions,
@@ -240,6 +240,19 @@ describe('useComposerState host-context boundaries', () => {
)
})
it('selects a project by its own host instead of pinning the current host', () => {
// Regression: passing the current host as a hard `hostId` made picking a
// project set up only on a different host a silent no-op. The current host
// must be a preference (focusedHostScope), with a fallback to any ready host.
const handleProjectChange = sourceBetween(
HOOK_SOURCE,
'const handleProjectChange = useCallback',
'const handleSmartGitHubItemSelect'
)
expect(handleProjectChange).toContain('focusedHostScope: preferredHostId ?? workspaceHostScope')
expect(handleProjectChange).not.toContain('hostId: preferredHostId')
})
it('clears GitLab-specific linked state when clearing smart-name selection', () => {
const section = sourceBetween(
HOOK_SOURCE,
+7 -2
View File
@@ -2277,13 +2277,18 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
setSelectedProjectGroupId(null)
const preferredHostId =
selectedWorkspaceTarget.status === 'ready' ? selectedWorkspaceTarget.target.hostId : null
// Why: prefer the host the user is currently on, but treat it as a
// preference (focusedHostScope) rather than a hard hostId match. Pinning
// hostId made selecting a project that is only set up on a different host
// a silent no-op — the resolver returned '' (project-not-set-up-on-host)
// and the early return below swallowed the click. Falling back to any
// host the project is ready on lets cross-host selection work.
const nextRepoId = resolveWorkspaceCreationRepoId({
eligibleRepos,
projects,
projectHostSetups,
projectId,
hostId: preferredHostId,
focusedHostScope: workspaceHostScope
focusedHostScope: preferredHostId ?? workspaceHostScope
})
if (!nextRepoId) {
return

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