mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 08:03:09 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/fix-sidebar-sync
This commit is contained in:
+23
-12
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=')) ?? ''
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
+45
-1
@@ -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', () => {
|
||||
|
||||
+13
-38
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user