Merge branch 'main' of https://github.com/stablyai/orca into improve-agent-session

This commit is contained in:
Jinjing
2026-06-25 00:21:16 -07:00
101 changed files with 7406 additions and 2713 deletions
+23 -12
View File
@@ -81,16 +81,26 @@ jobs:
fail-fast: false
matrix:
include:
- shard: '1/5'
shard_name: 1-of-5
- shard: '2/5'
shard_name: 2-of-5
- shard: '3/5'
shard_name: 3-of-5
- shard: '4/5'
shard_name: 4-of-5
- shard: '5/5'
shard_name: 5-of-5
- shard: '1/10'
shard_name: 1-of-10
- shard: '2/10'
shard_name: 2-of-10
- shard: '3/10'
shard_name: 3-of-10
- shard: '4/10'
shard_name: 4-of-10
- shard: '5/10'
shard_name: 5-of-10
- shard: '6/10'
shard_name: 6-of-10
- shard: '7/10'
shard_name: 7-of-10
- shard: '8/10'
shard_name: 8-of-10
- shard: '9/10'
shard_name: 9-of-10
- shard: '10/10'
shard_name: 10-of-10
steps:
- name: Checkout
@@ -142,8 +152,9 @@ jobs:
path: out/
# Why: the Electron suite is wall-clock constrained on OSS runners, but
# raising workers on one runner OOMs. Sharding keeps each VM at 4 workers
# while splitting the 91-test headless suite across separate runners.
# multiple Electron apps on one Xvfb VM contend on git/Chromium resources.
# Sharding keeps each VM at one Playwright worker while splitting the
# headless suite across separate runners.
# SKIP_BUILD makes Playwright globalSetup reuse the single build job's
# artifact instead of starting five concurrent electron-vite builds.
# ORCA_E2E_FORWARD_APP_LOGS keeps startup failures visible when Electron
+1
View File
@@ -7,6 +7,7 @@
<a href="https://github.com/stablyai/orca/releases"><img src="docs/assets/readme-downloads.svg" alt="Total downloads across all releases" /></a>
<img src="https://badgen.net/github/license/stablyai/orca" alt="License" />
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white" alt="Join the Orca Discord" /></a>
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/X-000000?logo=x&logoColor=white" alt="Follow Orca on X" /></a>
<img src="https://img.shields.io/badge/macOS%20%7C%20Windows%20%7C%20Linux-4493F8?style=flat-square" alt="Supported platforms: macOS, Windows, and Linux" />
</p>
+1
View File
@@ -7,6 +7,7 @@
<a href="https://github.com/stablyai/orca/releases"><img src="../assets/readme-downloads.svg" alt="Descargas totales en todas las versiones" /></a>
<img src="https://badgen.net/github/license/stablyai/orca" alt="Licencia" />
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white" alt="Únete al Discord de Orca" /></a>
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/X-000000?logo=x&logoColor=white" alt="Sigue a Orca en X" /></a>
<img src="https://img.shields.io/badge/macOS%20%7C%20Windows%20%7C%20Linux-4493F8?style=flat-square" alt="Plataformas compatibles: macOS, Windows y Linux" />
</p>
+1
View File
@@ -7,6 +7,7 @@
<a href="https://github.com/stablyai/orca/releases"><img src="../assets/readme-downloads.svg" alt="全リリースの合計ダウンロード数" /></a>
<img src="https://badgen.net/github/license/stablyai/orca" alt="ライセンス" />
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white" alt="Orca の Discord に参加" /></a>
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/X-000000?logo=x&logoColor=white" alt="X で Orca をフォロー" /></a>
<img src="https://img.shields.io/badge/macOS%20%7C%20Windows%20%7C%20Linux-4493F8?style=flat-square" alt="対応プラットフォーム: macOS、Windows、Linux" />
</p>
+1
View File
@@ -7,6 +7,7 @@
<a href="https://github.com/stablyai/orca/releases"><img src="../assets/readme-downloads.svg" alt="전체 릴리스 누적 다운로드 수" /></a>
<img src="https://badgen.net/github/license/stablyai/orca" alt="라이선스" />
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white" alt="Orca Discord 참여" /></a>
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/X-000000?logo=x&logoColor=white" alt="X에서 Orca 팔로우" /></a>
<img src="https://img.shields.io/badge/macOS%20%7C%20Windows%20%7C%20Linux-4493F8?style=flat-square" alt="지원 플랫폼: macOS, Windows, Linux" />
</p>
+1
View File
@@ -7,6 +7,7 @@
<a href="https://github.com/stablyai/orca/releases"><img src="../assets/readme-downloads.svg" alt="所有版本的总下载量" /></a>
<img src="https://badgen.net/github/license/stablyai/orca" alt="许可证" />
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white" alt="加入 Orca Discord" /></a>
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/X-000000?logo=x&logoColor=white" alt="在 X 上关注 Orca" /></a>
<img src="https://img.shields.io/badge/macOS%20%7C%20Windows%20%7C%20Linux-4493F8?style=flat-square" alt="支持的平台:macOS、Windows 和 Linux" />
</p>
+329
View File
@@ -0,0 +1,329 @@
# Terminal Scroll Intent Architecture
## Problem
Terminal panes can jump or jitter when a user switches workspaces while a TUI
pane is scrolled near, but not exactly at, the bottom. The most visible case is a
Codex-style alternate-screen TUI in a split pane:
1. The user scrolls slightly up from the bottom.
2. The user switches to another workspace.
3. The user switches back.
4. The terminal sometimes jumps to the top, jumps to the bottom, flashes at a
wrong position, or restores an older viewport.
The current code tries to repair the viewport after layout, fit, split
reparenting, hidden-output replay, and visibility resume. Those repairs compete
with each other because none of them owns the user's scroll intent. A delayed
restore can replay an old state after the user has already scrolled, while a
follow-output path can force the bottom even when the user is reading scrollback.
## Reference Investigation Summary
The useful reference pattern is not a timer-based restore loop. It is an
explicit pinned-to-bottom model owned by the terminal frontend:
- The frontend stores bottom-following state separately from xterm's transient
`viewportY`.
- `xterm.onScroll` is not used as user intent. In xterm, that event can be
content-driven and can briefly report bottom during fast output or layout work.
- User wheel and keyboard scroll commands update the intent.
- `scrollToBottom()` is an explicit command that sets the intent back to
follow-output.
- Every write snapshots the intent before writing. If the terminal was following
output, it scrolls to bottom after the write. If the user was pinned to a
viewport, it preserves the prior viewport line.
- Fit/resize uses the same rule: follow bottom if explicitly pinned, otherwise
preserve the current viewport.
- Alternate-screen state is tracked from `buffer.active.type`, but it is not a
reason to infer user scroll intent from `onScroll`.
The reference implementation also patches private xterm internals to disable
xterm's implicit scroll-to-bottom behavior and call the original bottom-scroll
only from explicit owner paths. That gives a clean ownership boundary, but it is
an escalation point for Orca because private internals increase xterm upgrade
risk.
## Current Orca Risk Points
The current Orca branch already has several independent scroll actors:
- `src/renderer/src/lib/pane-manager/pane-scroll.ts` captures `viewportY`,
`baseY`, bottom state, and sometimes a marker, then restores using immediate,
rAF, and timeout paths.
- `src/renderer/src/lib/pane-manager/pane-tree-ops.ts` captures and restores
scroll around `safeFit()`.
- `src/renderer/src/lib/pane-manager/pane-split-scroll.ts` schedules split
reparent restores across rAF and timeout phases.
- `src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.ts`
listens to `terminal.onScroll` and stores snapshots while visible.
- `src/renderer/src/components/terminal-pane/pty-connection.ts` writes PTY output
through foreground, background, hidden-output skip, snapshot replay, and
restore paths.
- `src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts` can
write immediately, enqueue foreground writes, coalesce synchronized output, or
drain hidden/background chunks later.
The biggest architectural mismatch is the visibility memory hook's use of
`terminal.onScroll` as a snapshot trigger. That event is not a reliable user
scroll signal. During workspace switching, output parsing, hidden replay, or fit
can make it persist transient positions that later appear as "older position"
restores.
## Design Goal
Make terminal viewport movement a result of explicit scroll intent, not a side
effect of visibility, output, or layout timing.
The terminal should have one active scroll intent per pane:
- `followOutput`: the terminal is logically pinned to the live output bottom.
New output, explicit focus-follow requests, and fits may keep it at bottom.
- `pinnedViewport`: the user is reading a specific viewport. New output,
workspace switches, hidden-output replay, and fits must not move the viewport
except when scrollback pruning or buffer replacement makes the exact line
impossible.
## State Model
Add a focused module, for example:
`src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts`
Suggested state:
```ts
export type TerminalScrollIntentKind = 'followOutput' | 'pinnedViewport'
export type TerminalScrollIntent = {
kind: TerminalScrollIntentKind
bufferType: 'normal' | 'alternate'
viewportY: number
baseY: number
capturedAt: number
}
```
The state should be keyed by the live `Terminal` or by pane leaf identity where
it needs to survive pane replacement. For normal workspace switches that keep
the same xterm instance, the live terminal-keyed state should be authoritative.
## Intent Transitions
Only these events should change scroll intent:
- User wheel scrolls upward: set `pinnedViewport` immediately before xterm write
callbacks or rAF can pull the viewport back down.
- User wheel scrolls downward: after xterm applies the scroll, recompute whether
the viewport reached bottom. If yes, set `followOutput`; otherwise keep
`pinnedViewport`.
- User keyboard scroll commands: apply the same rules as wheel.
- Explicit `scrollToBottom()`, "follow output", or focus command with
follow-output semantics: set `followOutput`.
- Programmatic `scrollToTop()` or page/line scroll commands: set
`pinnedViewport`, unless the resulting viewport is bottom.
- Buffer change to alternate screen: record `bufferType`, but do not infer
follow-output from `onScroll`.
- Buffer return to normal screen: recompute from current viewport only if no
stronger user intent exists for the normal buffer.
- Scrollback prune: clamp a pinned viewport to the nearest valid line without
changing intent.
- Terminal remount/replay: restore from durable fallback only if the live xterm
instance was actually replaced.
Do not use xterm `onScroll` to set intent. It may still be useful as a passive
diagnostic signal, but it should not persist authoritative scroll state.
## Write Contract
All PTY output that reaches xterm should pass through one wrapper that enforces
intent around the actual `terminal.write` call.
Suggested owner:
`src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts`
Contract:
1. Capture intent and current `viewportY` before the write is scheduled.
2. Execute the write through the existing foreground/background/coalescing path.
3. After the write has parsed enough for xterm buffer state to be meaningful:
- If intent is `followOutput`, scroll to bottom.
- If intent is `pinnedViewport`, clamp and restore the saved viewport line.
4. Do not schedule multi-frame retries for normal output. A single post-write
enforcement point should be enough for standard writes.
For foreground synchronized-output holds, the enforcement point should run after
the held/coalesced frame is released, not for each partial cursor-hide chunk.
For background writes, preserve `pinnedViewport` if the pane is hidden or
inactive. Hidden output should not silently repin the terminal.
## Fit And Resize Contract
`safeFit()` should stop using generic delayed scroll restoration as its normal
behavior.
New rule:
1. If dimensions do not change, do nothing.
2. Before fit, capture current intent and viewport.
3. Fit.
4. If intent is `followOutput`, scroll to bottom.
5. If intent is `pinnedViewport`, restore the saved viewport line clamped to the
new `baseY`.
Avoid fitting hidden or zero-geometry panes. Existing geometry guards should
remain because fitting to transient workspace-switch geometry can send bad sizes
to the PTY and trigger TUI redraw churn.
## Visibility And Workspace Switch Contract
Workspace switching should not be a scroll operation.
On hide:
- Capture the current intent once.
- Do not run scroll restore.
- Do not update intent from `onScroll`.
- Continue hidden-output throttling/snapshot behavior as today.
On show:
- Reattach or refresh renderer resources as needed.
- Flush only the bounded amount of hidden output required for the active pane.
- Apply intent once after the visible output catch-up:
- `followOutput` goes to bottom.
- `pinnedViewport` stays pinned.
- Do not run repeated rAF/timeout scroll restores.
If the underlying terminal instance was replaced, use durable fallback state.
If the instance stayed alive, its live scroll intent is authoritative.
## Hidden Output And Snapshot Replay
Hidden-output snapshot replay is a true fallback path because it clears and
reconstructs xterm content. It needs scroll handling, but it should still obey
intent:
- Before snapshot replay, capture intent and viewport.
- Replay serialized content.
- Fit only if needed and only with valid visible geometry.
- Reapply the captured intent once:
- `followOutput`: bottom.
- `pinnedViewport`: previous viewport line, clamped.
- Do not let replay set follow-output just because the replayed buffer ends at
bottom.
If hidden output arrives while the user is pinned, background catch-up should not
move the visible viewport on activation.
## Split Reparenting
DOM reparenting can reset browser scroll state and WebGL resources. This is a
legitimate place for a fallback restore, but it should be scoped:
- Capture intent before reparent.
- Reparent.
- Reattach renderer if required.
- Apply intent once after DOM settles.
- Avoid restoring alternate-screen scrollback, because alternate screen has no
normal scrollback and a TUI owns its cursor.
The current split-specific rAF/timer code should become a local fallback for DOM
reparenting only, not a general model copied into workspace switching.
## Performance Constraints
The design must preserve Orca's terminal performance priorities:
- Do not keep all hidden terminals hot-rendering.
- Keep PTY/session/xterm state warm when feasible, but suspend hidden rendering
work and throttle hidden output as today.
- Avoid per-output layout reads. Intent enforcement should read xterm buffer
fields, not DOM geometry.
- Avoid multi-frame restore loops. They cause visible jitter and keep the
renderer busy after activation.
- Keep active-pane hidden-output catch-up bounded. Inactive visible split panes
can catch up over later frames.
- Do not send resize/SIGWINCH unless dimensions actually changed and the
renderer is the authoritative size owner.
The intended steady-state cost is one small buffer-state capture per xterm write
batch, not per byte and not per animation frame.
## Implementation Plan
1. Add `terminal-scroll-intent.ts`.
- Track `followOutput` vs `pinnedViewport`.
- Provide helpers for user scroll, explicit bottom, write capture, fit
capture, and intent enforcement.
2. Replace visibility-memory `onScroll` ownership.
- Remove authoritative snapshot updates from `terminal.onScroll`.
- Add capture-phase wheel listeners and keyboard scroll command hooks.
- Keep `onScroll` only for diagnostics if needed.
3. Route explicit scroll commands through intent helpers.
- `scrollToBottom()` sets `followOutput`.
- `scrollToTop()`, page up, and line up set `pinnedViewport`.
- Downward commands recompute after the command.
4. Wrap output writes.
- Add an intent-aware writer boundary in the output scheduler or in a narrow
wrapper used by the scheduler.
- Ensure foreground coalescing applies intent after the coalesced frame.
- Ensure background drains preserve pinned viewports.
5. Convert `safeFit()` to intent-aware fit.
- Capture intent before fit.
- Apply once after fit.
- Remove generic deferred scroll restore from normal fit.
6. Limit restore fallbacks.
- Keep fallback restore only for true remount/replay/reparent cases.
- Remove workspace-switch rAF/timeout restore loops once intent enforcement is
in place.
7. Add focused tests.
- User scroll up sets `pinnedViewport`.
- Output while pinned preserves viewport.
- Output while following scrolls to bottom.
- Fit while pinned preserves viewport.
- Fit while following stays at bottom.
- Hidden-output replay while pinned does not repin.
- Workspace hide/show does not change intent.
- `onScroll` does not mutate intent.
8. Add an E2E reproduction.
- Use the existing `scroll-primary` and `scroll-secondary` worktrees.
- Top pane in `scroll-primary` contains the TUI with enough scrollback.
- Scroll slightly above bottom, switch to `scroll-secondary`, then switch back.
- Assert the terminal remains within a small viewport tolerance and does not
visit top/bottom during the transition.
## Acceptance Criteria
- Switching away and back does not move a pinned TUI viewport.
- No visible flash to top or bottom during activation.
- Scrolling all the way to bottom re-enables follow-output and does not later
restore an older pinned position.
- Hidden output in inactive workspaces does not change visible scroll position
until the user explicitly follows output or reaches bottom.
- Alternate-screen TUIs do not receive extra restore/fill behavior that shifts
their cursor or scroll region.
- The active pane remains responsive during heavy hidden-output catch-up.
## Open Decisions
- Whether Orca should patch private xterm bottom-scroll internals. The reference
pattern does this for strict ownership, but Orca should first try public API
enforcement at write/fit boundaries and only escalate if xterm continues to
auto-follow independently.
- Whether scroll intent should be stored only by live `Terminal` instance or also
mirrored by leaf ID for remount fallback. The initial implementation should
use live instance state plus a leaf-keyed fallback for true replacement.
- Whether alternate screen needs a separate intent record from normal screen.
The first version can use one record with `bufferType`; a separate normal vs
alternate record is justified only if testing shows mode switches overwrite
user intent.
@@ -192,7 +192,16 @@ describe('remote hook service installers', () => {
})
it('installs remote Codex hooks with matching trust entries', async () => {
const { sftp, fs } = createFakeSftp()
const { sftp, fs } = createFakeSftp({
'/home/dev/.codex/hooks.json': `${JSON.stringify({
hooks: {},
_managed: {
'external-manager': {
Stop: [0]
}
}
})}\n`
})
const status = await new CodexHookService().installRemote(sftp, '/home/dev/')
@@ -200,7 +209,9 @@ describe('remote hook service installers', () => {
expect(status.configPath).toBe('/home/dev/.codex/hooks.json')
const hooks = JSON.parse(fs.files.get('/home/dev/.codex/hooks.json')!) as {
hooks: Record<string, { hooks: { command: string }[] }[]>
_managed?: unknown
}
expect(hooks._managed).toEqual({ 'external-manager': { Stop: [0] } })
for (const eventName of [
'SessionStart',
'UserPromptSubmit',
@@ -0,0 +1,253 @@
import { createReadStream } from 'fs'
import { readdir, stat } from 'fs/promises'
import { createInterface } from 'readline'
import { extname, join } from 'path'
import {
isPathInsideOrEqual,
normalizeRuntimePathForComparison
} from '../../shared/cross-platform-path'
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
import { parseWslUncPath } from '../../shared/wsl-paths'
import type { FileWithMtime } from './session-scanner-types'
import { errorMessage, extractString, parseJsonObject } from './session-scanner-values'
// Reading a few lines of one transcript per project dir is enough to learn that
// dir's cwd; cap both so a giant or cwd-less transcript can't stall the scan.
const REPRESENTATIVE_CWD_LINE_LIMIT = 200
const REPRESENTATIVE_FILE_LIMIT = 3
const CLAUDE_EXTENSIONS = new Set(['.jsonl'])
/**
* Fully include the transcripts of Claude project directories whose cwd falls
* inside the active workspace/project paths.
*
* Why: Claude organizes `~/.claude/projects/<cwd-encoded>/` one directory per
* cwd. The global scan is recency-capped, so a project the user hasn't touched
* recently can drop off the list entirely even though `claude --resume` still
* finds it. For scoped panel views we resolve each project dir's cwd cheaply and
* bypass the cap for the ones that belong to the active scope.
*/
export async function discoverInScopeClaudeFiles(args: {
rootDirs: readonly string[]
scopePaths: readonly string[]
limit: number
excludedFilePaths: ReadonlySet<string>
issues: AiVaultScanIssue[]
}): Promise<FileWithMtime[]> {
if (args.scopePaths.length === 0 || args.limit <= 0) {
return []
}
const scopeProjectPrefixes = claudeProjectScopePrefixes(args.scopePaths)
const collected = new Map<string, FileWithMtime>()
for (const rootDir of args.rootDirs) {
for (const projectDir of await listProjectDirs(rootDir, scopeProjectPrefixes)) {
const cwd = await readProjectDirCwd(projectDir)
if (!cwd || !args.scopePaths.some((scopePath) => isCwdInsideScopePath(scopePath, cwd))) {
continue
}
await collectClaudeFiles({
projectDir,
issues: args.issues,
collected,
limit: args.limit,
excludedFilePaths: args.excludedFilePaths
})
}
}
return [...collected.values()].sort((left, right) => right.mtimeMs - left.mtimeMs)
}
function claudeProjectScopePrefixes(scopePaths: readonly string[]): Set<string> {
const prefixes = new Set<string>()
for (const scopePath of scopePaths) {
for (const candidate of scopePathCandidates(scopePath)) {
prefixes.add(encodeClaudeProjectPath(candidate))
}
}
return prefixes
}
function scopePathCandidates(scopePath: string): string[] {
const wslScopePath = parseWslUncPath(scopePath)
return wslScopePath ? [scopePath, wslScopePath.linuxPath] : [scopePath]
}
function encodeClaudeProjectPath(pathValue: string): string {
return normalizeRuntimePathForComparison(pathValue).replace(/[^a-zA-Z0-9]/g, '-')
}
function isClaudeProjectDirInScope(projectDirName: string, scopePrefixes: ReadonlySet<string>) {
for (const prefix of scopePrefixes) {
if (projectDirName === prefix || projectDirName.startsWith(`${prefix}-`)) {
return true
}
}
return false
}
function isCwdInsideScopePath(scopePath: string, cwd: string): boolean {
if (isPathInsideOrEqual(scopePath, cwd)) {
return true
}
const wslScopePath = parseWslUncPath(scopePath)
if (!wslScopePath) {
return false
}
// WSL transcripts record Linux cwd values even when the renderer sends the
// active worktree as a Windows UNC path.
return isPathInsideOrEqual(wslScopePath.linuxPath, cwd)
}
async function listProjectDirs(
rootDir: string,
scopeProjectPrefixes: ReadonlySet<string>
): Promise<string[]> {
let entries
try {
entries = await readdir(rootDir, { withFileTypes: true })
} catch {
return []
}
return entries
.filter(
(entry) => entry.isDirectory() && isClaudeProjectDirInScope(entry.name, scopeProjectPrefixes)
)
.map((entry) => join(rootDir, entry.name))
}
async function readProjectDirCwd(projectDir: string): Promise<string | null> {
const files = await newestClaudeFilesInDir(projectDir)
for (const file of files.slice(0, REPRESENTATIVE_FILE_LIMIT)) {
const cwd = await readFirstCwd(file)
if (cwd) {
return cwd
}
}
return null
}
async function newestClaudeFilesInDir(projectDir: string): Promise<string[]> {
let entries
try {
entries = await readdir(projectDir, { withFileTypes: true })
} catch {
return []
}
const newest: { path: string; mtimeMs: number }[] = []
for (const entry of entries) {
if (!entry.isFile() || !CLAUDE_EXTENSIONS.has(extname(entry.name).toLowerCase())) {
continue
}
const path = join(projectDir, entry.name)
try {
addBoundedPath(newest, REPRESENTATIVE_FILE_LIMIT, {
path,
mtimeMs: (await stat(path)).mtimeMs
})
} catch {
// Best effort: unreadable candidates are ignored here and reported during
// full collection if the project directory proves in-scope.
}
}
return newest.sort((left, right) => right.mtimeMs - left.mtimeMs).map((value) => value.path)
}
async function readFirstCwd(filePath: string): Promise<string | null> {
const input = createReadStream(filePath, { encoding: 'utf-8' })
const lines = createInterface({ input, crlfDelay: Infinity })
let read = 0
try {
for await (const line of lines) {
if (read++ >= REPRESENTATIVE_CWD_LINE_LIMIT) {
break
}
const cwd = extractString(parseJsonObject(line)?.cwd)
if (cwd) {
return cwd
}
}
} catch {
return null
} finally {
// readline.close() leaves the underlying stream open; destroy it so the early
// break/catch paths don't leak a file descriptor (this runs per project dir).
lines.close()
input.destroy()
}
return null
}
async function collectClaudeFiles(args: {
projectDir: string
issues: AiVaultScanIssue[]
collected: Map<string, FileWithMtime>
limit: number
excludedFilePaths: ReadonlySet<string>
}): Promise<void> {
let entries
try {
entries = await readdir(args.projectDir, { withFileTypes: true })
} catch {
return
}
for (const entry of entries) {
if (!entry.isFile() || !CLAUDE_EXTENSIONS.has(extname(entry.name).toLowerCase())) {
continue
}
const path = join(args.projectDir, entry.name)
if (args.collected.has(path) || args.excludedFilePaths.has(path)) {
continue
}
try {
const fileStat = await stat(path)
addBoundedFile(args.collected, args.limit, {
path,
mtimeMs: fileStat.mtimeMs,
modifiedAt: fileStat.mtime.toISOString()
})
} catch (err) {
args.issues.push({ agent: 'claude', path, message: errorMessage(err) })
}
}
}
function addBoundedFile(
collected: Map<string, FileWithMtime>,
limit: number,
file: FileWithMtime
): void {
if (collected.size < limit) {
collected.set(file.path, file)
return
}
let oldest: FileWithMtime | null = null
for (const candidate of collected.values()) {
if (!oldest || candidate.mtimeMs < oldest.mtimeMs) {
oldest = candidate
}
}
if (oldest && file.mtimeMs > oldest.mtimeMs) {
collected.delete(oldest.path)
collected.set(file.path, file)
}
}
function addBoundedPath<T extends { mtimeMs: number }>(items: T[], limit: number, item: T): void {
if (items.length < limit) {
items.push(item)
return
}
let oldestIndex = 0
for (let index = 1; index < items.length; index++) {
if (items[index].mtimeMs < items[oldestIndex].mtimeMs) {
oldestIndex = index
}
}
if (item.mtimeMs > items[oldestIndex].mtimeMs) {
items[oldestIndex] = item
}
}
@@ -0,0 +1,169 @@
import { mkdtemp, mkdir, rm, utimes, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, describe, expect, it } from 'vitest'
import { scanAiVaultSessions } from './session-scanner'
import type { AiVaultScanOptions } from './session-scanner-types'
let tempRoots: string[] = []
afterEach(async () => {
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
tempRoots = []
})
// Point every non-Claude source at a nonexistent dir so the scan only sees the
// Claude fixtures created per test.
function scopedScanOptions(claudeProjectsDir: string, extra: Partial<AiVaultScanOptions>) {
return {
claudeProjectsDir,
codexSessionsDir: '/nonexistent/codex',
geminiSessionsDir: '/nonexistent/gemini',
copilotSessionsDir: '/nonexistent/copilot',
cursorProjectsDir: '/nonexistent/cursor',
opencodeStorageDir: '/nonexistent/opencode',
opencodeDbPaths: [],
grokSessionsDir: '/nonexistent/grok',
devinTranscriptsDir: '/nonexistent/devin',
hermesSessionsDir: '/nonexistent/hermes',
rovoSessionsDir: '/nonexistent/rovo',
openclawStateDir: '/nonexistent/openclaw',
openclawLegacyStateDir: '/nonexistent/openclaw-legacy',
piSessionsDir: '/nonexistent/pi',
droidSessionsDir: '/nonexistent/droid',
droidProjectsDir: '/nonexistent/droid-projects',
kimiSessionsDir: '/nonexistent/kimi',
...extra
} satisfies AiVaultScanOptions
}
async function writeClaudeSession(args: {
claudeRoot: string
dirName: string
sessionId: string
cwd: string
iso: string
leadingCwdlessLine?: boolean
}): Promise<void> {
const dir = join(args.claudeRoot, args.dirName)
await mkdir(dir, { recursive: true })
const records: unknown[] = []
if (args.leadingCwdlessLine) {
records.push({ type: 'last-prompt', sessionId: args.sessionId })
}
records.push({
type: 'user',
sessionId: args.sessionId,
timestamp: args.iso,
cwd: args.cwd,
message: { role: 'user', content: `work in ${args.cwd}` }
})
const filePath = join(dir, `${args.sessionId}.jsonl`)
await writeFile(filePath, records.map((record) => JSON.stringify(record)).join('\n'))
const time = new Date(args.iso)
await utimes(filePath, time, time)
}
describe('scanAiVaultSessions scope inclusion', () => {
it('surfaces in-scope sessions older than the global recency cap', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-scope-'))
tempRoots.push(root)
const claudeRoot = join(root, 'claude-projects')
// One old in-scope session that the recency cap would otherwise drop.
await writeClaudeSession({
claudeRoot,
dirName: '-repo-app',
sessionId: 'old-in-scope',
cwd: '/repo/app',
iso: '2026-01-01T00:00:00.000Z',
leadingCwdlessLine: true
})
// A session in a sub-cwd directory of the same workspace path.
await writeClaudeSession({
claudeRoot,
dirName: '-repo-app-packages-ui',
sessionId: 'old-in-scope-subdir',
cwd: '/repo/app/packages/ui',
iso: '2026-01-02T00:00:00.000Z'
})
// Recent out-of-scope sessions that fill the cap.
for (let index = 0; index < 4; index++) {
await writeClaudeSession({
claudeRoot,
dirName: `-other-${index}`,
sessionId: `recent-${index}`,
cwd: `/other/${index}`,
iso: `2026-06-2${index}T00:00:00.000Z`
})
}
const withoutScope = await scanAiVaultSessions(scopedScanOptions(claudeRoot, { limit: 2 }))
const withScope = await scanAiVaultSessions(
scopedScanOptions(claudeRoot, { limit: 2, scopePaths: ['/repo/app'] })
)
const ids = (result: { sessions: { sessionId: string }[] }) =>
result.sessions.map((session) => session.sessionId)
// The cap hides the old in-scope sessions when no scope is provided.
expect(ids(withoutScope)).not.toContain('old-in-scope')
expect(ids(withoutScope)).not.toContain('old-in-scope-subdir')
// Scope paths force them back in, including the sub-cwd directory.
expect(ids(withScope)).toContain('old-in-scope')
expect(ids(withScope)).toContain('old-in-scope-subdir')
})
it('does not duplicate sessions already in the capped result', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-scope-'))
tempRoots.push(root)
const claudeRoot = join(root, 'claude-projects')
await writeClaudeSession({
claudeRoot,
dirName: '-repo-app',
sessionId: 'recent-in-scope',
cwd: '/repo/app',
iso: '2026-06-24T00:00:00.000Z'
})
const result = await scanAiVaultSessions(
scopedScanOptions(claudeRoot, { limit: 50, scopePaths: ['/repo/app'] })
)
const matches = result.sessions.filter((session) => session.sessionId === 'recent-in-scope')
expect(matches).toHaveLength(1)
})
it('matches WSL UNC scope paths against Linux Claude cwd values', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-scope-'))
tempRoots.push(root)
const claudeRoot = join(root, 'claude-projects')
await writeClaudeSession({
claudeRoot,
dirName: '-home-ada-repo',
sessionId: 'old-wsl-in-scope',
cwd: '/home/ada/repo',
iso: '2026-01-01T00:00:00.000Z'
})
for (let index = 0; index < 4; index++) {
await writeClaudeSession({
claudeRoot,
dirName: `-other-wsl-${index}`,
sessionId: `recent-wsl-${index}`,
cwd: `/other/wsl/${index}`,
iso: `2026-06-2${index}T00:00:00.000Z`
})
}
const result = await scanAiVaultSessions(
scopedScanOptions(claudeRoot, {
limit: 2,
scopePaths: ['\\\\wsl.localhost\\Ubuntu\\home\\ada\\repo']
})
)
expect(result.sessions.map((session) => session.sessionId)).toContain('old-wsl-in-scope')
})
})
@@ -29,6 +29,9 @@ export type AiVaultScanOptions = {
kimiSessionsDir?: string
limit?: number
limitPerAgent?: number
// Active workspace/project paths whose sessions must be included regardless of
// the recency cap (see discoverInScopeClaudeFiles).
scopePaths?: readonly string[]
platform?: NodeJS.Platform
}
+70 -2
View File
@@ -6,6 +6,7 @@ import type {
import { sessionSortTime } from './session-scanner-accumulator'
import { parseAgentSessionFile } from './session-scanner-agent-parser'
import { codexHomeForSessionsDir } from './session-scanner-codex-paths'
import { discoverInScopeClaudeFiles } from './session-scanner-scope-discovery'
import {
DEFAULT_CODEX_HOME_DIR,
discoverAiVaultSessionSources
@@ -13,6 +14,7 @@ import {
import type {
AiVaultScanOptions,
SessionFileCandidate,
SessionFileDiscovery,
SessionParseResult
} from './session-scanner-types'
import { clampPositiveInteger, errorMessage } from './session-scanner-values'
@@ -20,6 +22,9 @@ import { clampPositiveInteger, errorMessage } from './session-scanner-values'
const DEFAULT_LIMIT = 1000
const DEFAULT_SCAN_LIMIT_PER_AGENT = 1000
const SESSION_PARSE_CONCURRENCY = 8
// Upper bound on extra in-scope transcripts discovered and parsed past the
// recency cap; guards against a pathological scoped history directory.
const SCOPE_PARSE_LIMIT = 2000
/**
* Scan all supported AI agent session stores and return a unified, sorted,
@@ -61,17 +66,80 @@ export async function scanAiVaultSessions(
issues
})
const sessions = parsedSessions
const cappedSessions = parsedSessions
.sort((left, right) => sessionSortTime(right) - sessionSortTime(left))
.slice(0, limit)
const scopeSessions = await scanInScopeSessions({
discoveries,
scopePaths: options.scopePaths ?? [],
alreadyParsedFilePaths: new Set(cappedSessions.map((session) => session.filePath)),
platform,
issues
})
return {
sessions,
sessions: mergeSessions(cappedSessions, scopeSessions),
issues,
scannedAt: new Date().toISOString()
}
}
// In-scope sessions are guaranteed regardless of the recency cap, so the global
// (already capped) result and the scope result are unioned and de-duplicated by
// session id, then re-sorted DESC.
function mergeSessions(
cappedSessions: AiVaultSession[],
scopeSessions: AiVaultSession[]
): AiVaultSession[] {
if (scopeSessions.length === 0) {
return cappedSessions
}
const byId = new Map<string, AiVaultSession>()
for (const session of cappedSessions) {
byId.set(session.id, session)
}
for (const session of scopeSessions) {
byId.set(session.id, session)
}
return [...byId.values()].sort((left, right) => sessionSortTime(right) - sessionSortTime(left))
}
async function scanInScopeSessions(args: {
discoveries: SessionFileDiscovery[]
scopePaths: readonly string[]
alreadyParsedFilePaths: ReadonlySet<string>
platform: NodeJS.Platform
issues: AiVaultScanIssue[]
}): Promise<AiVaultSession[]> {
if (args.scopePaths.length === 0) {
return []
}
const claudeRootDirs = args.discoveries
.filter((discovery) => discovery.agent === 'claude')
.map((discovery) => discovery.rootDir)
const files = await discoverInScopeClaudeFiles({
rootDirs: claudeRootDirs,
scopePaths: args.scopePaths,
limit: SCOPE_PARSE_LIMIT,
excludedFilePaths: args.alreadyParsedFilePaths,
issues: args.issues
})
const candidates = files.map(
(file): SessionFileCandidate => ({ agent: 'claude', file, codexHome: null })
)
if (candidates.length === 0) {
return []
}
// Parse every in-scope candidate (limit === candidate count never early-stops).
return parseSessionCandidates({
candidates,
limit: candidates.length,
platform: args.platform,
issues: args.issues
})
}
async function parseSessionCandidates(args: {
candidates: SessionFileCandidate[]
limit: number
@@ -144,6 +144,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
terminalWindowsPowerShellImplementation: 'powershell.exe',
enableGitHubAttribution: true,
...overrides,
diffWordWrap: overrides.diffWordWrap ?? false,
localWindowsRuntimeDefault: overrides.localWindowsRuntimeDefault ?? { kind: 'windows-host' },
leftSidebarAppearanceMode: overrides.leftSidebarAppearanceMode ?? 'default',
appFontFamily,
+1
View File
@@ -148,6 +148,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
terminalWindowsPowerShellImplementation: 'powershell.exe',
enableGitHubAttribution: true,
...overrides,
diffWordWrap: overrides.diffWordWrap ?? false,
localWindowsRuntimeDefault: overrides.localWindowsRuntimeDefault ?? { kind: 'windows-host' },
leftSidebarAppearanceMode: overrides.leftSidebarAppearanceMode ?? 'default',
appFontFamily,
+182 -20
View File
@@ -16,6 +16,7 @@ import {
computeTrustKey,
computeTrustedHash,
escapeTomlString,
getCodexCanonicalTrustPath,
parseTrustKey,
readHookTrustEntries,
removeHookTrustEntries,
@@ -244,7 +245,31 @@ describe('computeTrustKey', () => {
handlerIndex: 0,
command: 'irrelevant'
})
).toBe(`${realpathSync.native(hooksPath)}:user_prompt_submit:0:0`)
).toBe(`${realpathSync.native(hooksPath).replace(/\\/g, '/')}:user_prompt_submit:0:0`)
})
it('normalizes Windows backslashes to forward slashes in the trust key', () => {
// Why: getCodexCanonicalTrustPath must return a forward-slash key even when
// the path uses native Windows separators, preventing separator mismatch
// against Codex-written literal-string keys.
const winPath = 'C:\\Users\\Rod\\AppData\\Roaming\\orca\\hooks.json'
const key = computeTrustKey({
sourcePath: winPath,
eventLabel: 'session_start',
groupIndex: 0,
handlerIndex: 0,
command: 'echo'
})
expect(key).not.toContain('\\')
expect(key.startsWith('C:/Users/Rod/AppData/Roaming/orca/hooks.json:')).toBe(true)
})
it('preserves literal backslashes in non-Windows-style fallback paths', () => {
// Why: SSH/POSIX paths can legally contain `\` as a filename character;
// only Windows-style separators should be normalized.
expect(getCodexCanonicalTrustPath('/tmp/with\\literal/hooks.json')).toBe(
'/tmp/with\\literal/hooks.json'
)
})
})
@@ -344,10 +369,11 @@ describe('upsertHookTrustEntries', () => {
})
it('collapses duplicate blocks for the same hook key while preserving unrelated hook state', () => {
const key =
'C:\\Users\\me\\AppData\\Roaming\\orca\\codex-runtime-home\\home\\hooks.json:session_start:0:0'
const unrelatedKey =
'C:\\Users\\me\\AppData\\Roaming\\orca\\codex-runtime-home\\home\\hooks.json:stop:0:0'
const sourcePath = 'C:\\Users\\me\\AppData\\Roaming\\orca\\codex-runtime-home\\home\\hooks.json'
const key = `${sourcePath}:session_start:0:0`
const unrelatedSourcePath =
'C:\\Users\\me\\AppData\\Roaming\\orca\\codex-runtime-home\\home\\hooks.json'
const unrelatedKey = `${unrelatedSourcePath}:stop:0:0`
const original = [
`[hooks.state."${escapeTomlString(key)}"]`,
'enabled = true',
@@ -365,7 +391,7 @@ describe('upsertHookTrustEntries', () => {
writeFileSync(configPath, original, 'utf-8')
const entry: CodexTrustEntry = {
sourcePath: 'C:\\Users\\me\\AppData\\Roaming\\orca\\codex-runtime-home\\home\\hooks.json',
sourcePath,
eventLabel: 'session_start',
groupIndex: 0,
handlerIndex: 0,
@@ -373,11 +399,15 @@ describe('upsertHookTrustEntries', () => {
}
upsertHookTrustEntries(configPath, [entry])
// Why: after normalization Orca writes the forward-slash form; the fixture's
// backslash form is matched via normalizeHookTrustKeyForLookup and collapsed.
const normalizedKey = key.replace(/\\/g, '/')
const written = readFileSync(configPath, 'utf-8')
const duplicateKeyOccurrences = written.match(
new RegExp(`\\[hooks\\.state\\."${escapeRegex(escapeTomlString(key))}"\\]`, 'g')
new RegExp(`\\[hooks\\.state\\."${escapeRegex(normalizedKey)}"\\]`, 'g')
)
expect(duplicateKeyOccurrences).toHaveLength(1)
// The unrelated key was not upserted and stays in its original escaped form.
expect(written).toContain(`[hooks.state."${escapeTomlString(unrelatedKey)}"]`)
expect(written).toContain('trusted_hash = "sha256:KEEP"')
expect(written).toContain('enabled = false')
@@ -406,10 +436,12 @@ describe('upsertHookTrustEntries', () => {
}
upsertHookTrustEntries(configPath, [entry])
// Why: Orca writes the normalized forward-slash key form after replacement.
const normalizedKey = key.replace(/\\/g, '/')
const written = readFileSync(configPath, 'utf-8')
expect(written).not.toContain(`[hooks.state.'${key}']`)
expect(written.match(/\[hooks\.state\./g)).toHaveLength(1)
expect(written).toContain(`[hooks.state."${escapeTomlString(key)}"]`)
expect(written).toContain(`[hooks.state."${normalizedKey}"]`)
expect(written).toContain('enabled = false')
expect(written).toContain(`trusted_hash = "${computeTrustedHash(entry)}"`)
})
@@ -710,7 +742,9 @@ describe('upsertHookTrustEntries', () => {
expect(written).toContain('[hooks.state."/x/hooks.json:pre_tool_use:0:0"]')
})
it('escapes literal `"` and `\\` in the source path inside the trust block header', () => {
it('escapes literal `"` and `\\` in non-Windows source paths inside the trust block header', () => {
// Why: a backslash in a POSIX path can be a literal filename character, so
// it must still be escaped instead of normalized away.
const entry: CodexTrustEntry = {
sourcePath: '/x/with"quote\\and\\back/hooks.json',
eventLabel: 'pre_tool_use',
@@ -821,6 +855,87 @@ describe('upsertHookTrustEntries', () => {
expect(headerCount).toBe(1)
expect(written).not.toContain('sha256:OLD')
})
it('finds and replaces a Codex literal-string backslash block when Orca upserts with forward-slash key', () => {
// Why: Codex writes literal-string keys with raw backslashes; Orca builds
// keys with forward slashes after getCodexCanonicalTrustPath normalization.
// normalizeHookTrustKeyForLookup must bridge that separator gap so upsert replaces
// rather than appends.
const backslashPath = 'C:\\Users\\Rod\\AppData\\Roaming\\orca\\hooks.json'
const literalKey = `${backslashPath}:session_start:0:0`
const original = [
`[hooks.state.'${literalKey}']`,
'enabled = true',
'trusted_hash = "sha256:CODEX-WRITTEN"',
''
].join('\n')
writeFileSync(configPath, original, 'utf-8')
const entry: CodexTrustEntry = {
sourcePath: backslashPath,
eventLabel: 'session_start',
groupIndex: 0,
handlerIndex: 0,
command: 'echo session'
}
upsertHookTrustEntries(configPath, [entry])
const written = readFileSync(configPath, 'utf-8')
expect((written.match(/\[hooks\.state\./g) ?? []).length).toBe(1)
expect(written).not.toContain('sha256:CODEX-WRITTEN')
expect(written).toContain(`trusted_hash = "${computeTrustedHash(entry)}"`)
})
it('produces exactly one [hooks.state.*] header after two consecutive upserts on Windows path', () => {
// Why: idempotency guard — repeated auto-install on app start must not
// accumulate duplicate trust blocks and produce invalid TOML.
const entry: CodexTrustEntry = {
sourcePath: 'C:\\Users\\Rod\\AppData\\Roaming\\orca\\hooks.json',
eventLabel: 'session_start',
groupIndex: 0,
handlerIndex: 0,
command: 'echo session'
}
upsertHookTrustEntries(configPath, [entry])
upsertHookTrustEntries(configPath, [entry])
const written = readFileSync(configPath, 'utf-8')
expect((written.match(/\[hooks\.state\./g) ?? []).length).toBe(1)
})
it.skipIf(process.platform !== 'win32')(
'finds a Codex-written block with lowercased username when Orca key has mixed-case username',
() => {
// Why: realpathSync.native casing can differ between what Codex wrote
// (C:\Users\rod\...) and what Orca resolves (C:\Users\Rod\...).
// normalizeHookTrustKeyForLookup case-folds on Windows so the existing block is
// replaced rather than a duplicate appended.
const lowercasePath = 'C:\\Users\\rod\\AppData\\Roaming\\orca\\hooks.json'
const mixedCasePath = 'C:\\Users\\Rod\\AppData\\Roaming\\orca\\hooks.json'
const literalKey = `${lowercasePath}:session_start:0:0`
const original = [
`[hooks.state.'${literalKey}']`,
'enabled = true',
'trusted_hash = "sha256:LOWERCASE"',
''
].join('\n')
writeFileSync(configPath, original, 'utf-8')
const entry: CodexTrustEntry = {
sourcePath: mixedCasePath,
eventLabel: 'session_start',
groupIndex: 0,
handlerIndex: 0,
command: 'echo session'
}
upsertHookTrustEntries(configPath, [entry])
const written = readFileSync(configPath, 'utf-8')
expect((written.match(/\[hooks\.state\./g) ?? []).length).toBe(1)
expect(written).not.toContain('sha256:LOWERCASE')
expect(written).toContain(`trusted_hash = "${computeTrustedHash(entry)}"`)
}
)
})
describe('upsertProjectTrustLevel', () => {
@@ -836,8 +951,9 @@ describe('upsertProjectTrustLevel', () => {
mkdirSync(nestedDir)
mkdirSync(projectDir)
const aliasedProjectPath = join(nestedDir, '..', 'project')
const trustedPath = realpathSync.native(aliasedProjectPath)
const trustedTomlPath = trustedPath.replaceAll('\\', '\\\\').replaceAll('"', '\\"')
// Why: getCodexCanonicalTrustPath normalizes separators to forward slashes.
const trustedPath = realpathSync.native(aliasedProjectPath).replace(/\\/g, '/')
const trustedTomlPath = trustedPath.replaceAll('"', '\\"')
expect(upsertProjectTrustLevelInContent('', aliasedProjectPath, 'trusted')).toBe(
[`[projects."${trustedTomlPath}"]`, 'trust_level = "trusted"', ''].join('\n')
@@ -884,17 +1000,38 @@ describe('upsertProjectTrustLevel', () => {
expect(updated).toContain('[other]\nvalue = 1')
})
it('preserves CRLF endings and escapes the project path in the header', () => {
it('preserves CRLF endings and normalizes Windows path separators in the header', () => {
// Why: getCodexCanonicalTrustPath normalizes backslashes to forward slashes;
// the project path header uses forward slashes even on Windows input.
const original = ['[profiles.default]', 'model = "gpt-5"', ''].join('\r\n')
const updated = upsertProjectTrustLevelInContent(original, 'C:\\Users\\nw\\repo', 'trusted')
expect(updated).toContain(
['[projects."C:\\\\Users\\\\nw\\\\repo"]', 'trust_level = "trusted"', ''].join('\r\n')
['[projects."C:/Users/nw/repo"]', 'trust_level = "trusted"', ''].join('\r\n')
)
expect(updated).toContain('[profiles.default]\r\nmodel = "gpt-5"')
})
it('updates an existing Windows backslash project block after separator normalization', () => {
// Why: forward-slash writes must not strand an older backslash-form
// project table and append a duplicate project trust block.
const original = [
'[projects."C:\\\\Users\\\\nw\\\\repo"]',
'notes = "keep"',
'trust_level = "untrusted"',
''
].join('\n')
const updated = upsertProjectTrustLevelInContent(original, 'C:\\Users\\nw\\repo', 'trusted')
expect(updated.match(/\[projects\./g)).toHaveLength(1)
expect(updated).toContain('[projects."C:\\\\Users\\\\nw\\\\repo"]')
expect(updated).toContain('notes = "keep"')
expect(updated).toContain('trust_level = "trusted"')
expect(updated).not.toContain('trust_level = "untrusted"')
})
it('writes config.toml and avoids rewriting an already-trusted project', () => {
upsertProjectTrustLevel(configPath, '/tmp/codex-ws', 'trusted')
const firstWrite = readFileSync(configPath, 'utf-8')
@@ -1181,10 +1318,11 @@ describe('readHookTrustEntries', () => {
expect(result.get(key)).toEqual({ trustedHash: 'sha256:AAA', enabled: true })
})
it('unescapes `\\\\` in the block key', () => {
it('normalizes backslash block key to forward-slash at ingestion', () => {
// Why: a real Windows path on disk like C:\foo gets written escaped as
// `C:\\foo` inside the TOML key — the returned Map should expose the
// original unescaped form.
// `C:\\foo` inside the TOML key. The Map key is normalized (backslash ->
// forward-slash) so computeTrustKey lookups match regardless of how Codex
// encoded the path separator.
const original = [
'[hooks.state."C:\\\\foo\\\\hooks.json:pre_tool_use:0:0"]',
'enabled = true',
@@ -1194,13 +1332,13 @@ describe('readHookTrustEntries', () => {
writeFileSync(configPath, original, 'utf-8')
const result = readHookTrustEntries(configPath)
expect(result.get('C:\\foo\\hooks.json:pre_tool_use:0:0')?.trustedHash).toBe('sha256:WIN')
expect(result.get('C:/foo/hooks.json:pre_tool_use:0:0')?.trustedHash).toBe('sha256:WIN')
})
it('reads a literal-string hook table key', () => {
const key = 'C:\\foo\\hooks.json:session_start:0:0'
const rawKey = 'C:\\foo\\hooks.json:session_start:0:0'
const original = [
`[hooks.state.'${key}']`,
`[hooks.state.'${rawKey}']`,
'enabled = false',
'trusted_hash = "sha256:LITERAL"',
''
@@ -1208,9 +1346,33 @@ describe('readHookTrustEntries', () => {
writeFileSync(configPath, original, 'utf-8')
const result = readHookTrustEntries(configPath)
expect(result.get(key)).toEqual({ trustedHash: 'sha256:LITERAL', enabled: false })
expect(result.get('C:/foo/hooks.json:session_start:0:0')).toEqual({
trustedHash: 'sha256:LITERAL',
enabled: false
})
})
it.skipIf(process.platform !== 'win32')(
'supports case-insensitive lookups for Windows hook trust keys read from config',
() => {
// Why: Codex and realpathSync.native can disagree on user-path casing;
// status checks still need Map.get(computeTrustKey(...)) to find the row.
const rawKey = 'C:\\Users\\rod\\AppData\\Roaming\\orca\\hooks.json:session_start:0:0'
const lookupKey = 'C:/Users/Rod/AppData/Roaming/orca/hooks.json:session_start:0:0'
const original = [
`[hooks.state.'${rawKey}']`,
'enabled = true',
'trusted_hash = "sha256:CASE"',
''
].join('\n')
writeFileSync(configPath, original, 'utf-8')
const result = readHookTrustEntries(configPath)
expect(result.get(lookupKey)).toEqual({ trustedHash: 'sha256:CASE', enabled: true })
}
)
it('reads entries from a CRLF-terminated config', () => {
const key = '/x/hooks.json:pre_tool_use:0:0'
const original = [
+57 -7
View File
@@ -52,6 +52,26 @@ export type CodexHookTrustState = {
export type CodexProjectTrustLevel = 'trusted' | 'untrusted'
// Why: callers use computeTrustKey() for lookups, while existing TOML can carry
// Codex-written separator/casing variants. Normalize both sides at the Map edge.
class HookTrustEntryMap extends Map<string, CodexHookTrustState> {
override get(key: string): CodexHookTrustState | undefined {
return super.get(normalizeHookTrustKeyForLookup(key))
}
override has(key: string): boolean {
return super.has(normalizeHookTrustKeyForLookup(key))
}
override delete(key: string): boolean {
return super.delete(normalizeHookTrustKeyForLookup(key))
}
override set(key: string, value: CodexHookTrustState): this {
return super.set(normalizeHookTrustKeyForLookup(key), value)
}
}
// Why: matches Codex's canonical_json. Sorts object keys recursively before
// SHA-256ing; arrays preserve order.
function canonicalize(value: unknown): unknown {
@@ -102,11 +122,23 @@ export function getCodexCanonicalTrustPath(sourcePath: string): string {
try {
// Why: Codex canonicalizes trust paths before building config keys. On
// macOS, /var is a symlink to /private/var; trusting the raw path still
// leaves the TUI in review/trust prompts.
return realpathSync.native(sourcePath)
// leaves the TUI in review/trust prompts. Forward-slash normalization
// prevents separator mismatch against Codex-written literal-string keys on Windows.
return normalizeWindowsPathSeparators(realpathSync.native(sourcePath))
} catch {
return normalizeWindowsPathSeparators(sourcePath)
}
}
function normalizeWindowsPathSeparators(sourcePath: string): string {
if (!usesWindowsPathSeparators(sourcePath)) {
return sourcePath
}
return sourcePath.replace(/\\/g, '/')
}
function usesWindowsPathSeparators(sourcePath: string): boolean {
return /^[A-Za-z]:[\\/]/.test(sourcePath) || sourcePath.startsWith('\\\\')
}
export function parseTrustKey(key: string): {
@@ -342,6 +374,17 @@ type TrustBlockRange = {
end: number
}
// Why: separator and casing drift between Codex-written keys (raw backslash,
// potentially lowercased) and Orca-built keys (forward-slash, realpathSync.native
// casing) must not prevent findTrustBlockRanges from matching an existing block.
export function normalizeHookTrustKeyForLookup(key: string): string {
const parsed = parseTrustKey(key)
const separated = parsed
? `${normalizeWindowsPathSeparators(parsed.sourcePath)}:${parsed.eventLabel}:${parsed.groupIndex}:${parsed.handlerIndex}`
: normalizeWindowsPathSeparators(key)
return process.platform === 'win32' ? separated.toLowerCase() : separated
}
function findTrustBlockRanges(content: string, key: string): TrustBlockRange[] {
const ranges: TrustBlockRange[] = []
let cursor = 0
@@ -355,7 +398,10 @@ function findTrustBlockRanges(content: string, key: string): TrustBlockRange[] {
const headerKey = isInsideTomlMultilineString(multilineState)
? null
: parseHookStateHeaderKey(line)
if (headerKey === key) {
if (
headerKey !== null &&
normalizeHookTrustKeyForLookup(headerKey) === normalizeHookTrustKeyForLookup(key)
) {
const headerLineEnd = rawLine.endsWith('\r') ? lineEnd - 1 : lineEnd
const after = content.slice(headerLineEnd)
const nextHeaderRel = findNextTableHeader(after)
@@ -371,9 +417,13 @@ function findTrustBlockRanges(content: string, key: string): TrustBlockRange[] {
}
function buildProjectHeaderPattern(projectPath: string): RegExp {
const escapedPath = escapeRegex(escapeTomlString(projectPath))
const headerPaths = [escapeRegex(escapeTomlString(projectPath))]
if (usesWindowsPathSeparators(projectPath)) {
headerPaths.push(escapeRegex(escapeTomlString(projectPath.replace(/\//g, '\\'))))
}
return new RegExp(
`(^|\\r?\\n)[ \\t]*\\[projects\\."${escapedPath}"\\][ \\t]*(?:#[^\\r\\n]*)?(?=\\r?\\n|$)`
`(^|\\r?\\n)[ \\t]*\\[projects\\."(?:${headerPaths.join('|')})"\\][ \\t]*(?:#[^\\r\\n]*)?(?=\\r?\\n|$)`,
process.platform === 'win32' ? 'i' : undefined
)
}
@@ -681,7 +731,7 @@ function removeTrustBlock(content: string, key: string): string {
}
export function readHookTrustEntries(configPath: string): Map<string, CodexHookTrustState> {
const result = new Map<string, CodexHookTrustState>()
const result = new HookTrustEntryMap()
if (!existsSync(configPath)) {
return result
}
@@ -707,7 +757,7 @@ export function readHookTrustEntries(configPath: string): Map<string, CodexHookT
// a line scan beats pulling in a full TOML value parser.
const hashMatch = /^[ \t]*trusted_hash[ \t]*=[ \t]*"((?:[^"\\]|\\.)*)"/m.exec(block)
const enabledMatch = /^[ \t]*enabled[ \t]*=[ \t]*(true|false)[ \t\r]*(?:#.*)?$/m.exec(block)
result.set(key, {
result.set(normalizeHookTrustKeyForLookup(key), {
trustedHash: hashMatch ? unescapeTomlString(hashMatch[1]) : undefined,
enabled: enabledMatch ? enabledMatch[1] === 'true' : undefined
})
+72 -2
View File
@@ -88,9 +88,11 @@ function canonicalizeHookTrustKeyForTest(key: string): string {
}
const sourcePath = key.slice(0, thirdLast)
try {
return `${realpathSync.native(sourcePath)}${key.slice(thirdLast)}`
// Why: mirrors getCodexCanonicalTrustPath separator normalization so test
// expectations match the forward-slash keys Orca writes on Windows.
return `${realpathSync.native(sourcePath).replace(/\\/g, '/')}${key.slice(thirdLast)}`
} catch {
return key
return key.replace(/\\/g, '/')
}
}
@@ -133,6 +135,32 @@ describe('CodexHookService', () => {
expect(trustConfig).toContain(':permission_request:0:0')
})
it('drops plugin manager metadata from runtime hooks.json during install', () => {
const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home')
mkdirSync(managedCodexHome, { recursive: true })
writeFileSync(
join(managedCodexHome, 'hooks.json'),
`${JSON.stringify({
hooks: {},
_managed: {
'compound-engineering': {
Stop: [0]
}
}
})}\n`,
'utf-8'
)
expect(new CodexHookService().install().state).toBe('installed')
const hooksConfig = JSON.parse(readFileSync(join(managedCodexHome, 'hooks.json'), 'utf-8')) as {
hooks: Record<string, unknown>
_managed?: unknown
}
expect(hooksConfig._managed).toBeUndefined()
expect(Object.keys(hooksConfig)).toEqual(['hooks'])
})
// Why: #6078 — a Windows user profile path like `C:\Users\Jane Doe` used to
// be written verbatim as the hook command, so Codex split it at the space and
// the hook exited with code 1. The managed command uses an encoded launcher
@@ -720,6 +748,11 @@ describe('CodexHookService', () => {
{ hooks: [{ type: 'command', command: legacyCommand }] }
],
SessionStart: [{ hooks: [{ type: 'command', command: legacyCommand }] }]
},
_managed: {
'external-manager': {
Stop: [0]
}
}
},
null,
@@ -752,9 +785,11 @@ describe('CodexHookService', () => {
const systemHooks = JSON.parse(readFileSync(systemHooksPath, 'utf-8')) as {
hooks: Record<string, { hooks?: { command?: string }[] }[]>
_managed?: unknown
}
expect(systemHooks.hooks.Stop).toEqual([{ hooks: [{ type: 'command', command: 'user-hook' }] }])
expect(systemHooks.hooks.SessionStart).toBeUndefined()
expect(systemHooks._managed).toEqual({ 'external-manager': { Stop: [0] } })
const systemToml = readFileSync(join(systemCodexHome, 'config.toml'), 'utf-8')
expect(systemToml).toContain('model = "system-model"')
expect(systemToml).not.toContain(':stop:1:0')
@@ -913,6 +948,41 @@ describe('CodexHookService', () => {
expect(existsSync(profilePath)).toBe(false)
})
it('sanitizes runtime hooks.json metadata during remove even without managed hooks', () => {
const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home')
const managedHooksPath = join(managedCodexHome, 'hooks.json')
mkdirSync(managedCodexHome, { recursive: true })
writeFileSync(
managedHooksPath,
`${JSON.stringify(
{
hooks: {
Stop: [{ hooks: [{ type: 'command', command: 'user-hook' }] }]
},
_managed: {
'compound-engineering': {
Stop: [0]
}
}
},
null,
2
)}\n`,
'utf-8'
)
const status = new CodexHookService().remove()
expect(status.state).toBe('not_installed')
const hooksConfig = JSON.parse(readFileSync(managedHooksPath, 'utf-8')) as {
hooks: Record<string, unknown>
_managed?: unknown
}
expect(hooksConfig._managed).toBeUndefined()
expect(Object.keys(hooksConfig)).toEqual(['hooks'])
expect(hooksConfig.hooks.Stop).toEqual([{ hooks: [{ type: 'command', command: 'user-hook' }] }])
})
it('cleans duplicate Codex hook representations while keeping status hooks in runtime CODEX_HOME', () => {
const systemCodexHome = join(tmpHome, '.codex')
const systemHooksPath = join(systemCodexHome, 'hooks.json')
+23 -12
View File
@@ -31,6 +31,7 @@ import {
computeTrustedHash,
escapeTomlString,
getCodexCanonicalTrustPath,
normalizeHookTrustKeyForLookup,
parseTrustKey,
readHookTrustEntries,
removeHookTrustEntries,
@@ -62,6 +63,12 @@ function getConfigPath(): string {
return join(getOrcaManagedCodexHomePath(), 'hooks.json')
}
function writeCodexHooksJson(configPath: string, hooks: Record<string, HookDefinition[]>): void {
// Why: Codex rejects unknown top-level hooks.json fields, so plugin manager
// bookkeeping such as `_managed` must not survive Orca's rewrite.
writeHooksJson(configPath, { hooks })
}
function getCodexConfigTomlPath(): string {
return join(getOrcaManagedCodexHomePath(), 'config.toml')
}
@@ -209,7 +216,10 @@ function removeStaleRuntimeHookTrustEntries(
expectedEntries: readonly CodexTrustEntry[]
): void {
const expectedHashes = new Map(
expectedEntries.map((entry) => [computeTrustKey(entry), computeTrustedHash(entry)])
expectedEntries.map((entry) => [
normalizeHookTrustKeyForLookup(computeTrustKey(entry)),
computeTrustedHash(entry)
])
)
const canonicalRuntimeHooksPath = getCodexCanonicalTrustPath(runtimeHooksPath)
const staleKeys: string[] = []
@@ -218,7 +228,7 @@ function removeStaleRuntimeHookTrustEntries(
if (!parsed || getCodexCanonicalTrustPath(parsed.sourcePath) !== canonicalRuntimeHooksPath) {
continue
}
if (expectedHashes.get(key) === state.trustedHash) {
if (expectedHashes.get(normalizeHookTrustKeyForLookup(key)) === state.trustedHash) {
continue
}
staleKeys.push(key)
@@ -532,6 +542,8 @@ function cleanupLegacySystemManagedHooks(): void {
// Why: Codex hooks moved to Orca's managed CODEX_HOME; old entries in
// ~/.codex would keep external Codex sessions reporting into Orca.
if (removedManagedHook) {
// Why: this is the user's system hooks file, not Orca's runtime copy.
// Remove only stale Orca hook entries and preserve other managers' metadata.
writeHooksJson(legacyConfigPath, { ...config, hooks: nextHooks })
}
removeMatchingTrustEntries(getSystemCodexConfigTomlPath(), trustEntries)
@@ -889,7 +901,7 @@ export class CodexHookService {
config.hooks = nextHooks
writeManagedScript(scriptPath, getManagedScript())
writeHooksJson(configPath, config)
writeCodexHooksJson(configPath, nextHooks)
// Why: trust entries write last so a half-write can't leave a hash
// pointing at a hook that doesn't exist. Surface failures — without this,
// getStatus would report green for a hook Codex won't actually fire.
@@ -979,7 +991,9 @@ export class CodexHookService {
// Why: SSH remotes use POSIX `.sh` hook paths even when Orca itself is
// running on Windows; never derive remote script syntax from local OS.
await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix'))
await writeHooksJsonRemote(sftp, remoteConfigPath, config)
// Why: SSH installs edit the user's remote ~/.codex/hooks.json directly.
// Preserve non-Orca top-level metadata while replacing the hooks tree.
await writeHooksJsonRemote(sftp, remoteConfigPath, { ...config, hooks: nextHooks })
try {
const existingToml = (await readTextFileRemote(sftp, remoteTomlPath)) ?? ''
const updatedToml = upsertHookTrustEntriesInContent(existingToml, trustEntries)
@@ -1035,7 +1049,7 @@ export class CodexHookService {
const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName())
const hookPlan = getRuntimeHooksWithSystemUserHooks(config.hooks, isManagedCommand)
config.hooks = hookPlan.hooks
writeHooksJson(configPath, config)
writeCodexHooksJson(configPath, hookPlan.hooks)
try {
const tomlPath = getCodexConfigTomlPath()
@@ -1080,7 +1094,6 @@ export class CodexHookService {
}
const nextHooks = { ...config.hooks }
let removedManagedHooks = false
// Why: same broad matcher as install(), so remove() also cleans up stale
// entries from older builds even if the current scriptPath has moved.
const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName())
@@ -1092,18 +1105,16 @@ export class CodexHookService {
continue
}
const cleaned = removeManagedCommands(definitions, isManagedCommand)
if (JSON.stringify(cleaned) !== JSON.stringify(definitions)) {
removedManagedHooks = true
}
if (cleaned.length === 0) {
delete nextHooks[eventName]
} else {
nextHooks[eventName] = cleaned
}
}
if (configExists && removedManagedHooks) {
config.hooks = nextHooks
writeHooksJson(configPath, config)
if (configExists) {
// Why: remove() can be the only repair path for a parseable runtime file
// whose top-level plugin metadata makes Codex reject hooks.json.
writeCodexHooksJson(configPath, nextHooks)
}
// Why: also drop our trust entries so config.toml doesn't accumulate dead
+2
View File
@@ -8,6 +8,7 @@
*/
import { startDaemon, type DaemonHandle } from './daemon-main'
import { createPtySubprocess } from './pty-subprocess'
import { warmPwshAvailabilityCache } from '../pwsh'
export function parseArgs(argv: string[]): { socketPath: string; tokenPath: string } {
let socketPath = ''
@@ -32,6 +33,7 @@ export function parseArgs(argv: string[]): { socketPath: string; tokenPath: stri
async function main(): Promise<void> {
const { socketPath, tokenPath } = parseArgs(process.argv.slice(2))
void warmPwshAvailabilityCache()
// Why: node-pty can throw a C++ Napi::Error that escapes all JS try/catch
// blocks (e.g. writing to a PTY whose fd was closed between the native
+230 -61
View File
@@ -81,58 +81,211 @@ 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)
expect(details?.timelineItems).toHaveLength(300)
expect(details?.timelineItems.at(0)).toMatchObject({ assignee: 'assignee-3-0' })
expect(details?.timelineItems.at(-1)).toMatchObject({ assignee: 'assignee-6-89' })
})
it('falls back to REST + GraphQL when the collapsed issue query fails', async () => {
@@ -142,17 +295,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 +320,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 +356,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 +383,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 +422,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 +441,7 @@ describe('getWorkItemDetails', () => {
})
}
}
if (target === 'repos/stablyai/orca/pulls/42/files?per_page=100') {
if (target === 'repos/acme/widgets/pulls/42/files?per_page=100') {
return { stdout: '[]' }
}
const query = args.find((arg) => arg.startsWith('query=')) ?? ''
+218 -10
View File
@@ -6,6 +6,8 @@ import type {
GitHubPRFile,
GitHubPRFileContents,
GitHubPRFileViewedState,
GitHubIssueTimelineItem,
GitHubIssueTimelineTarget,
GitHubWorkItem,
GitHubWorkItemDetails,
PRCheckDetail,
@@ -30,6 +32,10 @@ import { isMaxBufferOverflowError } from '../git/max-buffer-overflow'
// at 100 per page; we cap at a reasonable total so a massive PR cannot starve
// the gh semaphore while we fetch file listings.
const MAX_PR_FILES = 300
// Why: issue timelines can be extremely noisy from automation and cross-links.
// Bound drawer detail work so one huge issue cannot monopolize gh/API time.
const MAX_ISSUE_TIMELINE_ITEMS = 300
const GITHUB_REST_PAGE_SIZE = 100
// Why: hosted PR files must exceed the renderer's large-diff threshold before
// we give up on the raw fetch; otherwise the UI sees an empty diff instead of
// the safety fallback.
@@ -126,6 +132,197 @@ type GraphQLIssueDetailsResponse = {
errors?: { message?: string }[]
}
type GitHubOwnerRepoSlug = { owner: string; repo: string }
type RestTimelineUser = {
login?: string | null
avatar_url?: string | null
}
type RestTimelineIssue = {
number?: number | null
title?: string | null
html_url?: string | null
repository?: {
name?: string | null
owner?: { login?: string | null } | null
} | null
pull_request?: unknown
}
type RestTimelineEvent = {
id?: number | string | null
node_id?: string | null
event?: string | null
actor?: RestTimelineUser | null
user?: RestTimelineUser | null
assignee?: RestTimelineUser | null
created_at?: string | null
source?: {
issue?: RestTimelineIssue | null
} | null
closer?: RestTimelineIssue | null
state_reason?: string | null
project_card?: {
column_name?: string | null
previous_column_name?: string | null
project_url?: string | null
} | null
project?: {
name?: string | null
} | null
project_column_name?: string | null
previous_column_name?: string | null
}
function isSupportedTimelineEvent(
eventName: string | null | undefined
): eventName is GitHubIssueTimelineItem['event'] {
return (
eventName === 'assigned' ||
eventName === 'unassigned' ||
eventName === 'mentioned' ||
eventName === 'cross-referenced' ||
eventName === 'closed' ||
eventName === 'reopened' ||
eventName === 'moved_columns_in_project'
)
}
function mapTimelineTarget(
issue: RestTimelineIssue | null | undefined
): GitHubIssueTimelineTarget | undefined {
if (!issue || typeof issue.number !== 'number' || !issue.html_url) {
return undefined
}
const owner = issue.repository?.owner?.login
const repo = issue.repository?.name
return {
type: issue.pull_request ? 'pr' : 'issue',
number: issue.number,
title: issue.title ?? '',
url: issue.html_url,
repository: owner && repo ? `${owner}/${repo}` : undefined
}
}
function getTimelineActor(event: RestTimelineEvent): { login: string; avatarUrl: string } {
const actor = event.actor ?? event.user
return {
login: actor?.login ?? 'ghost',
avatarUrl: actor?.avatar_url ?? ''
}
}
function mapRestTimelineEvent(event: RestTimelineEvent): GitHubIssueTimelineItem | null {
const eventName = event.event
if (!isSupportedTimelineEvent(eventName)) {
return null
}
if (!event.created_at) {
return null
}
const actor = getTimelineActor(event)
const id = String(event.node_id ?? event.id ?? `${eventName}:${event.created_at}`)
const base = {
id,
event: eventName,
actor: actor.login,
actorAvatarUrl: actor.avatarUrl,
createdAt: event.created_at
}
if (eventName === 'assigned' || eventName === 'unassigned') {
return {
...base,
assignee: event.assignee?.login ?? undefined
}
}
if (eventName === 'mentioned' || eventName === 'cross-referenced') {
return {
...base,
source: mapTimelineTarget(event.source?.issue)
}
}
if (eventName === 'closed') {
return {
...base,
stateReason: event.state_reason ?? null,
closer: mapTimelineTarget(event.closer ?? event.source?.issue)
}
}
if (eventName === 'moved_columns_in_project') {
return {
...base,
previousColumnName:
event.previous_column_name ?? event.project_card?.previous_column_name ?? null,
columnName: event.project_column_name ?? event.project_card?.column_name ?? null,
projectName: event.project?.name ?? null
}
}
return base
}
function parseRestTimelineEventLines(stdout: string): RestTimelineEvent[] {
const events: RestTimelineEvent[] = []
for (const line of stdout.split('\n')) {
const trimmed = line.trim()
if (!trimmed) {
continue
}
try {
const parsed = JSON.parse(trimmed) as unknown
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
events.push(parsed)
}
} catch {
// Skip malformed jq lines; timeline activity is auxiliary to issue details.
}
}
return events
}
async function getIssueTimelineItems(
ownerRepo: GitHubOwnerRepoSlug,
issueNumber: number,
ghOptions: ReturnType<typeof ghRepoExecOptions>
): Promise<GitHubIssueTimelineItem[]> {
try {
const items: GitHubIssueTimelineItem[] = []
for (let page = 1; items.length < MAX_ISSUE_TIMELINE_ITEMS; page += 1) {
const { stdout } = await ghExecFileAsync(
[
'api',
'--cache',
'60s',
`repos/${ownerRepo.owner}/${ownerRepo.repo}/issues/${issueNumber}/timeline?per_page=${GITHUB_REST_PAGE_SIZE}&page=${page}`,
'--jq',
'.[] | @json'
],
ghOptions
)
// Why: --jq emits compact NDJSON while explicit pages let us stop once
// supported activity reaches the drawer cap.
const pageEvents = parseRestTimelineEventLines(stdout)
for (const event of pageEvents) {
const item = mapRestTimelineEvent(event)
if (!item) {
continue
}
items.push(item)
if (items.length === MAX_ISSUE_TIMELINE_ITEMS) {
break
}
}
if (pageEvents.length < GITHUB_REST_PAGE_SIZE) {
break
}
}
return items
} catch {
return []
}
}
async function getIssueDetailsViaGraphQL(
repoPath: string,
issueNumber: number,
@@ -136,6 +333,7 @@ async function getIssueDetailsViaGraphQL(
comments: PRComment[]
assignees: string[]
participants: GitHubAssignableUser[]
timelineItems: GitHubIssueTimelineItem[]
} | null> {
const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions))
const ownerRepo = await getIssueOwnerRepo(
@@ -198,11 +396,13 @@ async function getIssueDetailsViaGraphQL(
name: u.name ?? null,
avatarUrl: u.avatarUrl ?? ''
}))
const timelineItems = await getIssueTimelineItems(ownerRepo, issueNumber, ghOptions)
return {
body: issue.body ?? '',
comments,
assignees,
participants
participants,
timelineItems
}
} catch {
return null
@@ -471,7 +671,12 @@ async function getIssueBodyAndComments(
issueNumber: number,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<{ body: string; comments: PRComment[]; assignees: string[] }> {
): Promise<{
body: string
comments: PRComment[]
assignees: string[]
timelineItems: GitHubIssueTimelineItem[]
}> {
const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions))
const ownerRepo = await getIssueOwnerRepo(
repoPath,
@@ -480,7 +685,7 @@ async function getIssueBodyAndComments(
)
try {
if (ownerRepo) {
const [issueResult, commentsResult] = await Promise.all([
const [issueResult, commentsResult, timelineItems] = await Promise.all([
ghExecFileAsync(
[
'api',
@@ -498,7 +703,8 @@ async function getIssueBodyAndComments(
`repos/${ownerRepo.owner}/${ownerRepo.repo}/issues/${issueNumber}/comments?per_page=100`
],
ghOptions
)
),
getIssueTimelineItems(ownerRepo, issueNumber, ghOptions)
])
const issue = JSON.parse(issueResult.stdout) as {
body?: string | null
@@ -523,7 +729,7 @@ async function getIssueBodyAndComments(
})
)
const assignees = (issue.assignees ?? []).map((a) => a.login)
return { body: issue.body ?? '', comments, assignees }
return { body: issue.body ?? '', comments, assignees, timelineItems }
}
// Fallback: non-GitHub remote
const { stdout } = await ghExecFileAsync(
@@ -551,9 +757,9 @@ async function getIssueBodyAndComments(
})
)
const fallbackAssignees = (data.assignees ?? []).map((a) => a.login)
return { body: data.body ?? '', comments, assignees: fallbackAssignees }
return { body: data.body ?? '', comments, assignees: fallbackAssignees, timelineItems: [] }
} catch {
return { body: '', comments: [], assignees: [] }
return { body: '', comments: [], assignees: [], timelineItems: [] }
}
}
@@ -802,12 +1008,13 @@ export async function getWorkItemDetails(
body: collapsed.body,
comments: collapsed.comments,
assignees: collapsed.assignees,
participants: collapsed.participants
participants: collapsed.participants,
timelineItems: collapsed.timelineItems
}
}
// Why: fall back to body/comments and GraphQL participants in parallel;
// the mention-participant merge is a cheap local operation afterward.
const [{ body, comments, assignees }, participants] = await Promise.all([
const [{ body, comments, assignees, timelineItems }, participants] = await Promise.all([
getIssueBodyAndComments(repoPath, item.number, connectionId, localGitOptions),
getWorkItemParticipants(repoPath, item, connectionId, localGitOptions)
])
@@ -824,7 +1031,8 @@ export async function getWorkItemDetails(
body,
comments,
assignees,
participants: mentionParticipants
participants: mentionParticipants,
timelineItems
}
}
+6 -1
View File
@@ -22,7 +22,11 @@ let inflightKey: string | null = null
let handlerOptions: AiVaultHandlerOptions = {}
async function listAiVaultSessions(args?: AiVaultListArgs): Promise<AiVaultListResult> {
const key = String(args?.limit ?? 'default')
// Scope paths change the result set, so they must be part of the cache key.
const key = JSON.stringify({
limit: args?.limit ?? 'default',
scopePaths: args?.scopePaths ?? []
})
const now = Date.now()
// Why: opening this panel repeatedly should not re-parse hundreds of JSONL
// transcripts; explicit refreshes bypass the cache but not an active scan.
@@ -40,6 +44,7 @@ async function listAiVaultSessions(args?: AiVaultListArgs): Promise<AiVaultListR
inflightList = (async () =>
scanAiVaultSessions({
limit: args?.limit,
scopePaths: args?.scopePaths,
additionalCodexSessionsDirs,
wslHomeDirs: await getAiVaultWslHomeDirs()
}))()
+16 -2
View File
@@ -81,6 +81,7 @@ import { track } from '../telemetry/client'
import { getCohortAtEmit } from '../telemetry/cohort-classifier'
import type { RepoMethod } from '../../shared/telemetry-events'
import { detectRepoIconAndUpstream } from '../repo-icon-autodetect'
import { enrichMissingRepoGitRemoteIdentities } from '../repo-git-remote-identity-enrichment'
import { getProjectHostSetupForRepo } from '../../shared/project-host-setup-projection'
import { normalizeExecutionHostId, parseExecutionHostId } from '../../shared/execution-host'
import { joinRemotePath } from '../ssh/ssh-remote-platform'
@@ -1137,10 +1138,18 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
ipcMain.removeHandler('sparsePresets:remove')
ipcMain.handle('repos:list', () => {
enrichMissingRepoGitRemoteIdentities(store, {
onChanged: () => notifyReposChanged(mainWindow)
})
return store.getRepos()
})
ipcMain.handle('projects:list', () => store.getProjects())
ipcMain.handle('projects:list', () => {
enrichMissingRepoGitRemoteIdentities(store, {
onChanged: () => notifyReposChanged(mainWindow)
})
return store.getProjects()
})
ipcMain.handle('projects:update', (_event, rawArgs: ProjectUpdateArgs): Project | null => {
const args = parseProjectGroupIpcArgs(
@@ -1151,7 +1160,12 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
return store.updateProject(args.projectId, args.updates)
})
ipcMain.handle('projectHostSetups:list', () => store.getProjectHostSetups())
ipcMain.handle('projectHostSetups:list', () => {
enrichMissingRepoGitRemoteIdentities(store, {
onChanged: () => notifyReposChanged(mainWindow)
})
return store.getProjectHostSetups()
})
ipcMain.handle(
'projectHostSetups:create',
+32
View File
@@ -72,6 +72,7 @@ import {
normalizeProjectRuntimePreference
} from '../shared/project-execution-runtime'
import { projectHostSetupProjectionFromRepos } from '../shared/project-host-setup-projection'
import type { GitRemoteIdentity } from '../shared/git-remote-identity'
import {
buildTaskSourceContextFromRepo,
buildWorkspaceRunContext
@@ -1081,6 +1082,24 @@ function sanitizeRepoUpstream(value: unknown): Repo['upstream'] | undefined {
return owner && repo ? { owner, repo } : undefined
}
function sanitizeGitRemoteIdentity(value: unknown): GitRemoteIdentity | undefined {
if (!value || typeof value !== 'object') {
return undefined
}
const candidate = value as {
canonicalKey?: unknown
remoteName?: unknown
remoteUrl?: unknown
}
const canonicalKey =
typeof candidate.canonicalKey === 'string' ? candidate.canonicalKey.trim() : ''
const remoteName = typeof candidate.remoteName === 'string' ? candidate.remoteName.trim() : ''
const remoteUrl = typeof candidate.remoteUrl === 'string' ? candidate.remoteUrl.trim() : ''
return canonicalKey && remoteName && remoteUrl
? { canonicalKey, remoteName, remoteUrl }
: undefined
}
function sanitizeRepoProjectHostSetupMethod(
value: unknown
): RepoProjectHostSetupMethod | undefined {
@@ -1098,6 +1117,7 @@ function sanitizeRepoUpdatesForPersistence<
| 'badgeColor'
| 'repoIcon'
| 'upstream'
| 'gitRemoteIdentity'
| 'worktreeBasePath'
| 'projectHostSetupMethod'
| 'forkSyncMode'
@@ -1130,6 +1150,14 @@ function sanitizeRepoUpdatesForPersistence<
sanitized.upstream = upstream
}
}
if ('gitRemoteIdentity' in sanitized) {
const gitRemoteIdentity = sanitizeGitRemoteIdentity(sanitized.gitRemoteIdentity)
if (gitRemoteIdentity === undefined) {
delete sanitized.gitRemoteIdentity
} else {
sanitized.gitRemoteIdentity = gitRemoteIdentity
}
}
if ('worktreeBasePath' in sanitized && sanitized.worktreeBasePath !== undefined) {
if (typeof sanitized.worktreeBasePath === 'string') {
sanitized.worktreeBasePath = sanitized.worktreeBasePath.trim() || undefined
@@ -3720,6 +3748,7 @@ export class Store {
| 'badgeColor'
| 'repoIcon'
| 'upstream'
| 'gitRemoteIdentity'
| 'hookSettings'
| 'worktreeBaseRef'
| 'worktreeBasePath'
@@ -3895,6 +3924,7 @@ export class Store {
const {
repoIcon: rawRepoIcon,
upstream: rawUpstream,
gitRemoteIdentity: rawGitRemoteIdentity,
sourceControlAi: rawSourceControlAi,
projectHostSetupMethod: rawProjectHostSetupMethod,
forkSyncMode: rawForkSyncMode,
@@ -3902,6 +3932,7 @@ export class Store {
} = repo
const repoIcon = sanitizeRepoIcon(rawRepoIcon)
const upstream = sanitizeRepoUpstream(rawUpstream)
const gitRemoteIdentity = sanitizeGitRemoteIdentity(rawGitRemoteIdentity)
const sourceControlAi = normalizeRepoSourceControlAiOverrides(rawSourceControlAi)
const projectHostSetupMethod = sanitizeRepoProjectHostSetupMethod(rawProjectHostSetupMethod)
const forkSyncMode = sanitizeForkSyncMode(rawForkSyncMode)
@@ -3918,6 +3949,7 @@ export class Store {
...repoWithoutIcon,
...(repoIcon !== undefined ? { repoIcon } : {}),
...(upstream !== undefined ? { upstream } : {}),
...(gitRemoteIdentity !== undefined ? { gitRemoteIdentity } : {}),
...(sourceControlAi !== undefined ? { sourceControlAi } : {}),
...(projectHostSetupMethod !== undefined ? { projectHostSetupMethod } : {}),
...(forkSyncMode !== undefined ? { forkSyncMode } : {}),
+71 -1
View File
@@ -1,10 +1,12 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { execFileSyncMock } = vi.hoisted(() => ({
const { execFileMock, execFileSyncMock } = vi.hoisted(() => ({
execFileMock: vi.fn(),
execFileSyncMock: vi.fn()
}))
vi.mock('child_process', () => ({
execFile: execFileMock,
execFileSync: execFileSyncMock
}))
@@ -26,6 +28,8 @@ function setPlatform(platform: NodeJS.Platform): () => void {
describe('isPwshAvailable', () => {
beforeEach(() => {
vi.resetModules()
vi.useRealTimers()
execFileMock.mockReset()
execFileSyncMock.mockReset()
})
@@ -84,4 +88,70 @@ describe('isPwshAvailable', () => {
restorePlatform()
}
})
it('repro: does not keep a cold-start timeout cached for the daemon lifetime', async () => {
const restorePlatform = setPlatform('win32')
execFileSyncMock
.mockImplementationOnce(() => {
const error = Object.assign(new Error('spawnSync pwsh.exe ETIMEDOUT'), {
code: 'ETIMEDOUT'
})
throw error
})
.mockReturnValue('PowerShell 7.5.0')
try {
const { isPwshAvailable } = await import('./pwsh')
expect(isPwshAvailable()).toBe(false)
expect(isPwshAvailable()).toBe(true)
expect(execFileSyncMock).toHaveBeenCalledTimes(2)
} finally {
restorePlatform()
}
})
it('warms pwsh availability asynchronously with a longer timeout', async () => {
const restorePlatform = setPlatform('win32')
execFileMock.mockImplementation((_file, _args, _options, callback) => {
callback(null, 'PowerShell 7.5.0', '')
})
try {
const { isPwshAvailable, warmPwshAvailabilityCache } = await import('./pwsh')
await expect(warmPwshAvailabilityCache()).resolves.toBe(true)
expect(execFileMock).toHaveBeenCalledWith(
'pwsh.exe',
['-Version'],
{ timeout: 30_000 },
expect.any(Function)
)
expect(isPwshAvailable()).toBe(true)
expect(execFileSyncMock).not.toHaveBeenCalled()
} finally {
restorePlatform()
}
})
it('retries non-timeout failures after the negative cache TTL', async () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
const restorePlatform = setPlatform('win32')
execFileSyncMock
.mockImplementationOnce(() => {
throw new Error('missing pwsh')
})
.mockReturnValue('PowerShell 7.5.0')
try {
const { isPwshAvailable } = await import('./pwsh')
expect(isPwshAvailable()).toBe(false)
expect(isPwshAvailable()).toBe(false)
vi.setSystemTime(31_001)
expect(isPwshAvailable()).toBe(true)
expect(execFileSyncMock).toHaveBeenCalledTimes(2)
} finally {
restorePlatform()
vi.useRealTimers()
}
})
})
+73 -12
View File
@@ -1,31 +1,92 @@
import { execFileSync } from 'child_process'
import { execFile, execFileSync } from 'child_process'
// Cached pwsh availability check — evaluated once per process lifetime
let pwshAvailableCache: boolean | null = null
const PWSH_SYNC_PROBE_TIMEOUT_MS = 5000
const PWSH_WARMUP_PROBE_TIMEOUT_MS = 30_000
const PWSH_NEGATIVE_CACHE_TTL_MS = 30_000
type PwshAvailabilityCache =
| { available: true }
| { available: false; cachedAt: number; retryable: boolean }
let pwshAvailableCache: PwshAvailabilityCache | null = null
let pwshWarmupInFlight: Promise<boolean> | null = null
function isCacheFresh(cache: PwshAvailabilityCache): boolean {
return (
cache.available || !cache.retryable || Date.now() - cache.cachedAt < PWSH_NEGATIVE_CACHE_TTL_MS
)
}
function isTimeoutError(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as NodeJS.ErrnoException).code === 'ETIMEDOUT'
)
}
function cachePwshProbeFailure(error: unknown): void {
// Why: pwsh.exe cold starts can exceed the sync timeout; do not let one slow
// .NET startup disable the user's PowerShell 7 preference for the daemon.
if (isTimeoutError(error)) {
pwshAvailableCache = null
return
}
pwshAvailableCache = { available: false, cachedAt: Date.now(), retryable: true }
}
/**
* Check whether pwsh.exe is available on this Windows machine.
* Result is cached for the process lifetime.
* Positive results are cached for the process lifetime; negative results are
* retried so transient cold-start failures cannot outlive the daemon.
*/
export function isPwshAvailable(): boolean {
if (pwshAvailableCache !== null) {
return pwshAvailableCache
if (pwshAvailableCache && isCacheFresh(pwshAvailableCache)) {
return pwshAvailableCache.available
}
if (process.platform !== 'win32') {
pwshAvailableCache = false
pwshAvailableCache = { available: false, cachedAt: Date.now(), retryable: false }
return false
}
try {
execFileSync('pwsh.exe', ['-Version'], {
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 5000
timeout: PWSH_SYNC_PROBE_TIMEOUT_MS
})
pwshAvailableCache = true
} catch {
pwshAvailableCache = false
pwshAvailableCache = { available: true }
} catch (error) {
cachePwshProbeFailure(error)
}
return pwshAvailableCache
return pwshAvailableCache?.available ?? false
}
export function warmPwshAvailabilityCache(): Promise<boolean> {
if (pwshAvailableCache?.available) {
return Promise.resolve(true)
}
if (process.platform !== 'win32') {
pwshAvailableCache = { available: false, cachedAt: Date.now(), retryable: false }
return Promise.resolve(false)
}
if (pwshWarmupInFlight) {
return pwshWarmupInFlight
}
pwshWarmupInFlight = new Promise((resolve) => {
execFile('pwsh.exe', ['-Version'], { timeout: PWSH_WARMUP_PROBE_TIMEOUT_MS }, (error) => {
pwshWarmupInFlight = null
if (!error) {
pwshAvailableCache = { available: true }
resolve(true)
return
}
cachePwshProbeFailure(error)
resolve(false)
})
})
return pwshWarmupInFlight
}
@@ -0,0 +1,137 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { GitRemoteIdentity } from '../shared/git-remote-identity'
import type { Repo } from '../shared/types'
import { detectGitRemoteIdentity } from './repo-git-remote-identity'
import {
enrichMissingRepoGitRemoteIdentities,
flushRepoGitRemoteIdentityEnrichmentForTests,
resetRepoGitRemoteIdentityEnrichmentForTests
} from './repo-git-remote-identity-enrichment'
vi.mock('./repo-git-remote-identity', () => ({
detectGitRemoteIdentity: vi.fn()
}))
type RepoIdentityStore = {
getRepos: () => Repo[]
getRepo: (id: string) => Repo | undefined
updateRepo: (id: string, updates: Pick<Partial<Repo>, 'gitRemoteIdentity'>) => Repo | null
}
const remoteIdentity: GitRemoteIdentity = {
canonicalKey: 'git.company.test/team/sample-app',
remoteName: 'origin',
remoteUrl: 'git@git.company.test:team/sample-app.git'
}
function makeRepo(overrides: Partial<Repo> = {}): Repo {
return {
id: 'repo-1',
path: '/workspace/sample-app',
displayName: 'sample-app',
badgeColor: '#737373',
addedAt: 1,
kind: 'git',
...overrides
}
}
function makeStore(repo: Repo): RepoIdentityStore & { updateRepo: ReturnType<typeof vi.fn> } {
const repos = [repo]
return {
getRepos: () => repos,
getRepo: (id) => repos.find((candidate) => candidate.id === id),
updateRepo: vi.fn((id, updates) => {
const target = repos.find((candidate) => candidate.id === id)
if (!target) {
return null
}
Object.assign(target, updates)
return target
})
}
}
function deferred<T>(): {
promise: Promise<T>
resolve: (value: T) => void
} {
let resolve!: (value: T) => void
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise
})
return { promise, resolve }
}
afterEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
resetRepoGitRemoteIdentityEnrichmentForTests()
})
describe('enrichMissingRepoGitRemoteIdentities', () => {
it('schedules remote identity enrichment without blocking the caller', async () => {
vi.mocked(detectGitRemoteIdentity).mockResolvedValue(remoteIdentity)
const repo = makeRepo()
const store = makeStore(repo)
const onChanged = vi.fn()
enrichMissingRepoGitRemoteIdentities(store, { onChanged })
expect(repo.gitRemoteIdentity).toBeUndefined()
expect(detectGitRemoteIdentity).toHaveBeenCalledWith('/workspace/sample-app', undefined)
await flushRepoGitRemoteIdentityEnrichmentForTests()
expect(repo.gitRemoteIdentity).toEqual(remoteIdentity)
expect(onChanged).toHaveBeenCalledTimes(1)
})
it('coalesces concurrent probes for the same repo location', async () => {
const probe = deferred<GitRemoteIdentity | null>()
vi.mocked(detectGitRemoteIdentity).mockReturnValue(probe.promise)
const repo = makeRepo()
const store = makeStore(repo)
enrichMissingRepoGitRemoteIdentities(store)
enrichMissingRepoGitRemoteIdentities(store)
expect(detectGitRemoteIdentity).toHaveBeenCalledTimes(1)
probe.resolve(remoteIdentity)
await flushRepoGitRemoteIdentityEnrichmentForTests()
expect(store.updateRepo).toHaveBeenCalledTimes(1)
expect(repo.gitRemoteIdentity).toEqual(remoteIdentity)
})
it('caches no-identity probes briefly so list calls do not retry every time', async () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
vi.mocked(detectGitRemoteIdentity).mockResolvedValue(null)
const repo = makeRepo()
const store = makeStore(repo)
enrichMissingRepoGitRemoteIdentities(store)
await flushRepoGitRemoteIdentityEnrichmentForTests()
enrichMissingRepoGitRemoteIdentities(store)
await flushRepoGitRemoteIdentityEnrichmentForTests()
expect(detectGitRemoteIdentity).toHaveBeenCalledTimes(1)
})
it('does not write stale identity data after the repo path changes', async () => {
const probe = deferred<GitRemoteIdentity | null>()
vi.mocked(detectGitRemoteIdentity).mockReturnValue(probe.promise)
const repo = makeRepo()
const store = makeStore(repo)
enrichMissingRepoGitRemoteIdentities(store)
repo.path = '/workspace/renamed-sample-app'
probe.resolve(remoteIdentity)
await flushRepoGitRemoteIdentityEnrichmentForTests()
expect(store.updateRepo).not.toHaveBeenCalled()
expect(repo.gitRemoteIdentity).toBeUndefined()
})
})
@@ -0,0 +1,107 @@
import type { Repo } from '../shared/types'
import { detectGitRemoteIdentity } from './repo-git-remote-identity'
const NO_IDENTITY_RETRY_TTL_MS = 5 * 60 * 1000
type RepoIdentityStore = {
getRepos(): Repo[]
getRepo?(id: string): Repo | undefined
updateRepo(id: string, updates: Pick<Partial<Repo>, 'gitRemoteIdentity'>): Repo | null
}
type EnrichmentOptions = {
onChanged?: () => void
}
const inFlightProbesByLocation = new Map<string, Promise<boolean>>()
const noIdentityRetryAfterByLocation = new Map<string, number>()
function getRepoLocationKey(repo: Pick<Repo, 'path' | 'connectionId'>): string {
return `${repo.connectionId ?? 'local'}\0${repo.path}`
}
function getCurrentRepo(store: RepoIdentityStore, id: string): Repo | undefined {
return store.getRepo?.(id) ?? store.getRepos().find((repo) => repo.id === id)
}
function isSameUnenrichedRepo(snapshot: Repo, current: Repo | undefined): boolean {
return (
!!current &&
current.kind !== 'folder' &&
!current.gitRemoteIdentity &&
current.path === snapshot.path &&
(current.connectionId ?? null) === (snapshot.connectionId ?? null)
)
}
async function enrichRepoGitRemoteIdentity(store: RepoIdentityStore, repo: Repo): Promise<boolean> {
const locationKey = getRepoLocationKey(repo)
const retryAfter = noIdentityRetryAfterByLocation.get(locationKey) ?? 0
if (retryAfter > Date.now()) {
return false
}
const inFlight = inFlightProbesByLocation.get(locationKey)
if (inFlight) {
return inFlight
}
const probe = (async () => {
const identity = await detectGitRemoteIdentity(repo.path, repo.connectionId)
if (!identity) {
// Why: repos without a parseable remote are common; cache misses briefly so
// list calls stay cheap while still allowing recent remote changes to land.
noIdentityRetryAfterByLocation.set(locationKey, Date.now() + NO_IDENTITY_RETRY_TTL_MS)
return false
}
noIdentityRetryAfterByLocation.delete(locationKey)
const current = getCurrentRepo(store, repo.id)
if (!isSameUnenrichedRepo(repo, current)) {
return false
}
return !!store.updateRepo(repo.id, { gitRemoteIdentity: identity })
})().finally(() => {
if (inFlightProbesByLocation.get(locationKey) === probe) {
inFlightProbesByLocation.delete(locationKey)
}
})
inFlightProbesByLocation.set(locationKey, probe)
return probe
}
async function enrichMissingRepoGitRemoteIdentitiesInBackground(
store: RepoIdentityStore,
options: EnrichmentOptions
): Promise<void> {
const candidates = store
.getRepos()
.filter((repo) => repo.kind !== 'folder' && !repo.gitRemoteIdentity)
let changed = false
for (const repo of candidates) {
// Why: enrichment runs later; capture the location we probed so a mutable
// store cannot make the stale-write guard compare against changed fields.
if (await enrichRepoGitRemoteIdentity(store, { ...repo })) {
changed = true
}
}
if (changed) {
options.onChanged?.()
}
}
export function enrichMissingRepoGitRemoteIdentities(
store: RepoIdentityStore,
options: EnrichmentOptions = {}
): void {
void enrichMissingRepoGitRemoteIdentitiesInBackground(store, options).catch((error: unknown) => {
console.error('[repo-identity] Failed to enrich git remote identities:', error)
})
}
export async function flushRepoGitRemoteIdentityEnrichmentForTests(): Promise<void> {
await Promise.all(inFlightProbesByLocation.values())
}
export function resetRepoGitRemoteIdentityEnrichmentForTests(): void {
inFlightProbesByLocation.clear()
noIdentityRetryAfterByLocation.clear()
}
+18
View File
@@ -0,0 +1,18 @@
import { deriveGitRemoteIdentity, type GitRemoteIdentity } from '../shared/git-remote-identity'
import { gitExecFileAsync } from './git/runner'
import { getSshGitProvider } from './providers/ssh-git-dispatch'
export async function detectGitRemoteIdentity(
repoPath: string,
connectionId?: string | null
): Promise<GitRemoteIdentity | null> {
try {
const result = connectionId
? await getSshGitProvider(connectionId)?.exec(['remote', '-v'], repoPath)
: await gitExecFileAsync(['remote', '-v'], { cwd: repoPath })
return result ? deriveGitRemoteIdentity(result.stdout) : null
} catch {
// Repo creation must not fail because a best-effort remote probe failed.
return null
}
}
+23
View File
@@ -168,6 +168,11 @@ describe('detectRepoIcon', () => {
})
await expect(detectRepoIconAndUpstream({ repoPath, kind: 'git' })).resolves.toEqual({
gitRemoteIdentity: {
canonicalKey: 'github.com/stablyai/orca',
remoteName: 'upstream',
remoteUrl: 'git@github.com:stablyai/orca.git'
},
repoIcon: {
type: 'image',
src: 'https://github.com/stablyai.png?size=64',
@@ -177,4 +182,22 @@ describe('detectRepoIcon', () => {
upstream: { owner: 'stablyai', repo: 'orca' }
})
})
it('detects a provider-neutral git remote identity for non-GitHub remotes', async () => {
const repoPath = await makeTempRepoDir()
await gitExecFileAsync(['init'], { cwd: repoPath })
await gitExecFileAsync(
['remote', 'add', 'origin', 'git@git.company.test:platform/tools/sample-app.git'],
{ cwd: repoPath }
)
await expect(detectRepoIconAndUpstream({ repoPath, kind: 'git' })).resolves.toMatchObject({
gitRemoteIdentity: {
canonicalKey: 'git.company.test/platform/tools/sample-app',
remoteName: 'origin',
remoteUrl: 'git@git.company.test:platform/tools/sample-app.git'
},
upstream: null
})
})
})
+7 -7
View File
@@ -9,6 +9,7 @@ import {
import { getRepoSlug, getRepoUpstream } from './github/client'
import { getSshFilesystemProvider } from './providers/ssh-filesystem-dispatch'
import type { IFilesystemProvider } from './providers/types'
import { detectGitRemoteIdentity } from './repo-git-remote-identity'
import { iconHrefCandidates } from './repo-icon-href-candidates'
import { joinWorktreeRelativePath } from './runtime/runtime-relative-paths'
@@ -306,12 +307,8 @@ export async function detectRepoIcon({
return undefined
}
/**
* Detect a repo's icon and its fork upstream together. The upstream is resolved
* once and reused for the avatar so a fork shows the upstream owner's avatar.
* Returns a spread-ready slice of `Repo`. For git repos, `upstream: null`
* is a resolved "not a fork" marker and prevents repeated best-effort probes.
*/
// Why: `upstream: null` is a resolved "not a fork" marker and prevents
// repeated best-effort probes.
export async function detectRepoIconAndUpstream({
repoPath,
kind,
@@ -320,11 +317,14 @@ export async function detectRepoIconAndUpstream({
repoPath: string
kind: RepoKind
connectionId?: string | null
}): Promise<{ repoIcon?: RepoIcon; upstream?: GitHubRepositoryIdentity | null }> {
}) {
const upstream = kind === 'git' ? await getRepoUpstream(repoPath, connectionId) : null
const gitRemoteIdentity =
kind === 'git' ? await detectGitRemoteIdentity(repoPath, connectionId) : null
const repoIcon = await detectRepoIcon({ repoPath, kind, connectionId, upstream })
return {
...(repoIcon ? { repoIcon } : {}),
...(gitRemoteIdentity ? { gitRemoteIdentity } : {}),
...(kind === 'git' ? { upstream: upstream ?? null } : {})
}
}
+13
View File
@@ -643,6 +643,7 @@ import {
} from '../project-groups/folder-workspace-path-status'
import { getSshGitProvider, requireSshGitProvider } from '../providers/ssh-git-dispatch'
import { detectRepoIconAndUpstream } from '../repo-icon-autodetect'
import { enrichMissingRepoGitRemoteIdentities } from '../repo-git-remote-identity-enrichment'
import { githubAvatarIcon } from '../../shared/repo-icon'
import type { ClaudeAccountService } from '../claude-accounts/service'
import type { CodexAccountService } from '../codex-accounts/service'
@@ -8807,6 +8808,18 @@ export class OrcaRuntimeService {
return this.store?.getRepos() ?? []
}
enrichMissingRepoGitRemoteIdentities(): void {
if (!this.store) {
return
}
enrichMissingRepoGitRemoteIdentities(this.store, {
onChanged: () => {
this.invalidateResolvedWorktreeCache()
this.notifyReposChanged()
}
})
}
listProjects(): Project[] {
return this.store?.getProjects?.() ?? []
}
@@ -1,6 +1,7 @@
/* eslint-disable max-lines -- Why: coordinator tests cover dispatch, DAG ordering, escalation, decision gates, concurrency, and stop — splitting by category would scatter shared setup without improving clarity. */
import { afterEach, describe, expect, it } from 'vitest'
import { OrchestrationDb } from './db'
import { reconcileLifecycleMessage } from './lifecycle-reconciliation'
import {
Coordinator,
DISPATCH_STALE_THRESHOLD,
@@ -141,6 +142,68 @@ describe('Coordinator', () => {
expect(runtime.sentMessages.length).toBeGreaterThan(0)
})
it('records completedTasks when send reconciled worker_done before coordinator read', async () => {
db = new OrchestrationDb(':memory:')
const runtime = createMockRuntime()
const task = db.createTask({ spec: 'send-driven completion' })
const dispatch = db.createDispatchContext(task.id, 'term_a')
const msg = db.insertMessage({
from: 'term_a',
to: 'coord',
subject: 'Done',
type: 'worker_done',
payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id })
})
reconcileLifecycleMessage(db, msg)
const coordinator = new Coordinator(db, runtime, {
spec: 'go',
coordinatorHandle: 'coord',
pollIntervalMs: 20
})
const result = await coordinator.run()
expect(result.status).toBe('completed')
expect(result.completedTasks).toContain(task.id)
})
it('does not duplicate completedTasks for repeated completed worker_done messages', async () => {
db = new OrchestrationDb(':memory:')
const runtime = createMockRuntime()
const task = db.createTask({ spec: 'duplicate completion' })
const dispatch = db.createDispatchContext(task.id, 'term_a')
const payload = JSON.stringify({ taskId: task.id, dispatchId: dispatch.id })
const first = db.insertMessage({
from: 'term_a',
to: 'coord',
subject: 'Done',
type: 'worker_done',
payload
})
db.insertMessage({
from: 'term_a',
to: 'coord',
subject: 'Done again',
type: 'worker_done',
payload
})
reconcileLifecycleMessage(db, first)
const coordinator = new Coordinator(db, runtime, {
spec: 'go',
coordinatorHandle: 'coord',
pollIntervalMs: 20
})
const result = await coordinator.run()
expect(result.status).toBe('completed')
expect(result.completedTasks.filter((id) => id === task.id)).toHaveLength(1)
})
it('creates a terminal when none are available', async () => {
db = new OrchestrationDb(':memory:')
const runtime = createMockRuntime()
@@ -277,7 +277,9 @@ export class Coordinator {
private handleLifecycleMessage(msg: MessageRow): void {
const result = reconcileLifecycleMessage(this.db, msg, this.opts.onLog)
if (result.action === 'completed') {
this.state.completedTasks.push(result.taskId)
if (!this.state.completedTasks.includes(result.taskId)) {
this.state.completedTasks.push(result.taskId)
}
}
}
@@ -117,6 +117,11 @@ function reconcileWorkerDoneMessage(
)
return { action: 'ignored' }
}
// Why: `orchestration.send` can release the DB lock before waking the
// coordinator; the later coordinator read still needs to observe completion.
if (dispatch.status === 'completed' && task.status === 'completed') {
return { action: 'completed', taskId, dispatchId }
}
if (dispatch.status !== 'dispatched') {
onLog(`Warning: worker_done for inactive dispatch ${dispatchId} ignored`)
return { action: 'ignored' }
@@ -374,6 +374,71 @@ describe('orchestration RPC methods', () => {
})
).rejects.toThrow('No recipients resolved for group address')
})
it('releases dispatch lock before waking recipients when worker_done is sent via send', async () => {
setup()
const task = db.createTask({ spec: 'lock-release work' })
const dispatch = db.createDispatchContext(task.id, 'term_worker')
// Why: assert lock is already gone at delivery time, not just after the call.
vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {
expect(db.getActiveDispatchForTerminal('term_worker')).toBeUndefined()
})
const result = (await call('orchestration.send', {
from: 'term_worker',
to: 'term_coord',
subject: 'done',
type: 'worker_done',
payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id })
})) as { message: { type: string } }
expect(result.message.type).toBe('worker_done')
expect(db.getTask(task.id)?.status).toBe('completed')
expect(db.getDispatchContextById(dispatch.id)?.status).toBe('completed')
expect(db.getActiveDispatchForTerminal('term_worker')).toBeUndefined()
// Lock released — a new dispatch to the same terminal must succeed.
const t2 = db.createTask({ spec: 'follow-up work' })
expect(() => db.createDispatchContext(t2.id, 'term_worker')).not.toThrow()
})
it('records heartbeat when heartbeat is sent via send', async () => {
setup()
const task = db.createTask({ spec: 'heartbeat work' })
const dispatch = db.createDispatchContext(task.id, 'term_worker')
vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {})
await call('orchestration.send', {
from: 'term_worker',
to: 'term_coord',
subject: 'alive',
type: 'heartbeat',
payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id })
})
expect(db.getTask(task.id)?.status).toBe('dispatched')
expect(db.getDispatchContextById(dispatch.id)?.status).toBe('dispatched')
expect(db.getDispatchContextById(dispatch.id)?.last_heartbeat_at).toBeTruthy()
expect(db.getActiveDispatchForTerminal('term_worker')).toBeDefined()
})
it('does not release dispatch lock for non-lifecycle sends', async () => {
setup()
const task = db.createTask({ spec: 'in-flight work' })
const dispatch = db.createDispatchContext(task.id, 'term_worker')
vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {})
await call('orchestration.send', {
from: 'term_coord',
to: 'term_worker',
subject: 'how is it going?',
type: 'status'
})
expect(db.getTask(task.id)?.status).toBe('dispatched')
expect(db.getDispatchContextById(dispatch.id)?.status).toBe('dispatched')
expect(db.getActiveDispatchForTerminal('term_worker')).toBeDefined()
})
})
describe('orchestration.check', () => {
@@ -197,6 +197,13 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
threadId: params.threadId,
payload: params.payload
})
// Why: worker_done/heartbeat sent via `send` must release the dispatch
// lock before waking recipients — a coordinator woken by delivery may
// immediately dispatch to the same terminal, which fails if the lock
// is still held.
if (msg.type === 'worker_done' || msg.type === 'heartbeat') {
reconcileLifecycleMessage(db, msg)
}
runtime.deliverPendingMessagesForHandle(params.to)
runtime.notifyMessageArrived(params.to, msg.type)
return { message: msg }
@@ -90,7 +90,10 @@ export const PROJECT_RUNTIME_METHODS: RpcMethod[] = [
defineMethod({
name: 'project.list',
params: null,
handler: (_params, { runtime }) => ({ projects: runtime.listProjects() })
handler: (_params, { runtime }) => {
runtime.enrichMissingRepoGitRemoteIdentities?.()
return { projects: runtime.listProjects() }
}
}),
defineMethod({
name: 'project.update',
@@ -102,7 +105,10 @@ export const PROJECT_RUNTIME_METHODS: RpcMethod[] = [
defineMethod({
name: 'projectHostSetup.list',
params: null,
handler: (_params, { runtime }) => ({ setups: runtime.listProjectHostSetups() })
handler: (_params, { runtime }) => {
runtime.enrichMissingRepoGitRemoteIdentities?.()
return { setups: runtime.listProjectHostSetups() }
}
}),
defineMethod({
name: 'projectHostSetup.create',
+4 -1
View File
@@ -158,7 +158,10 @@ export const REPO_METHODS: RpcMethod[] = [
defineMethod({
name: 'repo.list',
params: null,
handler: (_params, { runtime }) => ({ repos: runtime.listRepos() })
handler: (_params, { runtime }) => {
runtime.enrichMissingRepoGitRemoteIdentities?.()
return { repos: runtime.listRepos() }
}
}),
...PROJECT_RUNTIME_METHODS,
defineMethod({
+3 -1
View File
@@ -234,7 +234,9 @@
--worktree-sidebar-foreground: #fafafa;
--worktree-sidebar-accent: #353535;
--worktree-sidebar-accent-foreground: #fafafa;
--worktree-sidebar-border: rgb(255 255 255 / 0.1);
/* Match --border / --sidebar-border (7%) so the left sidebar's divider lines
aren't brighter than the rest of the UI in dark mode (#5906). */
--worktree-sidebar-border: rgb(255 255 255 / 0.07);
--worktree-sidebar-ring: #737373;
--sidebar: #171717;
--sidebar-foreground: #fafafa;
+263 -16
View File
@@ -20,6 +20,7 @@ import {
Ban,
Braces,
Check,
CheckCircle2,
ChevronDown,
ChevronLeft,
ChevronRight,
@@ -33,9 +34,11 @@ import {
GitPullRequest,
GitPullRequestClosed,
ListChecks,
Link2,
LoaderCircle,
MessageSquare,
MessageSquarePlus,
MoveRight,
PanelLeftOpen,
Pencil,
Plus,
@@ -44,6 +47,8 @@ import {
Send,
Settings,
UndoDot,
UserMinus,
UserPlus,
Wrench,
X
} from 'lucide-react'
@@ -140,7 +145,10 @@ import {
type CommentCodeContextLineUpdate
} from '@/components/comment-code-context-state'
import { getPrCommentCodeContext } from '@/components/github/pr-comment-code-context'
import { resolveCommentReplyTarget } from '@/components/comment-reply-target-state'
import {
getCommentReplyTargetCandidates,
resolveCommentReplyTarget
} from '@/components/comment-reply-target-state'
import { useAppStore } from '@/store'
import { useAllWorktrees } from '@/store/selectors'
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
@@ -181,6 +189,8 @@ import type {
GitHubPRFileViewedState,
GitHubWorkItem,
GitHubWorkItemDetails,
GitHubIssueTimelineItem,
GitHubIssueTimelineTarget,
GitHubAssignableUser,
GitHubReaction,
GitHubPRMergeMethod,
@@ -2806,12 +2816,75 @@ function CommentCodeContext({
)
}
type IssueConversationEntry =
| { kind: 'comment'; id: string; createdAt: string; comment: PRComment; index: number }
| {
kind: 'activity'
id: string
createdAt: string
activity: GitHubIssueTimelineItem
index: number
}
const EMPTY_GITHUB_ISSUE_TIMELINE_ITEMS: GitHubIssueTimelineItem[] = []
function getTimelineSortValue(createdAt: string): number {
const value = new Date(createdAt).getTime()
return Number.isFinite(value) ? value : 0
}
function getIssueConversationEntries(
comments: PRComment[],
timelineItems: GitHubIssueTimelineItem[]
): IssueConversationEntry[] {
return [
...comments.map(
(comment, index): IssueConversationEntry => ({
kind: 'comment',
id: `comment:${comment.id}`,
createdAt: comment.createdAt,
comment,
index
})
),
...timelineItems.map(
(activity, index): IssueConversationEntry => ({
kind: 'activity',
id: `activity:${activity.id}`,
createdAt: activity.createdAt,
activity,
index: comments.length + index
})
)
].sort((a, b) => {
const diff = getTimelineSortValue(a.createdAt) - getTimelineSortValue(b.createdAt)
return diff === 0 ? a.index - b.index : diff
})
}
function getTimelineTargetLabel(target: GitHubIssueTimelineTarget): string {
const prefix = target.type === 'pr' ? 'PR' : 'issue'
const title = target.title ? ` ${target.title}` : ''
return `${prefix} #${target.number}${title}`
}
function getTimelineStateReasonLabel(reason: string | null | undefined): string | null {
if (reason === 'completed') {
return translate('auto.components.GitHubItemDialog.timeline.completed', 'as completed')
}
if (reason === 'not_planned') {
return translate('auto.components.GitHubItemDialog.timeline.notPlanned', 'as not planned')
}
return null
}
function ConversationTab({
item,
repoPath,
sourceContext,
body,
comments,
timelineItems,
files,
headSha,
baseSha,
@@ -2833,6 +2906,7 @@ function ConversationTab({
sourceContext?: TaskSourceContext | null
body: string
comments: PRComment[]
timelineItems?: GitHubIssueTimelineItem[]
files: GitHubPRFile[]
headSha: string | undefined
baseSha: string | undefined
@@ -2860,7 +2934,13 @@ function ConversationTab({
[commentFilter, comments]
)
const visibleCommentGroups = useMemo(() => groupPRComments(visibleComments), [visibleComments])
const resolvedReplyingTo = resolveCommentReplyTarget(replyingTo, visibleComments)
const resolvedTimelineItems = timelineItems ?? EMPTY_GITHUB_ISSUE_TIMELINE_ITEMS
const issueConversationEntries = useMemo(
() => getIssueConversationEntries(comments, resolvedTimelineItems),
[comments, resolvedTimelineItems]
)
const replyTargetComments = getCommentReplyTargetCandidates(item.type, comments, visibleComments)
const resolvedReplyingTo = resolveCommentReplyTarget(replyingTo, replyTargetComments)
if (resolvedReplyingTo !== replyingTo) {
// Why: comment filters/refetches can hide the active reply target; clear it
@@ -3192,6 +3272,147 @@ function ConversationTab({
)
}
const renderTimelineTarget = (target: GitHubIssueTimelineTarget | undefined): React.ReactNode => {
if (!target) {
return null
}
return (
<button
key={target.url}
type="button"
className="min-w-0 truncate font-medium text-foreground underline underline-offset-2 hover:text-muted-foreground"
title={getTimelineTargetLabel(target)}
onClick={() => window.api.shell.openUrl(target.url)}
>
{getTimelineTargetLabel(target)}
</button>
)
}
const renderTimelineActivityMessage = (activity: GitHubIssueTimelineItem): React.ReactNode => {
const assignee =
activity.assignee ?? translate('auto.components.GitHubItemDialog.timeline.someone', 'someone')
if (activity.event === 'assigned') {
return (
<>
{translate('auto.components.GitHubItemDialog.timeline.assigned', 'assigned')}{' '}
<span className="font-medium text-foreground">{assignee}</span>
</>
)
}
if (activity.event === 'unassigned') {
return (
<>
{translate('auto.components.GitHubItemDialog.timeline.unassigned', 'unassigned')}{' '}
<span className="font-medium text-foreground">{assignee}</span>
</>
)
}
if (activity.event === 'mentioned' || activity.event === 'cross-referenced') {
return (
<>
{translate('auto.components.GitHubItemDialog.timeline.mentioned', 'mentioned this')}
{activity.source ? (
<>
{' '}
{translate('auto.components.GitHubItemDialog.timeline.in', 'in')}{' '}
{renderTimelineTarget(activity.source)}
</>
) : null}
</>
)
}
if (activity.event === 'closed') {
const stateReason = getTimelineStateReasonLabel(activity.stateReason)
return (
<>
{translate('auto.components.GitHubItemDialog.timeline.closed', 'closed this')}
{stateReason ? ` ${stateReason}` : ''}
{activity.closer ? (
<>
{' '}
{translate('auto.components.GitHubItemDialog.timeline.in', 'in')}{' '}
{renderTimelineTarget(activity.closer)}
</>
) : null}
</>
)
}
if (activity.event === 'reopened') {
return translate('auto.components.GitHubItemDialog.timeline.reopened', 'reopened this')
}
const hasFrom = Boolean(activity.previousColumnName)
const hasTo = Boolean(activity.columnName)
return (
<>
{translate('auto.components.GitHubItemDialog.timeline.moved', 'moved this')}
{hasFrom ? (
<>
{' '}
{translate('auto.components.GitHubItemDialog.timeline.from', 'from')}{' '}
<span className="font-medium text-foreground">{activity.previousColumnName}</span>
</>
) : null}
{hasTo ? (
<>
{' '}
{translate('auto.components.GitHubItemDialog.timeline.to', 'to')}{' '}
<span className="font-medium text-foreground">{activity.columnName}</span>
</>
) : null}
{activity.projectName ? (
<>
{' '}
{translate('auto.components.GitHubItemDialog.timeline.in', 'in')}{' '}
<span className="font-medium text-foreground">{activity.projectName}</span>
</>
) : null}
</>
)
}
const renderTimelineActivity = (activity: GitHubIssueTimelineItem): React.JSX.Element => {
const Icon =
activity.event === 'assigned'
? UserPlus
: activity.event === 'unassigned'
? UserMinus
: activity.event === 'closed'
? CheckCircle2
: activity.event === 'reopened'
? CircleDot
: activity.event === 'moved_columns_in_project'
? MoveRight
: Link2
return (
<div
key={`activity-${activity.id}`}
className="flex min-w-0 items-start gap-3 rounded-md px-1 py-1.5 text-[13px] text-muted-foreground"
>
<span className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full border border-border/50 bg-muted/30 text-muted-foreground">
<Icon className="size-3.5" />
</span>
{activity.actorAvatarUrl ? (
<img src={activity.actorAvatarUrl} alt="" className="mt-1 size-5 shrink-0 rounded-full" />
) : null}
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1">
<span className="font-medium text-foreground">{activity.actor}</span>
<span className="contents">{renderTimelineActivityMessage(activity)}</span>
<span className="text-[12px] text-muted-foreground">
{formatRelativeTime(activity.createdAt)}
</span>
</div>
</div>
</div>
)
}
const renderIssueConversationEntry = (entry: IssueConversationEntry): React.JSX.Element =>
entry.kind === 'comment'
? renderCommentCard(entry.comment)
: renderTimelineActivity(entry.activity)
return (
<div
className={cn(
@@ -3308,13 +3529,19 @@ function ConversationTab({
{detailsLoaded ? (
<>
<div className="flex items-center gap-2 pt-1">
<MessageSquare className="size-4 text-muted-foreground" />
{item.type === 'issue' ? (
<FolderKanban className="size-4 text-muted-foreground" />
) : (
<MessageSquare className="size-4 text-muted-foreground" />
)}
<span className="text-[13px] font-medium text-foreground">
{translate('auto.components.GitHubItemDialog.1506916c09', 'Comments')}
{item.type === 'issue'
? translate('auto.components.GitHubItemDialog.timeline.activity', 'Activity')
: translate('auto.components.GitHubItemDialog.1506916c09', 'Comments')}
</span>
{comments.length > 0 && (
{comments.length + (item.type === 'issue' ? resolvedTimelineItems.length : 0) > 0 && (
<span className="rounded-full border border-border/50 bg-muted/30 px-1.5 py-0.5 text-[11px] tabular-nums text-muted-foreground">
{comments.length}
{comments.length + (item.type === 'issue' ? resolvedTimelineItems.length : 0)}
</span>
)}
</div>
@@ -3342,7 +3569,20 @@ function ConversationTab({
</div>
)}
{comments.length === 0 ? (
{item.type === 'issue' ? (
issueConversationEntries.length === 0 ? (
<div className="rounded-lg border border-dashed border-border/50 px-3 py-6 text-left text-[13px] text-muted-foreground">
{translate(
'auto.components.GitHubItemDialog.timeline.noActivity',
'No activity yet.'
)}
</div>
) : (
<div className="flex min-w-0 flex-col gap-3">
{issueConversationEntries.map(renderIssueConversationEntry)}
</div>
)
) : comments.length === 0 ? (
<div className="rounded-lg border border-dashed border-border/50 px-3 py-6 text-left text-[13px] text-muted-foreground">
{translate('auto.components.GitHubItemDialog.5a94f3d0e9', 'No comments yet.')}
</div>
@@ -6151,15 +6391,6 @@ export default function GitHubItemDialog({
})
}, [repoPath, effectiveRepoId, workItem, issueSourcePreference])
// Why: reset lifted edit state when the dialog switches items or when the
// same item receives an optimistic cache patch from the surrounding table.
useEffect(() => {
if (workItemState && workItemLabels) {
setLocalState(workItemState)
setLocalLabels(workItemLabels)
}
}, [workItemId, workItemState, workItemLabels])
// Why: track comments added optimistically before the detail fetch resolves
// so they can be merged into the fetch result instead of being overwritten.
const optimisticCommentsRef = useRef<PRComment[]>([])
@@ -6257,6 +6488,19 @@ export default function GitHubItemDialog({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cachedEntry, workItem, optimisticTick])
const resolvedWorkItemState = details?.item.state ?? workItemState
// Why: the list row that opens the dialog can be stale; the detail payload
// carries the authoritative issue/PR state and should refresh local edit UI.
useEffect(() => {
if (resolvedWorkItemState) {
setLocalState(resolvedWorkItemState)
}
if (workItemLabels) {
setLocalLabels(workItemLabels)
}
}, [workItemId, resolvedWorkItemState, workItemLabels])
const loading = !!cachedEntry?.pending && !cachedEntry?.details
const error = cachedEntry?.error && !cachedEntry?.details ? cachedEntry.error : null
const detailsLoaded =
@@ -6393,6 +6637,7 @@ export default function GitHubItemDialog({
const body = details?.body ?? ''
const comments = details?.comments ?? []
const timelineItems = details?.timelineItems ?? []
const files = details?.files ?? []
const checks = details?.checks ?? []
const [pendingViewedPaths, setPendingViewedPaths] = useState<Set<string>>(() => new Set())
@@ -6910,6 +7155,7 @@ export default function GitHubItemDialog({
sourceContext={sourceContext}
body={body}
comments={comments}
timelineItems={timelineItems}
files={files}
headSha={details?.headSha}
baseSha={details?.baseSha}
@@ -7030,6 +7276,7 @@ export default function GitHubItemDialog({
sourceContext={sourceContext}
body={body}
comments={comments}
timelineItems={timelineItems}
files={files}
headSha={details?.headSha}
baseSha={details?.baseSha}
@@ -185,8 +185,8 @@ function QuickTabBody({
const primaryActionLabel = isFolderWorkspaceTarget
? getFolderWorkspacePrimaryActionLabel()
: cardProps.selectedRepoIsGit
? 'Create worktree'
: 'Create workspace'
? translate('auto.components.NewWorkspaceComposerModal.createWorktree', 'Create worktree')
: translate('auto.components.NewWorkspaceComposerModal.createWorkspace', 'Create workspace')
// Cmd/Ctrl+Enter submits, Esc first blurs the focused input (like the full page).
useEffect(() => {
@@ -1,5 +1,8 @@
import { describe, expect, it } from 'vitest'
import { resolveCommentReplyTarget } from './comment-reply-target-state'
import {
getCommentReplyTargetCandidates,
resolveCommentReplyTarget
} from './comment-reply-target-state'
describe('comment reply target state', () => {
it('preserves a visible reply target', () => {
@@ -13,4 +16,17 @@ describe('comment reply target state', () => {
it('keeps an empty reply target empty', () => {
expect(resolveCommentReplyTarget(null, [{ id: 1 }])).toBeNull()
})
it('uses filtered comments as reply targets for PRs', () => {
expect(getCommentReplyTargetCandidates('pr', [{ id: 1 }, { id: 2 }], [{ id: 2 }])).toEqual([
{ id: 2 }
])
})
it('uses all comments as reply targets for issues', () => {
expect(getCommentReplyTargetCandidates('issue', [{ id: 1 }, { id: 2 }], [{ id: 2 }])).toEqual([
{ id: 1 },
{ id: 2 }
])
})
})
@@ -2,6 +2,16 @@ export type CommentReplyTargetComment = {
id: number
}
export function getCommentReplyTargetCandidates(
itemType: 'issue' | 'pr',
comments: readonly CommentReplyTargetComment[],
visibleComments: readonly CommentReplyTargetComment[]
): readonly CommentReplyTargetComment[] {
// Why: the PR audience filter is hidden on issues, so stale PR filter state
// should not constrain issue reply targets after switching items.
return itemType === 'issue' ? comments : visibleComments
}
export function resolveCommentReplyTarget(
replyingTo: number | null,
visibleComments: readonly CommentReplyTargetComment[]
@@ -46,7 +46,7 @@ import type {
GitDiffResult,
GitStatusEntry
} from '../../../../shared/types'
import { Check, Copy, MessageSquare, PanelLeftOpen, Sparkles, Trash2 } from 'lucide-react'
import { Check, Copy, MessageSquare, PanelLeftOpen, Sparkles, Trash2, WrapText } from 'lucide-react'
import { toast } from 'sonner'
import { DiffSectionItem } from './DiffSectionItem'
import { DiffNotesSendMenu } from './DiffNotesSendMenu'
@@ -55,10 +55,7 @@ import {
createCombinedDiffSectionIndexMap,
handleCombinedDiffFileTreeNavigation
} from './CombinedDiffFileTree'
import {
getCombinedDiffFileTreeSectionKey,
type CombinedDiffFileTreeMode
} from './combined-diff-file-tree-model'
import { getCombinedDiffFileTreeSectionKey } from './combined-diff-file-tree-model'
import {
ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT,
type EditorPathMutationTarget
@@ -77,6 +74,7 @@ import type { DiffSection } from './diff-section-types'
import { getInitialCombinedDiffSectionLoadIndices } from './combined-diff-initial-section-load'
import { removeDiffSectionMeasuredHeight } from './diff-section-height-cache'
import { createCombinedDiffLoadScheduler } from './combined-diff-load-scheduler'
import { combinedDiffSectionsMatchEntryMetadata } from './combined-diff-section-cache-match'
import {
beginCombinedDiffScrollbarDrag,
type CombinedDiffScrollbarDragCleanup
@@ -210,26 +208,6 @@ function getInitialCombinedDiffFileTreeCollapsed(
return combinedDiffFileTreeCollapsedPreference ?? combinedDiffFileTreeVisibleByDefault !== true
}
function cachedCombinedDiffSectionsMatchEntries({
entries,
sections,
treeMode
}: {
entries: readonly (GitStatusEntry | GitBranchChangeEntry)[]
sections: readonly DiffSection[]
treeMode: CombinedDiffFileTreeMode
}): boolean {
return (
sections.length === entries.length &&
sections.every((section, index) => {
const entry = entries[index]
return (
entry !== undefined && section.key === getCombinedDiffFileTreeSectionKey(treeMode, entry)
)
})
)
}
export default function CombinedDiffViewer({
file,
viewStateKey
@@ -251,6 +229,7 @@ export default function CombinedDiffViewer({
const openCommitDiff = useAppStore((s) => s.openCommitDiff)
const openConflictReview = useAppStore((s) => s.openConflictReview)
const openBranchAllDiffs = useAppStore((s) => s.openBranchAllDiffs)
const updateSettings = useAppStore((s) => s.updateSettings)
const clearDiffComments = useAppStore((s) => s.clearDiffComments)
const diffCommentsForWorktree = useAppStore((s) => s.getDiffComments(file.worktreeId))
const activeGroupId = useAppStore((s) => s.activeGroupIdByWorktree[file.worktreeId])
@@ -540,7 +519,7 @@ export default function CombinedDiffViewer({
const canRestoreSnapshotSectionsByKey =
hasUncommittedEntriesSnapshot &&
cached !== undefined &&
cachedCombinedDiffSectionsMatchEntries({
combinedDiffSectionsMatchEntryMetadata({
entries,
sections: cached.sections,
treeMode
@@ -1129,6 +1108,10 @@ export default function CombinedDiffViewer({
})
}, [])
const toggleDiffWordWrap = useCallback(() => {
void updateSettings({ diffWordWrap: settings?.diffWordWrap !== true })
}, [settings?.diffWordWrap, updateSettings])
const openSection = useCallback(
(index: number) => {
const section = sectionsRef.current[index]
@@ -1838,6 +1821,20 @@ export default function CombinedDiffViewer({
? translate('auto.components.editor.CombinedDiffViewer.f786fd54e1', 'Inline')
: translate('auto.components.editor.CombinedDiffViewer.ec5053c7f5', 'Side by Side')}
</button>
<button
className={`inline-flex h-6 items-center gap-1 rounded border border-border px-2 text-xs transition-colors hover:text-foreground ${
settings?.diffWordWrap === true
? 'bg-accent text-foreground'
: 'text-muted-foreground'
}`}
onClick={toggleDiffWordWrap}
aria-pressed={settings?.diffWordWrap === true}
>
<WrapText className="size-3.5" />
{settings?.diffWordWrap === true
? translate('auto.components.editor.CombinedDiffViewer.a4420ca1f7', 'Wrap On')
: translate('auto.components.editor.CombinedDiffViewer.dde325ddfe', 'Wrap Off')}
</button>
</div>
</div>
@@ -9,6 +9,7 @@ import { combinedDiffSectionScrollbarOptions } from './diff-editor-scrollbar-opt
import type { DiffSection } from './diff-section-types'
import { translate } from '@/i18n/i18n'
import { LargeDiffFallback } from './LargeDiffFallback'
import { buildDiffEditorWordWrapOptions } from './diff-editor-word-wrap-options'
const ImageDiffViewer = lazy(() => import('./ImageDiffViewer'))
@@ -34,6 +35,7 @@ type DiffSectionBodyProps = {
modelPathBase: string
isEditable: boolean
diffEditorFontSize: number
diffWordWrap?: boolean
terminalFontFamily?: string
onCancelComment: () => void
onSubmitComment: (body: string) => Promise<void>
@@ -58,6 +60,7 @@ export function DiffSectionBody({
modelPathBase,
isEditable,
diffEditorFontSize,
diffWordWrap,
terminalFontFamily,
onCancelComment,
onSubmitComment,
@@ -190,6 +193,7 @@ export function DiffSectionBody({
fontSize: diffEditorFontSize,
fontFamily: terminalFontFamily || 'monospace',
lineNumbers: 'on',
...buildDiffEditorWordWrapOptions(diffWordWrap),
automaticLayout: true,
renderOverviewRuler: false,
scrollbar: combinedDiffSectionScrollbarOptions,
@@ -65,7 +65,11 @@ export function DiffSectionItem({
isBranchMode: boolean
sideBySide: boolean
isDark: boolean
settings: { terminalFontSize?: number; terminalFontFamily?: string } | null
settings: {
terminalFontSize?: number
terminalFontFamily?: string
diffWordWrap?: boolean
} | null
sectionHeight: number | undefined
worktreeId?: string
loadSection: (index: number) => void
@@ -421,6 +425,7 @@ export function DiffSectionItem({
modelPathBase={modelPathBase}
isEditable={isEditable}
diffEditorFontSize={diffEditorFontSize}
diffWordWrap={settings?.diffWordWrap}
terminalFontFamily={settings?.terminalFontFamily}
onCancelComment={() => setPopover(null)}
onSubmitComment={handleSubmitComment}
@@ -23,6 +23,7 @@ import { getLargeDiffRenderLimit } from './large-diff-render-limit'
import { useDiffViewerLargeDiffLifecycle } from './useDiffViewerLargeDiffLifecycle'
import { getDiffViewerLargeDiffSaveAction } from './diff-viewer-large-diff-save-action'
import type { DiffViewerProps } from './diff-viewer-props'
import { buildDiffEditorWordWrapOptions } from './diff-editor-word-wrap-options'
export default function DiffViewer({
modelKey,
@@ -450,6 +451,7 @@ export default function DiffViewer({
fontSize: diffEditorFontSize,
fontFamily: settings?.terminalFontFamily || 'monospace',
lineNumbers: 'on',
...buildDiffEditorWordWrapOptions(settings?.diffWordWrap),
automaticLayout: true,
renderOverviewRuler: true,
scrollbar: diffEditorScrollbarOptions,
@@ -83,6 +83,8 @@ export function EditorPanelHeader({
}: EditorPanelHeaderProps): React.JSX.Element {
const diffComments = useAppStore((s) => s.getDiffComments(activeFile.worktreeId))
const activeGroupId = useAppStore((s) => s.activeGroupIdByWorktree[activeFile.worktreeId])
const diffWordWrap = useAppStore((s) => s.settings?.diffWordWrap === true)
const updateSettings = useAppStore((s) => s.updateSettings)
const fileDiffComments = useMemo(
() => diffComments.filter((comment) => comment.filePath === activeFile.relativePath),
[activeFile.relativePath, diffComments]
@@ -246,10 +248,13 @@ export function EditorPanelHeader({
)}
<EditorPanelMarkdownActionsMenu
isMarkdown={isMarkdown}
isDiffSurface={isDiffSurface}
diffWordWrap={diffWordWrap}
shouldShowMarkdownExportAction={shouldShowMarkdownExportAction}
canExportMarkdownToPdf={canExportMarkdownToPdf}
canShowMarkdownFrontmatterToggle={canShowMarkdownFrontmatterToggle}
markdownFrontmatterVisible={markdownFrontmatterVisible}
onToggleDiffWordWrap={() => void updateSettings({ diffWordWrap: !diffWordWrap })}
onToggleMarkdownFrontmatter={onToggleMarkdownFrontmatter}
onExportMarkdownToPdf={onExportMarkdownToPdf}
/>
@@ -2,6 +2,7 @@ import type React from 'react'
import { MoreHorizontal } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
@@ -11,24 +12,32 @@ import { translate } from '@/i18n/i18n'
type EditorPanelMarkdownActionsMenuProps = {
isMarkdown: boolean
isDiffSurface: boolean
diffWordWrap: boolean
shouldShowMarkdownExportAction: boolean
canExportMarkdownToPdf: boolean
canShowMarkdownFrontmatterToggle: boolean
markdownFrontmatterVisible: boolean
onToggleDiffWordWrap: () => void
onToggleMarkdownFrontmatter: () => void
onExportMarkdownToPdf: () => void
}
export function EditorPanelMarkdownActionsMenu({
isMarkdown,
isDiffSurface,
diffWordWrap,
shouldShowMarkdownExportAction,
canExportMarkdownToPdf,
canShowMarkdownFrontmatterToggle,
markdownFrontmatterVisible,
onToggleDiffWordWrap,
onToggleMarkdownFrontmatter,
onExportMarkdownToPdf
}: EditorPanelMarkdownActionsMenuProps): React.JSX.Element | null {
if (!isMarkdown || (!shouldShowMarkdownExportAction && !canShowMarkdownFrontmatterToggle)) {
const hasMarkdownActions =
isMarkdown && (shouldShowMarkdownExportAction || canShowMarkdownFrontmatterToggle)
if (!isDiffSurface && !hasMarkdownActions) {
return null
}
@@ -51,6 +60,17 @@ export function EditorPanelMarkdownActionsMenu({
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={4}>
{isDiffSurface ? (
<>
<DropdownMenuCheckboxItem checked={diffWordWrap} onCheckedChange={onToggleDiffWordWrap}>
{translate(
'auto.components.editor.EditorPanelMarkdownActionsMenu.1eef809708',
'Word Wrap'
)}
</DropdownMenuCheckboxItem>
{hasMarkdownActions ? <DropdownMenuSeparator /> : null}
</>
) : null}
{canShowMarkdownFrontmatterToggle ? (
<>
<DropdownMenuItem
@@ -0,0 +1,87 @@
import { describe, expect, it } from 'vitest'
import type { GitBranchChangeEntry, GitStatusEntry } from '../../../../shared/types'
import type { DiffSection } from './diff-section-types'
import { combinedDiffSectionsMatchEntryMetadata } from './combined-diff-section-cache-match'
function section(overrides: Partial<DiffSection>): DiffSection {
return {
key: 'unstaged:src/file.ts',
path: 'src/file.ts',
status: 'modified',
area: 'unstaged',
originalContent: '',
modifiedContent: '',
collapsed: false,
loading: false,
dirty: false,
diffResult: null,
largeDiffRenderLimit: null,
...overrides
}
}
describe('combinedDiffSectionsMatchEntryMetadata', () => {
it('matches sections when keys and entry metadata still match', () => {
const entry: GitStatusEntry = {
path: 'src/file.ts',
status: 'modified',
area: 'unstaged',
added: 2,
removed: 1
}
expect(
combinedDiffSectionsMatchEntryMetadata({
entries: [entry],
sections: [section({ added: 2, removed: 1 })],
treeMode: 'uncommitted'
})
).toBe(true)
})
it('rejects cached sections when a same-path entry has new line counts', () => {
const entry: GitStatusEntry = {
path: 'src/file.ts',
status: 'modified',
area: 'unstaged',
added: 177,
removed: 175
}
expect(
combinedDiffSectionsMatchEntryMetadata({
entries: [entry],
sections: [section({ added: 2, removed: 1 })],
treeMode: 'uncommitted'
})
).toBe(false)
})
it('keeps branch sections separate when old paths change for the same new path', () => {
const entry: GitBranchChangeEntry = {
path: 'src/new.ts',
oldPath: 'src/old.ts',
status: 'renamed',
added: 4,
removed: 3
}
expect(
combinedDiffSectionsMatchEntryMetadata({
entries: [entry],
sections: [
section({
key: 'combined-branch:src/new.ts',
path: 'src/new.ts',
status: 'renamed',
area: undefined,
oldPath: 'src/older.ts',
added: 4,
removed: 3
})
],
treeMode: 'branch'
})
).toBe(false)
})
})
@@ -0,0 +1,37 @@
import type { GitBranchChangeEntry, GitStatusEntry } from '../../../../shared/types'
import type { CombinedDiffFileTreeMode } from './combined-diff-file-tree-model'
import { getCombinedDiffFileTreeSectionKey } from './combined-diff-file-tree-model'
import type { DiffSection } from './diff-section-types'
export function combinedDiffSectionsMatchEntryMetadata({
entries,
sections,
treeMode
}: {
entries: readonly (GitStatusEntry | GitBranchChangeEntry)[]
sections: readonly DiffSection[]
treeMode: CombinedDiffFileTreeMode
}): boolean {
// Why: same-path combined sections can survive status refreshes; metadata
// drift means restoring cached content would replay stale, partial diffs.
return (
sections.length === entries.length &&
sections.every((section, index) => {
const entry = entries[index]
if (!entry) {
return false
}
const entryArea = 'area' in entry ? entry.area : undefined
const entryAdded = 'added' in entry ? entry.added : undefined
const entryRemoved = 'removed' in entry ? entry.removed : undefined
return (
section.key === getCombinedDiffFileTreeSectionKey(treeMode, entry) &&
section.status === entry.status &&
section.area === entryArea &&
section.oldPath === entry.oldPath &&
section.added === entryAdded &&
section.removed === entryRemoved
)
})
)
}
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest'
import { buildDiffEditorWordWrapOptions } from './diff-editor-word-wrap-options'
describe('buildDiffEditorWordWrapOptions', () => {
it('keeps long diff lines unwrapped by default', () => {
expect(buildDiffEditorWordWrapOptions(undefined)).toEqual({ wordWrap: 'off' })
expect(buildDiffEditorWordWrapOptions(false)).toEqual({ wordWrap: 'off' })
})
it('enables Monaco diff word wrapping when the diff preference is on', () => {
expect(buildDiffEditorWordWrapOptions(true)).toEqual({ wordWrap: 'on' })
})
})
@@ -0,0 +1,9 @@
import type { editor } from 'monaco-editor'
export function buildDiffEditorWordWrapOptions(
diffWordWrap: boolean | undefined
): Pick<editor.IStandaloneDiffEditorConstructionOptions, 'wordWrap'> {
return {
wordWrap: diffWordWrap === true ? 'on' : 'off'
}
}
@@ -9,11 +9,11 @@ import {
useRepoById,
useRepos
} from '@/store/selectors'
import { filterAiVaultSessions, groupAiVaultSessions } from './ai-vault-session-filters'
import {
deriveAiVaultWorkspaceScopePaths,
filterAiVaultSessions,
groupAiVaultSessions
} from './ai-vault-session-filters'
deriveAiVaultScopeSessionPaths,
deriveAiVaultWorkspaceScopePaths
} from './ai-vault-scope-paths'
import {
DEFAULT_AI_VAULT_SCOPE,
getRestorableAiVaultScope,
@@ -31,7 +31,6 @@ import {
AI_VAULT_AGENTS,
type AiVaultAgent,
type AiVaultGroup,
type AiVaultListResult,
type AiVaultScope,
type AiVaultSession,
type AiVaultSort
@@ -40,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()
@@ -60,14 +58,7 @@ export default function AiVaultPanel(): React.JSX.Element {
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)
@@ -78,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,
@@ -87,12 +102,9 @@ 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,
@@ -136,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, {
@@ -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
)
}
@@ -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,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 }
}
@@ -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',
@@ -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">
@@ -29,6 +29,7 @@ import type { AppState } from '@/store/types'
import {
getAllWorktreesFromState,
useAllWorktrees,
useProjectHostSetupProjection,
useRepoMap,
useWorktreeMap
} from '@/store/selectors'
@@ -5332,11 +5333,13 @@ const WorktreeList = React.memo(function WorktreeList({
// Why: manual repo header order is bound to state.repos. Recent/Smart derive
// header order from the sorted visible worktree stream instead.
const repos = useAppStore((s) => s.repos)
const projects = useAppStore((s) => s.projects)
const projectHostSetups = useAppStore((s) => s.projectHostSetups)
const projectHostSetupProjection = useProjectHostSetupProjection()
const projectGrouping = useMemo(
() => ({ projects, projectHostSetups }),
[projectHostSetups, projects]
() => ({
projects: projectHostSetupProjection.projects,
projectHostSetups: projectHostSetupProjection.setups
}),
[projectHostSetupProjection]
)
const projectGroups = useAppStore((s) => s.projectGroups ?? EMPTY_PROJECT_GROUPS)
const folderWorkspaces = useAppStore((s) => s.folderWorkspaces)
@@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { getExecutionHostLabel } from '../../../../shared/execution-host'
import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection'
import {
ALL_GROUP_META,
buildRows,
@@ -361,6 +362,105 @@ describe('buildRows with pinned worktrees', () => {
])
})
it('renders same-project records with git remote identity as one mixed-host project header', () => {
const localRepo: Repo = {
...repo,
id: 'local-sample-app',
path: '/Users/alice/work/sample-app',
displayName: 'sample-app',
gitRemoteIdentity: {
canonicalKey: 'git.company.test/team/sample-app',
remoteName: 'origin',
remoteUrl: 'git@git.company.test:team/sample-app.git'
}
}
const sshRepo: Repo = {
...repo,
id: 'ssh-sample-app',
path: '/home/alice/src/sample-app',
displayName: 'sample-app',
connectionId: 'build server',
gitRemoteIdentity: {
canonicalKey: 'git.company.test/team/sample-app',
remoteName: 'origin',
remoteUrl: 'https://git.company.test/team/sample-app.git'
}
}
const runtimeRepo: Repo = {
...repo,
id: 'runtime-sample-app',
path: '/workspace/sample-app',
displayName: 'sample-app',
executionHostId: 'runtime:dev-container',
gitRemoteIdentity: {
canonicalKey: 'git.company.test/team/sample-app',
remoteName: 'origin',
remoteUrl: 'ssh://git@git.company.test/team/sample-app.git'
}
}
const localWorktree: Worktree = {
...worktree,
id: 'wt-local-sample-app',
repoId: localRepo.id,
path: '/Users/alice/work/sample-app-feature'
}
const sshWorktree: Worktree = {
...worktree,
id: 'wt-ssh-sample-app',
repoId: sshRepo.id,
path: '/home/alice/src/sample-app-feature'
}
const runtimeWorktree: Worktree = {
...worktree,
id: 'wt-runtime-sample-app',
repoId: runtimeRepo.id,
path: '/workspace/sample-app-feature'
}
const projection = projectHostSetupProjectionFromRepos([localRepo, sshRepo, runtimeRepo])
const rows = buildRows(
'repo',
[localWorktree, sshWorktree, runtimeWorktree],
new Map([
[localRepo.id, localRepo],
[sshRepo.id, sshRepo],
[runtimeRepo.id, runtimeRepo]
]),
null,
new Set(),
undefined,
undefined,
undefined,
{},
new Map([
[localWorktree.id, localWorktree],
[sshWorktree.id, sshWorktree],
[runtimeWorktree.id, runtimeWorktree]
]),
false,
undefined,
[],
new Set(),
new Map(),
[],
{
projects: projection.projects,
projectHostSetups: projection.setups
}
)
expect(rows).toMatchObject([
{
type: 'header',
key: 'project:git:git.company.test/team/sample-app',
label: 'sample-app',
count: 3
},
{ type: 'item', worktree: { id: localWorktree.id }, hostContextLabel: LOCAL_HOST_LABEL },
{ type: 'item', worktree: { id: sshWorktree.id }, hostContextLabel: 'build server' },
{ type: 'item', worktree: { id: runtimeWorktree.id }, hostContextLabel: 'dev-container' }
])
})
it('orders project identity headers by the manual repo order anchor', () => {
const analyticsProject: Project = {
...project,
@@ -2506,6 +2606,13 @@ describe('WorktreeList header styles', () => {
expect(source).toContain('headerKey: row.key')
expect(source).toContain('color={repoHeaderColor}')
})
it('adapts projected setup rows for sidebar project grouping', () => {
const source = readWorktreeListSource()
expect(source).toContain('const projectHostSetupProjection = useProjectHostSetupProjection()')
expect(source).toContain('projectHostSetups: projectHostSetupProjection.setups')
})
})
describe('buildRows pending creations', () => {
@@ -21,6 +21,11 @@ import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion
import { splitTerminalPaneWithInheritedCwd } from './terminal-pane-split-with-inherited-cwd'
import { useAppStore } from '@/store'
import { recordTerminalUserInputForLeaf } from './terminal-input-activity'
import {
markTerminalFollowOutput,
markTerminalPinnedViewport,
syncTerminalScrollIntentFromViewport
} from '@/lib/pane-manager/terminal-scroll-intent'
export function recordKeyboardCreatedTerminalPaneSplit(
createdPane: unknown,
@@ -314,9 +319,13 @@ export function useTerminalKeyboardShortcuts({
return
}
if (action.position === 'top') {
markTerminalPinnedViewport(pane.terminal)
pane.terminal.scrollToLine(0)
syncTerminalScrollIntentFromViewport(pane.terminal)
} else {
markTerminalFollowOutput(pane.terminal)
pane.terminal.scrollToBottom()
syncTerminalScrollIntentFromViewport(pane.terminal)
}
return
}
@@ -6255,6 +6255,12 @@ describe('connectPanePty', () => {
const pane = createPane(1)
pane.terminal.buffer.active.viewportY = 42
pane.terminal.buffer.active.baseY = 100
pane.terminal.write.mockImplementation((data: string, callback?: () => void) => {
if (data.includes('snapshot-state')) {
pane.terminal.buffer.active.viewportY = 0
}
callback?.()
})
const manager = createManager(1)
const deps = createDeps({
isVisibleRef: { current: false }
@@ -58,8 +58,11 @@ import {
} from '@/lib/pane-manager/pane-terminal-output-scheduler'
import { recordAgentHibernationPaneOutput } from '@/lib/agent-hibernation-output-activity'
import { isLocalNativeWindowsConpty } from '@/lib/pane-manager/windows-pty-compatibility'
import { recordTerminalOutput, restoreScrollStateAfterLayout } from '@/lib/pane-manager/pane-scroll'
import type { ScrollState } from '@/lib/pane-manager/pane-manager-types'
import { recordTerminalOutput } from '@/lib/pane-manager/pane-scroll'
import {
captureTerminalWriteScrollIntent,
enforceTerminalWriteScrollIntent
} from '@/lib/pane-manager/terminal-scroll-intent'
import { createBrowserUuid } from '@/lib/browser-uuid'
import { makePaneKey, parseLegacyNumericPaneKey } from '../../../../shared/stable-pane-id'
import { createTerminalCommandLifecycle } from './terminal-command-lifecycle'
@@ -3114,40 +3117,13 @@ export function connectPanePty(
})
}
function captureScrollStateForSnapshotReplay(): ScrollState | null {
const buf = pane.terminal.buffer?.active
if (!buf) {
return null
}
const viewportY = buf.viewportY
const baseY = buf.baseY
if (!Number.isFinite(viewportY) || !Number.isFinite(baseY)) {
return null
}
return {
bufferType: buf.type,
wasAtBottom: viewportY >= baseY,
viewportY,
baseY
}
}
function restoreScrollStateAfterSnapshotReplay(state: ScrollState | null): void {
if (!state || state.wasAtBottom) {
return
}
// Why: hidden-backlog replay clears xterm after visibility scroll restore;
// re-apply a scrolled-up viewport so recovery does not jump to bottom.
restoreScrollStateAfterLayout(pane.terminal, state)
}
function applyMainBufferSnapshot(snapshot: {
data: string
cols: number
rows: number
seq?: number
}): void {
const scrollState = captureScrollStateForSnapshotReplay()
const scrollIntent = captureTerminalWriteScrollIntent(pane.terminal)
const colsBeforeReplay = pane.terminal.cols
const rowsBeforeReplay = pane.terminal.rows
const hasSnapshotDimensions =
@@ -3191,7 +3167,9 @@ export function connectPanePty(
}
scheduleReattachIdleAgentCursorReset()
}
restoreScrollStateAfterSnapshotReplay(scrollState)
// Why: snapshot replay clears and rebuilds xterm state; re-apply the
// user's scroll intent once so hidden catch-up cannot repin the viewport.
enforceTerminalWriteScrollIntent(pane.terminal, scrollIntent)
}
function requestHiddenOutputRestoreIfNeeded(opts?: { bypassScheduler?: boolean }): boolean {
@@ -5,7 +5,7 @@ import {
flushTerminalOutput,
requestTerminalBacklogRecovery
} from '@/lib/pane-manager/pane-terminal-output-scheduler'
import { restoreScrollStateAfterLayout } from '@/lib/pane-manager/pane-scroll'
import { enforceTerminalCurrentScrollIntent } from '@/lib/pane-manager/terminal-scroll-intent'
import { fitAndFocusPanes, fitPanes, focusActivePane } from './pane-helpers'
const VISIBLE_RESUME_FLUSH_CHARS = 256 * 1024
@@ -47,7 +47,7 @@ export function resumeTerminalVisibility({
// post-resume fit runs. Capture numeric viewport positions first; the
// restore path avoids content matching so duplicate agent log lines do
// not jump to the wrong history entry.
const viewportPositions = captureViewportPositions(!wasVisible)
captureViewportPositions(!wasVisible)
withSuppressedScrollTracking(() => {
if (shouldUseLightTabResume) {
// Why: intra-worktree tab switches only toggle the overlay. Keeping
@@ -61,7 +61,7 @@ export function resumeTerminalVisibility({
} else {
resumeTerminalVisibilityHeavy(manager, isActive)
}
restoreTerminalViewportPositions(manager, viewportPositions)
enforceTerminalViewportIntents(manager)
if (!shouldUseLightTabResume) {
// Why: this clear wipes the glyph atlas shared with other same-config
// terminals; the global reset rebuilds their render models too.
@@ -137,14 +137,8 @@ function resumeTerminalVisibilityHeavy(manager: PaneManager, isActive: boolean):
}
}
function restoreTerminalViewportPositions(
manager: PaneManager,
viewportPositions: Map<number, ScrollState>
): void {
function enforceTerminalViewportIntents(manager: PaneManager): void {
for (const pane of manager.getPanes()) {
const position = viewportPositions.get(pane.id)
if (position) {
restoreScrollStateAfterLayout(pane.terminal, position)
}
enforceTerminalCurrentScrollIntent(pane.terminal)
}
}
@@ -17,6 +17,7 @@ const mocks = vi.hoisted(() => ({
flushTerminalOutput: vi.fn(),
getTerminalOutputEpoch: vi.fn(() => 0),
handleTerminalFileDrop: vi.fn(),
enforceTerminalCurrentScrollIntent: vi.fn(),
pasteTerminalText: vi.fn(),
recordTerminalUserInputForLeaf: vi.fn(),
requestTerminalBacklogRecovery: vi.fn(),
@@ -75,6 +76,10 @@ vi.mock('@/lib/pane-manager/pane-scroll', () => ({
restoreScrollStateAfterLayout: mocks.restoreScrollStateAfterLayout
}))
vi.mock('@/lib/pane-manager/terminal-scroll-intent', () => ({
enforceTerminalCurrentScrollIntent: mocks.enforceTerminalCurrentScrollIntent
}))
vi.mock('./terminal-drop-handler', () => ({
handleTerminalFileDrop: mocks.handleTerminalFileDrop
}))
@@ -234,6 +239,9 @@ describe('useTerminalPaneGlobalEffects', () => {
mocks.restoreScrollStateAfterLayout.mockImplementation((terminal: { name: string }) => {
order.push(`restore:${terminal.name}`)
})
mocks.enforceTerminalCurrentScrollIntent.mockImplementation((terminal: { name: string }) => {
order.push(`intent:${terminal.name}`)
})
mocks.fitAndFocusPanes.mockImplementation(() => order.push('fit-focus'))
// Why: the resume path resets atlases through the live-manager registry
@@ -267,10 +275,11 @@ describe('useTerminalPaneGlobalEffects', () => {
'flush:terminal-b',
'resume',
'fit-focus',
'restore:terminal-a',
'restore:terminal-b',
'intent:terminal-a',
'intent:terminal-b',
'reset-atlas'
])
expect(mocks.restoreScrollStateAfterLayout).not.toHaveBeenCalled()
expect(mocks.flushTerminalOutput).toHaveBeenNthCalledWith(1, terminalA, {
maxChars: 256 * 1024
})
@@ -563,7 +572,7 @@ describe('useTerminalPaneGlobalEffects', () => {
expect(window.api.pty.setActiveRendererPty).toHaveBeenCalledWith('pty-active', true)
})
it('restores from the pre-hide scroll state when hidden layout changes the viewport', () => {
it('enforces scroll intent after hidden layout changes the viewport', () => {
const terminalA = { name: 'terminal-a' }
const manager = {
getPanes: vi.fn(() => [{ id: 1, terminal: terminalA }]),
@@ -618,7 +627,8 @@ describe('useTerminalPaneGlobalEffects', () => {
expect(mocks.captureScrollState).toHaveBeenCalledTimes(2)
expect(manager.suspendRendering).toHaveBeenCalledTimes(1)
expect(mocks.restoreScrollStateAfterLayout).toHaveBeenLastCalledWith(terminalA, preHideState)
expect(mocks.restoreScrollStateAfterLayout).not.toHaveBeenCalled()
expect(mocks.enforceTerminalCurrentScrollIntent).toHaveBeenLastCalledWith(terminalA)
})
it('clears WebGL texture atlases when the active visible terminal regains focus', () => {
@@ -71,6 +71,10 @@ import { getConnectionId } from '@/lib/connection-context'
import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'
import { isPaneReplaying, type ReplayingPanesRef } from './replay-guard'
import { fitAndFocusPanes, fitPanes } from './pane-helpers'
import {
markTerminalPinnedViewport,
syncTerminalScrollIntentSoon
} from '@/lib/pane-manager/terminal-scroll-intent'
import { registerRuntimeTerminalTab, scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph'
import { e2eConfig } from '@/lib/e2e-config'
import {
@@ -720,6 +724,15 @@ export function useTerminalPaneLifecycle({
return false
}
if (e.type === 'keydown') {
if (e.key === 'PageUp' || e.key === 'Home') {
markTerminalPinnedViewport(pane.terminal)
syncTerminalScrollIntentSoon(pane.terminal, { preservePinnedAtBottom: true })
} else if (e.key === 'PageDown' || e.key === 'End') {
syncTerminalScrollIntentSoon(pane.terminal)
}
}
return !shouldBypassXtermKeyboardEvent(e, {
isMac,
hasSelection: pane.terminal.hasSelection()
@@ -11,7 +11,9 @@ const mocks = vi.hoisted(() => ({
baseY: 0
})),
flushTerminalOutput: vi.fn(),
getTerminalOutputEpoch: vi.fn(() => 1)
getTerminalOutputEpoch: vi.fn(() => 1),
getTerminalScrollIntentKind: vi.fn(() => 'followOutput'),
markTerminalFollowOutput: vi.fn()
}))
const reactRefState = vi.hoisted(() => ({
@@ -68,6 +70,11 @@ vi.mock('@/lib/pane-manager/pane-scroll', () => ({
getTerminalOutputEpoch: mocks.getTerminalOutputEpoch
}))
vi.mock('@/lib/pane-manager/terminal-scroll-intent', () => ({
getTerminalScrollIntentKind: mocks.getTerminalScrollIntentKind,
markTerminalFollowOutput: mocks.markTerminalFollowOutput
}))
describe('useTerminalScrollVisibilityMemory', () => {
const originalRequestAnimationFrame = globalThis.requestAnimationFrame
const originalCancelAnimationFrame = globalThis.cancelAnimationFrame
@@ -75,6 +82,7 @@ describe('useTerminalScrollVisibilityMemory', () => {
beforeEach(() => {
resetHookRefs()
vi.clearAllMocks()
mocks.getTerminalScrollIntentKind.mockReturnValue('followOutput')
})
afterEach(() => {
@@ -121,6 +129,42 @@ describe('useTerminalScrollVisibilityMemory', () => {
maxChars: 256 * 1024
})
expect(terminal.scrollToBottom).toHaveBeenCalled()
expect(mocks.markTerminalFollowOutput).toHaveBeenCalledWith(terminal)
})
it('does not turn a pinned viewport into follow-output when pending focus requests catch up', () => {
mocks.getTerminalScrollIntentKind.mockReturnValue('pinnedViewport')
const terminal = {
onScroll: vi.fn(() => ({ dispose: vi.fn() })),
scrollToBottom: vi.fn()
}
const manager = {
getPanes: vi.fn(() => [{ id: 1, terminal }])
}
const animationFrames: FrameRequestCallback[] = []
globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => {
animationFrames.push(callback)
return animationFrames.length
})
beginHookRender()
const visibilityMemory = useTerminalScrollVisibilityMemory({
managerRef: { current: manager as never },
isVisibleRef: { current: true },
visibleResumeCompleteRef: { current: true },
paneCount: 1
})
visibilityMemory.scheduleFollowOutputIfNeeded(1)
animationFrames.shift()?.(16)
animationFrames.shift()?.(32)
expect(mocks.flushTerminalOutput).toHaveBeenCalledWith(terminal, {
maxChars: 256 * 1024
})
expect(mocks.cancelDeferredScrollRestore).not.toHaveBeenCalled()
expect(mocks.markTerminalFollowOutput).not.toHaveBeenCalled()
expect(terminal.scrollToBottom).not.toHaveBeenCalled()
})
it('cancels pending follow-output frames on cleanup', () => {
@@ -1,11 +1,15 @@
import { useCallback, useEffect, useRef } from 'react'
import type { IDisposable, Terminal } from '@xterm/xterm'
import type { Terminal } from '@xterm/xterm'
import { flushTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler'
import {
cancelDeferredScrollRestore,
captureScrollState,
getTerminalOutputEpoch
} from '@/lib/pane-manager/pane-scroll'
import {
getTerminalScrollIntentKind,
markTerminalFollowOutput
} from '@/lib/pane-manager/terminal-scroll-intent'
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
import type { ScrollState } from '@/lib/pane-manager/pane-manager-types'
@@ -37,7 +41,6 @@ export function useTerminalScrollVisibilityMemory({
paneCount
}: UseTerminalScrollVisibilityMemoryArgs): TerminalScrollVisibilityMemory {
const visibleScrollSnapshotsRef = useRef<Map<number, VisibleScrollSnapshot>>(new Map())
const scrollDisposablesRef = useRef<Map<number, IDisposable>>(new Map())
const suppressScrollTrackingRef = useRef(false)
const pendingFollowOutputPaneIdsRef = useRef<Set<number>>(new Set())
const followOutputFrameIdsRef = useRef<number[]>([])
@@ -117,10 +120,13 @@ export function useTerminalScrollVisibilityMemory({
const currentEpoch = getTerminalOutputEpoch(pane.terminal)
const hasNewOutput = previous ? currentEpoch > previous.outputEpoch : currentEpoch > 0
if (hasNewOutput) {
cancelDeferredScrollRestore(pane.terminal)
pane.terminal.scrollToBottom()
if (getTerminalScrollIntentKind(pane.terminal) === 'followOutput') {
cancelDeferredScrollRestore(pane.terminal)
markTerminalFollowOutput(pane.terminal)
pane.terminal.scrollToBottom()
didScroll = true
}
rememberVisibleScrollSnapshot(pane.id, pane.terminal)
didScroll = true
}
pending.delete(pane.id)
}
@@ -164,46 +170,15 @@ export function useTerminalScrollVisibilityMemory({
if (!manager) {
return
}
const disposables = scrollDisposablesRef.current
const panes = manager.getPanes()
const livePaneIds = new Set(panes.map((pane) => pane.id))
for (const [paneId, disposable] of disposables) {
for (const paneId of visibleScrollSnapshotsRef.current.keys()) {
if (!livePaneIds.has(paneId)) {
disposable.dispose()
disposables.delete(paneId)
visibleScrollSnapshotsRef.current.delete(paneId)
pendingFollowOutputPaneIdsRef.current.delete(paneId)
}
}
for (const pane of panes) {
if (disposables.has(pane.id)) {
continue
}
const onScroll = (
pane.terminal as Terminal & {
onScroll?: (listener: (position: number) => void) => IDisposable
}
).onScroll
if (typeof onScroll !== 'function') {
continue
}
disposables.set(
pane.id,
onScroll.call(pane.terminal, () => {
if (!isVisibleRef.current || suppressScrollTrackingRef.current) {
return
}
rememberVisibleScrollSnapshot(pane.id, pane.terminal)
})
)
}
return () => {
for (const disposable of disposables.values()) {
disposable.dispose()
}
disposables.clear()
}
}, [isVisibleRef, managerRef, paneCount, rememberVisibleScrollSnapshot])
}, [managerRef, paneCount])
return {
captureViewportPositions,
@@ -240,6 +240,19 @@ describe('useComposerState host-context boundaries', () => {
)
})
it('selects a project by its own host instead of pinning the current host', () => {
// Regression: passing the current host as a hard `hostId` made picking a
// project set up only on a different host a silent no-op. The current host
// must be a preference (focusedHostScope), with a fallback to any ready host.
const handleProjectChange = sourceBetween(
HOOK_SOURCE,
'const handleProjectChange = useCallback',
'const handleSmartGitHubItemSelect'
)
expect(handleProjectChange).toContain('focusedHostScope: preferredHostId ?? workspaceHostScope')
expect(handleProjectChange).not.toContain('hostId: preferredHostId')
})
it('clears GitLab-specific linked state when clearing smart-name selection', () => {
const section = sourceBetween(
HOOK_SOURCE,
+7 -2
View File
@@ -2277,13 +2277,18 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
setSelectedProjectGroupId(null)
const preferredHostId =
selectedWorkspaceTarget.status === 'ready' ? selectedWorkspaceTarget.target.hostId : null
// Why: prefer the host the user is currently on, but treat it as a
// preference (focusedHostScope) rather than a hard hostId match. Pinning
// hostId made selecting a project that is only set up on a different host
// a silent no-op — the resolver returned '' (project-not-set-up-on-host)
// and the early return below swallowed the click. Falling back to any
// host the project is ready on lets cross-host selection work.
const nextRepoId = resolveWorkspaceCreationRepoId({
eligibleRepos,
projects,
projectHostSetups,
projectId,
hostId: preferredHostId,
focusedHostScope: workspaceHostScope
focusedHostScope: preferredHostId ?? workspaceHostScope
})
if (!nextRepoId) {
return
+29 -4
View File
@@ -822,7 +822,23 @@
"ba8e329d92": "Unmark viewed",
"3f79ffc8b7": "Open the PR details to view current reviewers.",
"5c1c973855": "Remove reviewer",
"commentTooLarge": "Comment is too large to submit safely."
"commentTooLarge": "Comment is too large to submit safely.",
"timeline": {
"completed": "as completed",
"notPlanned": "as not planned",
"someone": "someone",
"assigned": "assigned",
"unassigned": "unassigned",
"mentioned": "mentioned this",
"in": "in",
"closed": "closed this",
"reopened": "reopened this",
"moved": "moved this",
"from": "from",
"to": "to",
"activity": "Activity",
"noActivity": "No activity yet."
}
},
"GitLabItemDialog": {
"65e784c1f1": "Reopen",
@@ -1107,6 +1123,8 @@
"notePasteTooLarge": "Paste is too large for the note field."
},
"NewWorkspaceComposerModal": {
"createWorktree": "Create worktree",
"createWorkspace": "Create workspace",
"fa90f739a5": "Choose the project, workspace name, and agent before creating the workspace."
},
"PullRequestPage": {
@@ -4941,7 +4959,11 @@
"70bb30feb1": "Save editor and editable diff changes automatically after a short pause.",
"0df2e4fd12": "Auto Save Files",
"d21136d9ef": "Configure how Orca persists file edits.",
"45c6e85c4d": "Editor"
"45c6e85c4d": "Editor",
"8f1afdfbd8": "Diff Word Wrap",
"4aa4d9fb73": "Wrap long lines in diff editors instead of requiring horizontal scrolling.",
"bf16ef0af2": "Off",
"3f6892f307": "On"
},
"AdvancedNetworkSettingsSection": {
"3e431564b5": "localhost, 127.0.0.1, *.internal",
@@ -10485,7 +10507,9 @@
"8368d256ec": "combined-branch",
"8f68ad9ca9": "Show {{value0}} AI {{value1}}",
"724a13568d": " in {{value0}}",
"6094135eec": " vs {{value0}}"
"6094135eec": " vs {{value0}}",
"a4420ca1f7": "Wrap On",
"dde325ddfe": "Wrap Off"
},
"ConflictComponents": {
"f338288514": "Loading conflict contents...",
@@ -10573,7 +10597,8 @@
"3e0ce48c24": "Export as PDF",
"561251019a": "More actions",
"8c8b7f5ff5": "Show front matter",
"10c39d58c1": "Hide front matter"
"10c39d58c1": "Hide front matter",
"1eef809708": "Word Wrap"
},
"EditorPanelShell": {
"e2c4dec350": "Loading editor..."
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+204 -179
View File
@@ -107,7 +107,7 @@
"c9d6f98459": "최대화",
"66f0a552e5": "복원",
"bbb7f90669": "최소화",
"d54e66004c": "terminal",
"d54e66004c": "터미널",
"9f0152563e": "모바일",
"62ca9895a7": "스페이스",
"844eb0f4f4": "활동",
@@ -537,10 +537,10 @@
"38c9f034ff": "드롭한 파일을 업로드하지 못했습니다.",
"d720e2f855": "드롭한 일부 파일을 업로드할 수 없습니다.",
"245faa95b9": "드롭한 파일에 사용할 수 있는 원격 워크스페이스 경로가 없습니다.",
"nativeDropTooManyPathsDescription": "Drop {{value0}} or fewer files at a time.",
"nativeDropTooManyPaths": "Drop contains too many files.",
"nativeDropPathsTooLargeDescription": "Drop fewer files or use a shorter path list.",
"nativeDropPathsTooLarge": "Drop path list is too large."
"nativeDropTooManyPathsDescription": "한 번에 {{value0}}개 이하의 파일을 드롭하세요.",
"nativeDropTooManyPaths": "드롭에 파일이 너무 많습니다.",
"nativeDropPathsTooLargeDescription": "파일 수를 줄이거나 더 짧은 경로 목록을 사용하세요.",
"nativeDropPathsTooLarge": "드롭 경로 목록이 너무 큽니다."
},
"useIpcEvents": {
"0e3cf53060": "브라우저 탭 {{value0}}을(를) 찾을 수 없습니다",
@@ -593,7 +593,7 @@
"42ae40842f": "전역적으로 또는 프로젝트별로 범위가 지정된 저장된 terminal 명령입니다.",
"3fc3db144f": "빠른 명령어",
"c33bfd664c": "셸, 렌더러, 세션 및 terminal 동작.",
"a9fb10afca": "Terminal",
"a9fb10afca": "터미널",
"5235c215ca": "작업 페이지와 사이드바에 표시할 작업 제공자를 선택합니다.",
"85f4fd7710": "작업 소스",
"ab4b21b58e": "브랜치 이름 지정, 기본 참조, 속성 및 Git AI Author.",
@@ -618,13 +618,13 @@
"b1c2f8b0ac": "Claude, Codex, Gemini 및 OpenCode Go에 대한 선택적 계정 전환.",
"f70ac54d38": "AI 제공업체 계정",
"4121f7a0a2": "AI agents를 관리하고, 기본값을 설정하고, 명령을 사용자 정의하세요.",
"b49abbd2f7": "Agents"
"b49abbd2f7": "에이전트"
},
"useAppMenuPaste": {
"pasteTooLarge": "Paste is too large."
"pasteTooLarge": "붙여넣기 내용이 너무 큽니다."
},
"useLargeTextControlPaste": {
"pasteTooLarge": "Paste is too large."
"pasteTooLarge": "붙여넣기 내용이 너무 큽니다."
}
},
"components": {
@@ -632,10 +632,10 @@
"9132779820": "닫기",
"c72a5fb234": "다시 시작",
"9263e75f49": "Codex는 이전 계정을 사용하고 있습니다",
"a4c8e1b2f7": "Codex is still signed in as {{value0}}",
"d3e8a1f4b2": "Account switched",
"e9b2c7d1a5": "Restart this session to use {{value0}}. Collapse to keep working with the current account.",
"f1a6c8e3b4": "Collapse"
"a4c8e1b2f7": "Codex가 아직 {{value0}} 계정으로 로그인되어 있습니다",
"d3e8a1f4b2": "계정 전환됨",
"e9b2c7d1a5": "{{value0}}을(를) 사용하려면 이 세션을 다시 시작하세요. 현재 계정으로 계속 작업하려면 접으세요.",
"f1a6c8e3b4": "접기"
},
"FirstLaunchBanner": {
"b9e1b966c7": "알림 닫기",
@@ -822,7 +822,23 @@
"ba8e329d92": "본 것으로 표시 해제",
"3f79ffc8b7": "현재 리뷰어를 보려면 PR 세부정보를 엽니다.",
"5c1c973855": "리뷰어 삭제",
"commentTooLarge": "Comment is too large to submit safely."
"commentTooLarge": "댓글이 너무 커서 안전하게 제출할 수 없습니다.",
"timeline": {
"completed": "as completed",
"notPlanned": "as not planned",
"someone": "someone",
"assigned": "assigned",
"unassigned": "unassigned",
"mentioned": "mentioned this",
"in": "in",
"closed": "closed this",
"reopened": "reopened this",
"moved": "moved this",
"from": "from",
"to": "to",
"activity": "Activity",
"noActivity": "No activity yet."
}
},
"GitLabItemDialog": {
"65e784c1f1": "다시 열기",
@@ -887,7 +903,7 @@
"3a051b8ade": "작업 항목",
"32f8bef818": "로그 출력이 없습니다.",
"4168eb2c51": "해결",
"commentTooLarge": "Comment is too large to submit safely."
"commentTooLarge": "댓글이 너무 커서 안전하게 제출할 수 없습니다."
},
"JiraIssueWorkspace": {
"b0b92666c9": "댓글",
@@ -922,7 +938,7 @@
"2a829a2f00": "우선순위",
"693be070d0": "전환",
"ef21405c6d": "Jira 이슈",
"commentTooLarge": "Comment is too large to submit safely."
"commentTooLarge": "댓글이 너무 커서 안전하게 제출할 수 없습니다."
},
"Landing": {
"76a95f7f47": "생성",
@@ -1045,7 +1061,7 @@
"39883467f4": "Linear 이슈",
"fda549766e": "댓글을 달려면 {{value0}}",
"d71cd3003e": "+ 담당자",
"commentTooLarge": "Comment is too large to submit safely."
"commentTooLarge": "댓글이 너무 커서 안전하게 제출할 수 없습니다."
},
"NewWorkspaceComposerCard": {
"cbb47ee0dc": "로컬 Git 프로젝트에만 사용할 수 있습니다.",
@@ -1057,7 +1073,7 @@
"f0470c7383": "고급",
"ba64270bdb": "agents 구성",
"ab63f25397": "agent 설정 열기",
"01d1e8f601": "Agent",
"01d1e8f601": "에이전트",
"0c5d6a479c": "[선택 사항]",
"b5a0796911": "연결",
"dccd26d4e4": "프로젝트 선택",
@@ -1102,11 +1118,13 @@
"importingHostSetup": "가져오는 중...",
"importHostSetup": "가져오기",
"notePasteTooLarge": "메모 필드에 붙여넣을 내용이 너무 큽니다.",
"reuseExistingBranch": "Reuse branch",
"reuseExistingBranchHint": "Check out the existing branch instead of creating a new one from it.",
"createMultiple": "Create more"
"reuseExistingBranch": "브랜치 재사용",
"reuseExistingBranchHint": "기존 브랜치에서 새 브랜치를 만들지 않고 해당 브랜치를 체크아웃합니다.",
"createMultiple": "더 만들기"
},
"NewWorkspaceComposerModal": {
"createWorktree": "작업 트리 만들기",
"createWorkspace": "워크스페이스 만들기",
"fa90f739a5": "워크스페이스를 만들기 전에 프로젝트, 워크스페이스 이름, agent를 선택하세요."
},
"PullRequestPage": {
@@ -1274,10 +1292,10 @@
"2b4fdb880c": "본 표시 해제",
"56ec6eafb7": "현재 리뷰어를 보려면 PR 세부정보를 엽니다.",
"7f964a365a": "리뷰어 삭제",
"commentTooLarge": "Comment is too large to submit safely.",
"8ff5ae8866": "Assignees",
"82c87eceb9": "Edit assignees",
"1ff5d979df": "No one assigned"
"commentTooLarge": "댓글이 너무 커서 안전하게 제출할 수 없습니다.",
"8ff5ae8866": "담당자",
"82c87eceb9": "담당자 편집",
"1ff5d979df": "할당된 사람이 없음"
},
"QuickOpen": {
"1dbd3f59ff": "이동",
@@ -1607,25 +1625,25 @@
"fe28c9821f": "보기",
"8d1e17a3ef": "GitHub에서 {{value0}} 열기",
"4ac8ff2275": "Jira에서 {{value0}} 열기",
"40eaf2c27c": "Details",
"closeAsCompleted": "Close as completed",
"closeAsCompletedDescription": "Done, closed, fixed, resolved",
"closeAsNotPlanned": "Close as not planned",
"closeAsNotPlannedDescription": "Won't fix, can't repro, stale",
"closeAsDuplicate": "Close as duplicate",
"closeAsDuplicateDescription": "Duplicate of another issue in this repository",
"duplicateIssueNumberPlaceholder": "Issue number",
"closeAsDuplicateSubmit": "Close duplicate",
"duplicateIssueMissing": "Enter an issue number in this repository.",
"duplicateIssueNotInteger": "Use a whole issue number.",
"duplicateIssueNotPositive": "Use a positive issue number.",
"duplicateIssueSameIssue": "Choose a different issue.",
"repository": "Repository",
"backToCloseReasons": "Back",
"searchIssues": "Search issues",
"useIssueNumber": "Use issue #{{value0}}",
"noMatchingIssuesLoaded": "No matching issues loaded.",
"editReviewersWithCurrent": "Edit reviewers: {{value0}}"
"40eaf2c27c": "세부 정보",
"closeAsCompleted": "완료로 닫기",
"closeAsCompletedDescription": "완료, 닫힘, 수정됨, 해결됨",
"closeAsNotPlanned": "계획 없음으로 닫기",
"closeAsNotPlannedDescription": "수정 안 함, 재현 불가, 오래됨",
"closeAsDuplicate": "중복으로 닫기",
"closeAsDuplicateDescription": "이 리포지토리의 다른 이슈와 중복",
"duplicateIssueNumberPlaceholder": "이슈 번호",
"closeAsDuplicateSubmit": "중복 닫기",
"duplicateIssueMissing": "이 리포지토리의 이슈 번호를 입력하세요.",
"duplicateIssueNotInteger": "정수 이슈 번호를 사용하세요.",
"duplicateIssueNotPositive": "양수 이슈 번호를 사용하세요.",
"duplicateIssueSameIssue": "다른 이슈를 선택하세요.",
"repository": "리포지토리",
"backToCloseReasons": "뒤로",
"searchIssues": "이슈 검색",
"useIssueNumber": "이슈 #{{value0}} 사용",
"noMatchingIssuesLoaded": "일치하는 이슈를 불러오지 않았습니다.",
"editReviewersWithCurrent": "리뷰어 편집: {{value0}}"
},
"Terminal": {
"73768427cf": "닫기",
@@ -1727,9 +1745,9 @@
"actionBadge": "작업",
"paletteHostBadge": "호스트: {{value0}}",
"workspaceTabMissing": "탭이 더 이상 존재하지 않습니다",
"projectsGroupsHeader": "Projects & Groups",
"projectBadge": "Project",
"repoGroupBadge": "Repo group"
"projectsGroupsHeader": "프로젝트 및 그룹",
"projectBadge": "프로젝트",
"repoGroupBadge": "리포지토리 그룹"
},
"github": {
"pr": {
@@ -1873,9 +1891,9 @@
"067119985c": "GitHub 검색, 예: assignee:@me is:open",
"1850fceac8": "{{value0}}/{{value1}}이(가) Orca에 추가되어 있지 않습니다. 작업을 시작하려면 추가하거나 GitHub에서 여세요.",
"1aa7c952b9": "프로젝트 보기",
"f352abf7c3": "Repository list is updating.",
"1ce21b8cff": "This item is outside the selected repositories.",
"030de75bc5": "This item matches multiple selected repositories."
"f352abf7c3": "리포지토리 목록을 업데이트 중입니다.",
"1ce21b8cff": "이 항목은 선택한 리포지토리 밖에 있습니다.",
"030de75bc5": "이 항목은 선택한 여러 리포지토리와 일치합니다."
},
"slug": {
"dialog": {
@@ -1892,7 +1910,7 @@
"463d030ae4": "삭제",
"8564f58542": "편집",
"5f104bf855": "아직 댓글이 없습니다.",
"commentTooLarge": "Comment is too large to submit safely."
"commentTooLarge": "댓글이 너무 커서 안전하게 제출할 수 없습니다."
},
"LabelsEditor": {
"34dd57d6c8": "로드 중…",
@@ -1921,7 +1939,7 @@
"8f1c2d4e6a": "미리 볼 내용이 없습니다",
"c91f0a2b14": "작성",
"d82b1e3f05": "미리보기",
"imageUrlTooLarge": "Image URL is too large."
"imageUrlTooLarge": "이미지 URL이 너무 큽니다."
},
"IssueSourceSelector": {
"d6aeb2012b": "다음에서 이슈 표시",
@@ -2158,7 +2176,7 @@
"WorkspaceSpacePage": {
"8d0048e1cb": "워크스페이스 디스크 사용량과 회수 가능한 워크트리 저장 공간.",
"e8d6ba11ab": "베타",
"45f6302dbc": "Space",
"45f6302dbc": "공간",
"ecf72fdc3b": "뒤로"
}
},
@@ -2272,7 +2290,7 @@
"577a342c7d": "agent에게 이 워크스페이스를 조사하도록 요청하세요.",
"026cfb232a": "프롬프트 명령을 지원하지 않습니다",
"346d409ab2": "agent 선택",
"0adba8fa0c": "Agent",
"0adba8fa0c": "에이전트",
"ec8f081919": "작업",
"ed04233b3e": "빠른 액세스를 위해 terminal 명령이나 agent 프롬프트를 저장하세요.",
"ca414324ee": "명령 텍스트",
@@ -2418,11 +2436,11 @@
"29c031b49a": "{{value0}} 파일{{value1}}을 런타임에 업로드하는 중…",
"0c77693641": "Worktree가 준비되지 않았습니다. 잠시 후에 다시 시도하세요.",
"ce8248b835": "작업 트리 경로를 사용할 수 없습니다.",
"internalTooManyPaths": "Drop contains too many paths for a safe terminal paste.",
"internalPathsTooLarge": "Drop path list is too large for a safe terminal paste.",
"b4cf68e889": "Skipped {{value0}} {{value1}}.",
"writeTimeout": "File drop cancelled: terminal did not accept the path before the safety timeout.",
"writeRejected": "File drop cancelled: terminal could not accept the path."
"internalTooManyPaths": "터미널에 안전하게 붙여넣기에는 드롭된 경로가 너무 많습니다.",
"internalPathsTooLarge": "터미널에 안전하게 붙여넣기에는 경로 목록이 너무 큽니다.",
"b4cf68e889": "{{value0}} {{value1}}을(를) 건너뛰었습니다.",
"writeTimeout": "파일 드롭이 취소되었습니다. 안전 시간 제한 전에 터미널이 경로를 받지 않았습니다.",
"writeRejected": "파일 드롭이 취소되었습니다. 터미널이 경로를 받을 수 없습니다."
}
}
},
@@ -2466,7 +2484,7 @@
"9acaf92093": "창 작업",
"1bce81dba6": "시뮬레이터",
"1ff1c77616": "브라우저",
"586d2ac445": "terminal",
"586d2ac445": "터미널",
"addSplitPane": "분할 창 추가",
"closePaneColumn": "분할 창 닫기"
},
@@ -2614,19 +2632,19 @@
"5a9c83c04b": "모든 파일, URL, agent를 엽니다...",
"90eb94dc48": "http:// 또는 https:// URL을 입력하세요.",
"5553b283ce": "URL 또는 파일 경로를 입력하세요.",
"queryTooLarge": "Search text is too large."
"queryTooLarge": "검색 텍스트가 너무 큽니다."
}
},
"menu": {
"options": {
"5501c2fb7a": "terminal",
"5501c2fb7a": "터미널",
"9630dd5494": "shell",
"a094576900": "새 terminal",
"4f23f4d01d": "새 shell",
"4f2a91e15b": "browser",
"4f2a91e15b": "브라우저",
"6d0e6a4b7a": "새 브라우저",
"c87ad57785": "브라우저 탭",
"cce7ef1d2c": "web",
"cce7ef1d2c": "웹",
"5f17fb9d0c": "markdown",
"44caaf7b36": "md",
"fb50e3d874": "새 Markdown",
@@ -2635,11 +2653,11 @@
"37ff3ddca1": "Markdown 열기",
"164c394bab": "파일 열기",
"bbaf4f85a4": "모바일 에뮬레이터",
"3784b83bd4": "emulator",
"3784b83bd4": "에뮬레이터",
"a63847a742": "시뮬레이터",
"1baeb07c17": "iOS 시뮬레이터",
"8a580f88cf": "iphone",
"7ecdc5ef08": "ipad",
"8a580f88cf": "iPhone",
"7ecdc5ef08": "iPad",
"14965cc123": "모바일"
}
}
@@ -2847,7 +2865,7 @@
"f4d2651498": "스캔됨",
"6a5dc3c61a": "리뷰",
"c361440dc0": "베타",
"8ff597593d": "Space",
"8ff597593d": "공간",
"0582df6d2e": "스캔",
"f5e1a84d79": "새로고침",
"2af2174d6d": "취소",
@@ -2898,8 +2916,8 @@
"b9b4a3a25d": "브랜치",
"c432278ec7": "편집기 버퍼",
"0bc756efaf": "Git 변경사항",
"e9528a89b3": "Terminals",
"a8d9e0de79": "Agents",
"e9528a89b3": "터미널",
"a8d9e0de79": "에이전트",
"d384a4ce9f": "삭제 판단",
"7d7745bb8f": "삭제할 수 있음",
"720870a18e": "유지: 연결됨",
@@ -2966,20 +2984,20 @@
"8418ec448d": "{{value0}} 사용량을 새로 고칠 수 없습니다. Agent 세션은 여전히 로그인되어 있을 수 있습니다.",
"45198c7d95": "rate-limit 재설정 1회 사용 가능",
"bce421cba3": "rate-limit 재설정 {{value0}}회 사용 가능",
"7ec6e030a0": "Next expires now",
"d1e442a9e5": "Expires now",
"6cf9eaed10": "Next expires in {{value0}}",
"20ad66aed1": "Expires in {{value0}}",
"0d8d7cfe15": "Waiting for Claude session",
"1804cd8c3f": "Refreshing sign-in",
"f8f0f9d8cc": "Network issue",
"bf2e739f18": "Sign-in unavailable",
"f8b8dbed85": "Usage unavailable",
"3d3c9c0c1f": "Claude usage will refresh after the live Claude terminal rotates its credentials.",
"42fdd4da1d": "Claude sign-in is being refreshed. Agent sessions may still be signed in.",
"c06c1d215d": "Claude usage could not be refreshed because the network request failed.",
"cabdc2a9e0": "Claude sign-in credentials could not be read.",
"a7517cccb6": "Claude usage is unavailable right now."
"7ec6e030a0": "다음 항목이 지금 만료됨",
"d1e442a9e5": "지금 만료됨",
"6cf9eaed10": "다음 항목이 {{value0}} 후 만료됨",
"20ad66aed1": "{{value0}} 후 만료됨",
"0d8d7cfe15": "Claude 세션 대기 중",
"1804cd8c3f": "로그인 새로 고치는 중",
"f8f0f9d8cc": "네트워크 문제",
"bf2e739f18": "로그인 사용할 수 없음",
"f8b8dbed85": "사용량 사용할 수 없음",
"3d3c9c0c1f": "실시간 Claude 터미널이 자격 증명을 교체한 후 Claude 사용량이 새로 고쳐집니다.",
"42fdd4da1d": "Claude 로그인을 새로 고치는 중입니다. 에이전트 세션은 여전히 로그인되어 있을 수 있습니다.",
"c06c1d215d": "네트워크 요청 실패로 Claude 사용량을 새로 고칠 수 없습니다.",
"cabdc2a9e0": "Claude 로그인 자격 증명을 읽을 수 없습니다.",
"a7517cccb6": "현재 Claude 사용량을 사용할 수 없습니다."
},
"SshTargetStatusRow": {
"sshHost": "SSH 호스트"
@@ -3215,7 +3233,7 @@
"8efeae0b22": "추적",
"5acbe1fdf2": "시간",
"ef8bbf7739": "PR",
"ce8533f02e": "agents",
"ce8533f02e": "에이전트",
"0bba8ca244": "통계",
"0e2a0b6431": "사용량",
"372debfac0": "통계",
@@ -3265,11 +3283,11 @@
"21ea00bfa8": "캐시",
"e0b988599d": "합계",
"01476891c7": "마지막 활동",
"c17bed0416": "Project",
"f6a2c8d019": "Model",
"faf3444859": "Input",
"a8b7487ff7": "Output",
"cfe2282ffa": "Unknown"
"c17bed0416": "프로젝트",
"f6a2c8d019": "모델",
"faf3444859": "입력",
"a8b7487ff7": "출력",
"cfe2282ffa": "알 수 없음"
}
},
"sparse": {
@@ -3551,7 +3569,7 @@
"51001182e3": "빈 디렉토리",
"971d85cc84": "이 호스트에서 프로젝트로 열립니다 · {{value0}}",
"00c4235c10": "'{{value0}}'과(와) 일치하는 항목이 없습니다.",
"largeInputNoMatches": "No matches for this long input"
"largeInputNoMatches": "이 긴 입력과 일치하는 항목 없음"
},
"RemoveFolderDialog": {
"4dc5b5065b": "제거",
@@ -3645,7 +3663,7 @@
"0c3395fd32": "작업 트리 및 브라우저 탭 검색",
"c86d83b5c3": "새로 만들기",
"1b5c41caee": "Orca 모바일",
"9c95e1ce91": "Agents",
"9c95e1ce91": "에이전트",
"f323383e9a": "자동화",
"e7ad3c540d": "Jira 작업 열기",
"c39ab10000": "Linear 작업 열기",
@@ -3748,7 +3766,7 @@
"65a9820bd1": "Agent 상태",
"219ebf1961": "브랜치 이름",
"automation": "자동화",
"folderPathIdentity": "Branch / folder path"
"folderPathIdentity": "브랜치 / 폴더 경로"
},
"SshDisconnectedDialog": {
"ca4a7892af": "연결 중...",
@@ -3822,18 +3840,18 @@
"0d224eff10": "기본 작업 트리",
"ca74db7550": "SSH를 통한 원격 프로젝트",
"021538e1d1": "SSH 연결이 끊어졌습니다.",
"runtimeHostDisconnected": "Server disconnected",
"runtimeHostProject": "Project on Orca server",
"automationCreated": "Created by automation",
"branchIdentity": "Branch",
"branchFolderPathIdentity": "Branch or folder path"
"runtimeHostDisconnected": "서버 연결 끊김",
"runtimeHostProject": "Orca 서버의 프로젝트",
"automationCreated": "자동화로 생성됨",
"branchIdentity": "브랜치",
"branchFolderPathIdentity": "브랜치 또는 폴더 경로"
},
"WorktreeCardAgents": {
"1b0a156717": "Agents"
"1b0a156717": "에이전트"
},
"WorktreeCardReviewDetailSection": {
"reviewHeader": "{{value0}} #{{value1}}",
"copyLink": "Copy link"
"copyLink": "링크 복사"
},
"WorktreeCardMeta": {
"3e65e11cc6": "워크스페이스 메타데이터",
@@ -3861,12 +3879,12 @@
"automationMissing": "자동화를 더 이상 사용할 수 없습니다.",
"automationRunMissing": "실행 기록을 더 이상 사용할 수 없습니다.",
"automationAvailabilityUnavailable": "자동화 사용 가능 여부를 확인할 수 없습니다.",
"moreIssueActions": "More issue actions",
"copyLink": "Copy link",
"copyLinkSuccess": "{{value0}} copied",
"copyLinkFailure": "Failed to copy link",
"issueLinkLabel": "Issue link",
"reviewLinkLabel": "{{value0}} link"
"moreIssueActions": "추가 이슈 작업",
"copyLink": "링크 복사",
"copyLinkSuccess": "{{value0}} 복사됨",
"copyLinkFailure": "링크 복사 실패",
"issueLinkLabel": "이슈 링크",
"reviewLinkLabel": "{{value0}} 링크"
},
"WorktreeCardMetadataStatusBadges": {
"fe188062a1": "상태: 열림",
@@ -3943,7 +3961,7 @@
"045a8aed48": "하위",
"2ca6e29a3c": "repo",
"bb85cd86ba": "{{value0}}에 대한 워크스페이스 만들기",
"ebadb7eadb": "{{value0}} {{value1}} child {{value2}}",
"ebadb7eadb": "{{value0}} {{value1}} 하위 {{value2}}",
"20bebf9c7f": "{{value0}}개 하위 워크스페이스 표시",
"c1f4a31623": "{{value0}}개 하위 워크스페이스 표시",
"e97297cb75": "{{value0}}개 하위 워크스페이스 숨기기",
@@ -3955,9 +3973,9 @@
"groupDeleteFailedDesc": "그룹을 삭제하는 중 문제가 발생했습니다. 제거된 프로젝트는 없습니다.",
"7a8b9c0d1e": "업데이트 필요",
"hostAuthNeeded": "인증 필요",
"hostDisconnected": "Disconnected",
"hostDisconnected": "연결 끊김",
"failedNestWorkspace": "워크스페이스를 중첩하지 못했습니다",
"sidebarRowMissing": "Target no longer exists"
"sidebarRowMissing": "대상이 더 이상 존재하지 않습니다"
},
"WorktreeMetaDialog": {
"3db0a2a593": "취소",
@@ -4307,7 +4325,7 @@
"empty": "일치하는 적격 워크트리가 없습니다."
},
"WorktreeCardStatusSlot": {
"branchIdentity": "Branch"
"branchIdentity": "브랜치"
}
},
"shared": {
@@ -4444,23 +4462,23 @@
"f97b986b7f": "wsl"
},
"AgentSkillSetupPanel": {
"0b810ec59f": "Press Enter to run the command.",
"0b810ec59f": "Enter를 눌러 명령을 실행하세요.",
"ed197f59a2": "명령 복사",
"817d3f9f18": "Copy command",
"817d3f9f18": "명령 복사",
"5289300939": "설치되지 않음",
"9fcebceb2a": "설치됨",
"68a468752e": "확인 중...",
"c689392435": "재확인",
"a31e2aa302": "Failed to copy command.",
"378ad26865": "Copied command.",
"copiedCommand": "Copied command.",
"failedToCopyCommand": "Failed to copy command.",
"copyCommandAria": "Copy command",
"runCommandDescription": "Press Enter to run the command."
"a31e2aa302": "명령 복사 실패.",
"378ad26865": "명령 복사됨.",
"copiedCommand": "명령 복사됨.",
"failedToCopyCommand": "명령 복사 실패.",
"copyCommandAria": "명령 복사",
"runCommandDescription": "Enter를 눌러 명령을 실행하세요."
},
"AgentsPane": {
"d83834f5e6": "설치된 agents 감지 중…",
"024bd95089": "agents",
"024bd95089": "에이전트",
"e8da2af684": "설치 가능",
"ed3e110e61": "감지됨",
"02e0143be5": "설치됨",
@@ -4488,17 +4506,17 @@
"24e032fa34": "기본",
"5f986a9b92": "기본값으로 설정",
"d7625cf8b2": "기본 agent",
"cfb3f35775": "Arguments",
"cfb3f35775": "인수",
"6f99bf5dd0": "기본 인수 없음",
"8fbe1f37c1": "Environment",
"8fbe1f37c1": "환경",
"2d133152fa": "기본 환경 없음",
"agentPermissions": "Agent 권한",
"agentPermissionsInfo": "Agent 권한 정보",
"agentPermissionsTooltip": "Custom agent arguments stay unchanged when switching modes.",
"agentPermissionsDescription": "Orca가 agents를 실행할 때 권한 프롬프트를 줄일지, 수동 확인을 유지할지 선택합니다.",
"agentPermissionsYolo": "Yolo",
"agentPermissionsManual": "Manual",
"3f1bdf3cb4": "Environment text is too large to parse safely."
"agentPermissionsManual": "수동",
"3f1bdf3cb4": "환경 텍스트가 너무 커서 안전하게 파싱할 수 없습니다."
},
"AppIconSelector": {
"d5a112dc9b": "다음 아이콘",
@@ -4644,7 +4662,7 @@
"c0f85056d9": "이 Orca 서버의 브라우저 프로필입니다.",
"86b7c83fee": "이 컴퓨터",
"6480776a03": "선택한 호스트의 브라우저 프로필입니다.",
"5e19a692f7": "Host"
"5e19a692f7": "호스트"
},
"BrowserProfileRow": {
"8e636cae25": "프로필 '{{value0}}'이(가) 삭제되었습니다.",
@@ -4926,7 +4944,11 @@
"70bb30feb1": "잠시 후에 편집기 및 편집 가능한 diff 변경 사항을 자동으로 저장합니다.",
"0df2e4fd12": "자동 저장 파일",
"d21136d9ef": "Orca가 파일 편집을 유지하는 방법을 구성합니다.",
"45c6e85c4d": "편집기"
"45c6e85c4d": "편집기",
"8f1afdfbd8": "Diff Word Wrap",
"4aa4d9fb73": "Wrap long lines in diff editors instead of requiring horizontal scrolling.",
"bf16ef0af2": "Off",
"3f6892f307": "On"
},
"AdvancedNetworkSettingsSection": {
"3e431564b5": "로컬호스트, 127.0.0.1, *.internal",
@@ -5456,7 +5478,7 @@
"3edf3deaf8": "'{{value0}}'을 삭제하시겠습니까?",
"9fcfc29519": "삽입",
"9b3e338d62": "입력",
"4ccc63da87": "Agent",
"4ccc63da87": "에이전트",
"0252ddd578": "명령 텍스트 없음",
"7784912ed6": "repo",
"2bb9e38e93": "제목 없음",
@@ -5608,7 +5630,7 @@
"hostSetupStateError": "오류",
"hostSetupStateUnsupported": "지원되지 않음",
"setupPathPending": "경로 대기 중",
"openSetup": "Open",
"openSetup": "열기",
"removeSetup": "제거",
"hostSetupBlockedVersion": "Orca 서버 버전이 호환되지 않습니다.",
"hostSetupMissingCapability": "프로젝트를 설정하려면 이 호스트의 Orca를 업데이트하세요.",
@@ -5634,7 +5656,7 @@
"addProjectHost": "프로젝트를 호스트에 추가",
"addProjectHostHelp": "이 프로젝트를 추가로 사용할 위치를 선택하세요.",
"closeHostSetup": "닫기",
"setupHostLabel": "Host",
"setupHostLabel": "호스트",
"browseFolder": "폴더 찾아보기",
"browseFolderHelp": "이 호스트의 기존 체크아웃 또는 폴더를 사용합니다.",
"otherWaysToAdd": "다른 추가 방법",
@@ -5653,7 +5675,7 @@
"7a3a8e431d": "CLI 인수",
"2b2f38652b": "사용자 정의 명령",
"0ffb081b3a": "기본 agent 사용",
"f4310cf63f": "Agent",
"f4310cf63f": "에이전트",
"1cd88d470a": "사용자 정의",
"403876bb48": "글로벌 사용",
"f0aa2cfaea": "액션 레시피"
@@ -5848,7 +5870,7 @@
"6742c7932c": "전역 또는 프로젝트별로 적용되는 저장된 terminal 명령입니다.",
"13d4fe30ad": "빠른 명령어",
"b79b5b31e9": "셸, 렌더러, 세션, terminal 동작.",
"3de4bbb841": "Terminal",
"3de4bbb841": "터미널",
"dd72ed437a": "작업 페이지와 사이드바에 표시할 작업 제공자를 선택합니다.",
"11faa2f7dd": "작업 소스",
"cfa34f4465": "브랜치 이름 지정, 기본 참조, 속성 및 Git AI Author.",
@@ -5868,7 +5890,7 @@
"21f09426ea": "선택 사항. Orca는 기존 공급자 로그인과 함께 작동합니다. Orca가 계정 간 전환을 돕도록 원하는 경우에만 계정을 추가하세요.",
"ad6c529693": "AI 제공업체 계정",
"ec1ba547f7": "AI agents를 관리하고, 기본값을 설정하고, 명령을 사용자 정의하세요.",
"8afa676615": "Agents",
"8afa676615": "에이전트",
"add3b97ee6": "\"",
"3c88ec55d6": "\"에 대한 설정을 찾을 수 없습니다.",
"c7ad095d96": "설정 로드 중...",
@@ -5900,9 +5922,9 @@
"cb330ef7f8": "{{value0}} 중",
"c822571b2e": "\"{{value0}}\"과(와) 일치",
"3119c012a5": "문자열",
"builtin_themes": "Built-in",
"builtin_themes": "기본 제공",
"imported_from": "{{value0}}에서 가져옴",
"imported_themes": "Imported",
"imported_themes": "가져옴",
"search_terminal_themes": "terminal 테마 검색"
},
"SettingsSidebar": {
@@ -5944,7 +5966,7 @@
"3c0fac059a": "terminal에 키보드 포커스가 있는 동안에도 계속 실행됩니다.",
"25b0004fbf": "Terminal 활성",
"781cb74d22": "terminal 패널에서 실행됩니다.",
"cb02e00202": "Terminal",
"cb02e00202": "터미널",
"d8c988dab4": "~/.orca/keybindings.json"
},
"SourceControlAiActionRecipeDefaults": {
@@ -6390,7 +6412,7 @@
"f9a9cf6928": "음성 받아쓰기를 활성화하려면 마이크 권한이 필요합니다.",
"1eac933202": "macOS 개인 정보 보호 및 보안을 열었습니다. 액세스 권한을 부여한 후 받아쓰기를 다시 활성화하세요.",
"cd9fe37556": "마이크 권한이 부여되었습니다.",
"6fa734ed95": "Delete {{value0}}"
"6fa734ed95": "{{value0}} 삭제"
},
"WorktreeSymlinksSection": {
"1c1e35b219": "{{value0}} 제거",
@@ -6516,7 +6538,7 @@
"cbdd7f3b9e": "설치된 agents가 이 장치에서 감지되는지 아니면 WSL에서 감지되는지 선택합니다.",
"ef804b7337": "Agent 위치",
"01926b9d8c": "AI 코딩 agents, 기본 agents 및 명령 재정의를 구성합니다.",
"bb9ad95777": "Agents",
"bb9ad95777": "에이전트",
"d8f3a8b8a0": "기본값",
"167daeb5e9": "명령",
"be59907510": "재정의",
@@ -6628,7 +6650,7 @@
"cf409b6c4d": "포트",
"cb1cc62cf8": "스페이스",
"90bdc043ea": "디스크",
"96b4fb0064": "terminal",
"96b4fb0064": "터미널",
"4ddbde4999": "CPU",
"4355f18ac6": "메모리",
"9c4d5f0894": "관리자",
@@ -6927,7 +6949,7 @@
"edc49480a1": "패널",
"268e99d957": "강조 표시",
"01567f19ca": "주목",
"9bb3bd5098": "terminal",
"9bb3bd5098": "터미널",
"11877246fc": "terminal 벨 및 agent 완료 이벤트에 대한 지속적인 패널 강조 표시입니다.",
"9e4ddf776d": "Terminal 주의",
"fe5688b761": "사이드바",
@@ -6935,7 +6957,7 @@
"d01b3882ba": "알림",
"244a0ecd3d": "활동",
"92a9357d1f": "agents 보기",
"fa72e71f05": "agents",
"fa72e71f05": "에이전트",
"4d63251595": "agent 완료 및 차단 상태에 대한 스레드 왼쪽 사이드바 피드입니다.",
"ccc5548ac5": "Agents 보기",
"9af7a518db": "캐릭터",
@@ -6949,11 +6971,11 @@
"87d99e634b": "애완 동물",
"agentHibernation": {
"agent": "agent",
"agents": "agents",
"agents": "에이전트",
"description": "설정된 유휴 시간이 지난 백그라운드 agent terminals을 중지하고, 다시 열 때 지원되는 세션을 재개합니다. Agent 최대 절전은 Orca에서 시작한 agent의 시작 옵션을 보존합니다. 수동으로 시작한 agent는 현재 Orca 기본값으로 재개될 수 있습니다.",
"minutes": "분",
"sleep": "절전",
"terminal": "terminal",
"terminal": "터미널",
"title": "Agent 최대 절전"
},
"newWorktreeCardStyle": {
@@ -6980,7 +7002,7 @@
"156ffeee08": "메모",
"884e5e6132": "markdown",
"49db74a92d": "브라우저",
"6410fe83d8": "terminal",
"6410fe83d8": "터미널",
"2b5efa55c9": "글로벌",
"ebeedb2f6a": "빠른 terminal",
"6f183fa1b9": "플로팅 terminal",
@@ -7029,13 +7051,13 @@
"79ff46776e": "앱 업데이트를 확인하고 최신 Orca 버전을 설치하세요.",
"e15af4eb64": "업데이트 확인",
"6382fe9724": "npx",
"baa263d6d8": "agents",
"baa263d6d8": "에이전트",
"bda108e66c": "스킬",
"244e3fb4c8": "agents가 Orca CLI를 사용하도록 Orca 스킬을 설치하세요.",
"2d9f7b42df": "Agent 스킬",
"0a00691c06": "셸 명령",
"dbeb1f348e": "명령",
"88d3df9ce9": "terminal",
"88d3df9ce9": "터미널",
"fb4f338a3d": "경로",
"924a660a78": "CLI",
"ca529079bf": "Orca CLI 명령을 등록하거나 제거합니다.",
@@ -7321,7 +7343,7 @@
"fadcbfdd99": "맞춤",
"ad08035c5f": "핸드폰",
"6cd2bfdb0e": "복원",
"b34ad5b3a7": "terminal",
"b34ad5b3a7": "터미널",
"6db86f445f": "모바일",
"707fc78052": "앱을 닫거나 다른 곳으로 전환한 후 모바일에서 보고 있던 terminals에 어떤 일이 발생하는지 선택하세요.",
"1e711aca11": "모바일 앱을 나갈 때",
@@ -7399,7 +7421,7 @@
"96562a72c6": "집중하는 동안 억제",
"a2ab73b325": "주목",
"ae0487f8fd": "벨",
"c638ae989d": "terminal",
"c638ae989d": "터미널",
"d3f1c48677": "백그라운드 terminal이 벨 문자를 내보낼 때 이를 알립니다.",
"a5edee1d99": "Terminal 벨",
"193e1f107c": "작업",
@@ -7429,7 +7451,7 @@
"eee028ae14": "디스패치",
"9a5ebdca31": "메시징",
"91fc8ab7e5": "코디네이션",
"13ba5c6cbd": "agents",
"13ba5c6cbd": "에이전트",
"d86705ba77": "멀티 agent",
"a7f76b4ca7": "오케스트레이션",
"e05ff36753": "메시징, 작업 DAG, 디스패치 및 결정 게이트를 통해 여러 코딩 agents를 조정합니다.",
@@ -7482,7 +7504,7 @@
"8bf43c2dad": "글로벌",
"a26ecdb77b": "스니펫",
"d07d130849": "단축키",
"0073cf8ce9": "terminal",
"0073cf8ce9": "터미널",
"cfffa6cdb6": "명령",
"fecb031823": "명령",
"236d4cfac8": "빠른",
@@ -7640,7 +7662,7 @@
"0f8cb15582": "agent",
"f1adebbe8c": "셸",
"7f1b38f59a": "TUI",
"7e3fc707aa": "terminal",
"7e3fc707aa": "터미널",
"0ecba9aa5f": "키보드",
"ebd7d81e1d": "단축키가 겹칠 때 Orca와 포커스된 terminal 중 어느 쪽이 우선할지 선택합니다.",
"f052906167": "Terminal의 단축키"
@@ -7761,7 +7783,7 @@
"d4daf4f612": "녹이다",
"88561b3499": "정지됨",
"0a05629060": "복구",
"f66a7cf715": "terminal",
"f66a7cf715": "터미널",
"6892fb1019": "다시 시작",
"cde233f5da": "스크롤백",
"3982d88725": "기록",
@@ -7907,7 +7929,7 @@
"fcfa53920b": "붙여넣기",
"e55186fe2b": "오른쪽 클릭",
"28ff08ed35": "창",
"e7d2793b03": "terminal",
"e7d2793b03": "터미널",
"8ba875c132": "Windows에서는 마우스 오른쪽 버튼을 클릭하여 클립보드를 terminal에 붙여넣습니다. 컨텍스트 메뉴를 열려면 Ctrl+오른쪽 클릭을 사용하세요.",
"f0b8448570": "붙여넣으려면 마우스 오른쪽 버튼을 클릭하세요.",
"04994f6929": "기본값",
@@ -8775,7 +8797,7 @@
"fe119187bb": "--model sonnet",
"bc8dc39f4b": "CLI 인수",
"b99c33cec5": "설정",
"15c5d85706": "Agent",
"15c5d85706": "에이전트",
"3e8f21954f": "오류",
"74168d7ada": "idle",
"1d47db9bf0": "활성화된 agents가 없습니다.",
@@ -8806,7 +8828,7 @@
"4eab815004": "CLI 인수",
"914c8f6ac2": "사용자 정의 명령",
"cce2cbd01d": "agent 선택",
"9c14186dd2": "Agent"
"9c14186dd2": "에이전트"
},
"activity": {
"bar": {
@@ -8900,7 +8922,7 @@
"b8c4e2a1f7": "전체 로그 보기",
"f8a2c91d04": "Queue for agent",
"b4e8a1c902": "Queued",
"7c1f0a2b11": "Open",
"7c1f0a2b11": "열기",
"e8b4c1a903": "Resolved · {{value0}}",
"c3a8e5d710": "Needs review · {{value0}}",
"a7f0c7e8d1": "Queue",
@@ -8969,7 +8991,7 @@
"6306b48afd": "소스 제어",
"ef182dcb12": "검색",
"fc3095d2ed": "탐색기",
"aiVaultSessionHistory": "Agents",
"aiVaultSessionHistory": "에이전트",
"folderWorkspaces": "연결된 워크트리",
"parentPrChecks": "PR 검사"
},
@@ -8984,7 +9006,7 @@
"542bf6a7e2": "기울임",
"256300f8ea": "굵게",
"87aff03d63": "전송 중...",
"commentTooLarge": "Comment is too large to submit safely."
"commentTooLarge": "댓글이 너무 커서 안전하게 제출할 수 없습니다."
}
}
}
@@ -9147,10 +9169,10 @@
"noSessionsMatchFilters": "현재 필터와 일치하는 세션이 없습니다",
"sessionId": "세션 ID",
"logPath": "로그 경로",
"agents": "Agents",
"agents": "에이전트",
"sessionsShownCompact": "{{value0}} 표시됨",
"originalPaneUnavailable": "Original pane is no longer available.",
"worktreeUnavailable": "Worktree is no longer available."
"originalPaneUnavailable": "원래 창은 더 이상 사용할 수 없습니다.",
"worktreeUnavailable": "워크트리는 더 이상 사용할 수 없습니다."
},
"AiVaultPanelControls": {
"scanningSessions": "세션 스캔 중",
@@ -9165,13 +9187,13 @@
"allSessions": "모든 세션",
"viewOptionsAriaLabel": "세션 기록 보기 옵션",
"viewOptions": "보기 옵션",
"agents": "Agents",
"agents": "에이전트",
"sort": "정렬",
"lastUpdated": "마지막 업데이트",
"created": "생성됨",
"group": "그룹",
"folder": "폴더",
"agent": "Agent",
"agent": "에이전트",
"resetView": "보기 재설정",
"hideEmptySessions": "빈 세션 숨기기",
"workspaceScope": "워크스페이스",
@@ -9353,8 +9375,8 @@
"3c5a593bc8": "웹",
"477b28c948": "데이터베이스",
"787490e9bd": "패키지",
"07012dc113": "Agent",
"3eba7387ab": "Terminal",
"07012dc113": "에이전트",
"3eba7387ab": "터미널",
"65b437c381": "코드",
"bed2674f9d": "폴더"
}
@@ -9828,7 +9850,7 @@
"8b14ba6c17": "새 브라우저 탭",
"b085fb58b5": "이 파일에는 저장되지 않은 변경사항이 있습니다.",
"5ddc688c52": "'{{value0}}'에 저장되지 않은 변경사항이 있습니다. 닫기 전에 저장하시겠습니까?",
"25d7817f79": "terminal"
"25d7817f79": "터미널"
},
"FloatingTerminalToggleButton": {
"3b04b065b5": "플로팅 워크스페이스 표시",
@@ -9871,7 +9893,7 @@
"be8917699e": "모델",
"4d9b6d84df": "지원되지 않습니다. Claude, Codex 또는 Custom을 선택하세요.",
"560d4feb00": "Custom",
"29d119fe95": "Agent",
"29d119fe95": "에이전트",
"f9382b48a1": "AI author 활성화",
"1c0cb4fabb": "AI 작성자",
"bd14e9c42a": "구성되지 않음",
@@ -10243,7 +10265,7 @@
"step": {
"0f47ff17c6": "변경",
"5538eb6743": "완료",
"open_step": "Open",
"open_step": "열기",
"close_step": "닫기"
}
}
@@ -10485,7 +10507,9 @@
"8368d256ec": "결합 브랜치",
"8f68ad9ca9": "{{value0}} AI {{value1}} 표시",
"724a13568d": "{{value0}}에서",
"6094135eec": "대 {{value0}}"
"6094135eec": "대 {{value0}}",
"a4420ca1f7": "Wrap On",
"dde325ddfe": "Wrap Off"
},
"ConflictComponents": {
"f338288514": "충돌 콘텐츠 로드 중...",
@@ -10573,7 +10597,8 @@
"3e0ce48c24": "PDF로 내보내기",
"561251019a": "추가 작업",
"8c8b7f5ff5": "머리말 표시",
"10c39d58c1": "머리말 숨기기"
"10c39d58c1": "머리말 숨기기",
"1eef809708": "Word Wrap"
},
"EditorPanelShell": {
"e2c4dec350": "편집기 로드 중..."
@@ -10682,7 +10707,7 @@
"MonacoEditor": {
"68cb83f4a7": "선택한 텍스트에 메모 추가",
"fd68ae03b3": "파일에서 검색",
"largePasteTooLarge": "Paste is too large."
"largePasteTooLarge": "붙여넣기 내용이 너무 큽니다."
},
"MonacoGutterContextMenu": {
"7b57b1b468": "원격 URL 복사",
@@ -10965,7 +10990,7 @@
}
},
"richMarkdownLargeTextPaste": {
"tooLarge": "Paste is too large."
"tooLarge": "붙여넣기 내용이 너무 큽니다."
}
},
"diff": {
@@ -10983,7 +11008,7 @@
"2b3ce6d394": "취소",
"e05063cfc1": "라인 {{value0}}",
"c845170b3b": "라인 {{value0}}-{{value1}}",
"commentTooLarge": "Comment is too large to submit safely."
"commentTooLarge": "댓글이 너무 커서 안전하게 제출할 수 없습니다."
},
"useDiffCommentDecorator": {
"995fa28b50": "이 메모"
@@ -11112,8 +11137,8 @@
"palette": {
"project": {
"results": {
"repoGroup": "Repo group",
"project": "Project"
"repoGroup": "리포지토리 그룹",
"project": "프로젝트"
}
}
}
@@ -11229,7 +11254,7 @@
"5c3d530a68": "Downloaded",
"4bb7424d6b": "Canceled",
"6e776f9ef9": "Download failed",
"756bfc25c9": "Open",
"756bfc25c9": "열기",
"09a9489aa5": "Show"
},
"BrowserToolbarMenu": {
@@ -11297,7 +11322,7 @@
"449fc83bf7": "토큰",
"401f40ae79": "예상 경비",
"a7c312430d": "마지막 실행",
"2df8970cd5": "Agent",
"2df8970cd5": "에이전트",
"e353ab9516": "사전 확인",
"620b22145e": "유예 시간",
"15ea446b93": "세션",
@@ -11320,7 +11345,7 @@
},
"AutomationEditorDialog": {
"fb1896a5e7": "취소",
"57b722cbba": "Agent",
"57b722cbba": "에이전트",
"6ff66f9012": "새로운 실행",
"a2e688226d": "워크트리",
"6f9610e667": "작업 트리는 선택한 워크스페이스에서 실행됩니다. 새로 실행하면 매번 선택한 브랜치에서 새 워크스페이스가 생성됩니다.",
@@ -11596,7 +11621,7 @@
},
"AgentSettingsDialog": {
"50cdb57c03": "AI agents를 관리하고, 기본값을 설정하고, 명령을 사용자 정의하세요.",
"fc0268e4ed": "Agents"
"fc0268e4ed": "에이전트"
}
},
"activity": {
@@ -11614,7 +11639,7 @@
"a472a14700": "추가 옵션",
"db8a1878b5": "스레드 목록 옵션",
"d1a88df9a8": "읽지 않은 스레드만 표시",
"f6396e1f85": "Agent",
"f6396e1f85": "에이전트",
"b29191b3e0": "워크트리",
"8c3b621ddf": "프로젝트",
"4a3986b200": "상태",
@@ -11629,7 +11654,7 @@
},
"ActivityTitlebarControls": {
"f915168c8e": "읽지 않음",
"d6a8de3934": "agents",
"d6a8de3934": "에이전트",
"dc708f3eff": "agents 닫기"
}
},
File diff suppressed because it is too large Load Diff
@@ -53,7 +53,9 @@ function buildSurfaceVariables(args: {
}): LeftSidebarStyleVariables {
const { background, foreground, overrideTextTokens = false } = args
const accent = `color-mix(in srgb, ${foreground} 9%, ${background})`
const border = `color-mix(in srgb, ${foreground} 14%, ${background})`
// 7% keeps the sidebar divider at the same prominence as the global --border
// (7% in dark mode) so it reads like the rest of the UI; 14% rendered brighter (#5906).
const border = `color-mix(in srgb, ${foreground} 7%, ${background})`
const ring = `color-mix(in srgb, ${foreground} 44%, ${background})`
const vars: LeftSidebarStyleVariables = {
'--worktree-sidebar': background,
@@ -81,7 +83,8 @@ function buildSurfaceVariables(args: {
vars['--accent-foreground'] = foreground
vars['--muted'] = `color-mix(in srgb, ${foreground} 7%, ${background})`
vars['--muted-foreground'] = `color-mix(in srgb, ${foreground} 62%, ${background})`
vars['--border'] = `color-mix(in srgb, ${foreground} 14%, ${background})`
// Match the global --border (7%) so sidebar-scoped dividers aren't brighter (#5906).
vars['--border'] = `color-mix(in srgb, ${foreground} 7%, ${background})`
}
return vars
}
@@ -0,0 +1,131 @@
import { FitAddon } from '@xterm/addon-fit'
import { SearchAddon } from '@xterm/addon-search'
import { SerializeAddon } from '@xterm/addon-serialize'
import { Unicode11Addon } from '@xterm/addon-unicode11'
import { WebLinksAddon } from '@xterm/addon-web-links'
import { Terminal } from '@xterm/xterm'
import type { ITerminalOptions } from '@xterm/xterm'
import type { TerminalLeafId } from '../../../../shared/stable-pane-id'
import type { DragReorderCallbacks, DragReorderState } from './pane-drag-reorder'
import { attachPaneDrag } from './pane-drag-pointer'
import type { ManagedPaneInternal, PaneManagerOptions } from './pane-manager-types'
import { buildDefaultTerminalOptions } from './pane-terminal-options'
import { shouldFocusTerminalFromPanePointerDown } from './pane-pointer-focus'
import { ENABLE_WEBGL_RENDERER } from './pane-webgl-renderer'
function getTerminalUrlOpenHint(): string {
return navigator.userAgent.includes('Mac')
? 'click to open or ⇧+click for system browser'
: 'click to open or Shift+click for system browser'
}
export function createPaneDOM(
id: number,
leafId: TerminalLeafId,
options: PaneManagerOptions,
dragState: DragReorderState,
dragCallbacks: DragReorderCallbacks,
onPointerDown: (id: number, options?: { focusTerminal?: boolean }) => void,
onMouseEnter: (id: number, event: MouseEvent) => void
): ManagedPaneInternal {
const container = document.createElement('div')
container.className = 'pane'
container.dataset.paneId = String(id)
container.dataset.leafId = leafId
// Why: CSS owns baseline xterm geometry so title offsets do not race safeFit().
const xtermContainer = document.createElement('div')
xtermContainer.className = 'xterm-container'
container.appendChild(xtermContainer)
const userOpts = options.terminalOptions?.(id) ?? {}
const terminalOpts: ITerminalOptions = {
...buildDefaultTerminalOptions(),
...userOpts
}
const terminal = new Terminal(terminalOpts)
const fitAddon = new FitAddon()
const searchAddon = new SearchAddon()
const unicode11Addon = new Unicode11Addon()
const openLinkHint = getTerminalUrlOpenHint()
const linkTooltip = document.createElement('div')
linkTooltip.className = 'pane-link-tooltip'
linkTooltip.classList.add('xterm-hover')
linkTooltip.style.cssText =
'display:none;position:absolute;bottom:4px;left:8px;z-index:40;' +
'padding:5px 8px;border-radius:4px;font-size:11px;font-family:inherit;' +
'color:#a1a1aa;background:rgba(24,24,27,0.85);border:1px solid rgba(63,63,70,0.6);' +
'pointer-events:none;max-width:80%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'
const dragHandle = document.createElement('div')
dragHandle.className = 'pane-drag-handle'
container.appendChild(dragHandle)
const paneDragCleanup = attachPaneDrag(dragHandle, id, dragState, dragCallbacks)
const webLinksAddon = new WebLinksAddon(
options.onLinkClick ? (event, uri) => options.onLinkClick!(event, uri) : undefined,
{
hover: (_event, uri) => {
if (uri) {
linkTooltip.textContent = `${uri} (${openLinkHint})`
linkTooltip.style.display = ''
}
},
leave: () => {
linkTooltip.style.display = 'none'
}
}
)
const panePointerDownHandler = (event: PointerEvent): void => {
onPointerDown(id, {
focusTerminal: shouldFocusTerminalFromPanePointerDown(event.target)
})
}
const paneMouseEnterHandler = (event: MouseEvent): void => onMouseEnter(id, event)
const pane: ManagedPaneInternal = {
id,
leafId,
stablePaneId: leafId,
terminal,
container,
xtermContainer,
linkTooltip,
terminalTuiScrollSensitivity: options.terminalTuiScrollSensitivity,
terminalGpuAcceleration: options.terminalGpuAcceleration ?? 'auto',
gpuRenderingEnabled: ENABLE_WEBGL_RENDERER,
webglAttachmentDeferred: false,
webglDisabledAfterContextLoss: false,
hasComplexScriptOutput: false,
fitAddon,
fitResizeObserver: null,
pendingInitialFitRafId: null,
pendingWebglRefreshRafId: null,
pendingObservedFitRafId: null,
searchAddon,
serializeAddon: new SerializeAddon(),
unicode11Addon,
webLinksAddon,
webglAddon: null,
ligaturesAddon: null,
panePointerDownHandler,
paneMouseEnterHandler,
paneDragCleanup,
compositionHandler: null,
focusClassSyncCleanup: null,
terminalScrollIntentDisposable: null,
pendingSplitScrollState: null,
pendingSplitScrollRafIds: [],
pendingSplitScrollTimerId: null,
pendingSplitScrollBufferDisposable: null,
debugLabel: options.debugLabel ?? null
}
container.addEventListener('pointerdown', panePointerDownHandler)
container.addEventListener('mouseenter', paneMouseEnterHandler)
return pane
}
@@ -1,179 +1,28 @@
import { Terminal } from '@xterm/xterm'
import type { ITerminalOptions } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
// Upstream packaging bug: @xterm/addon-ligatures declares `"main":
// "lib/addon-ligatures.js"` but ships only the `.mjs` entry, so Vite fails to
// resolve the bare import. Fixed locally via config/patches/@xterm__addon-ligatures*.
// Tracking upstream: https://github.com/xtermjs/xterm.js/issues/5822 and
// https://github.com/xtermjs/xterm.js/pull/5828 — drop the patch once that lands.
import { LigaturesAddon } from '@xterm/addon-ligatures'
import { SearchAddon } from '@xterm/addon-search'
import { Unicode11Addon } from '@xterm/addon-unicode11'
import { WebLinksAddon } from '@xterm/addon-web-links'
import { SerializeAddon } from '@xterm/addon-serialize'
import type { PaneManagerOptions, ManagedPaneInternal } from './pane-manager-types'
import type { TerminalLeafId } from '../../../../shared/stable-pane-id'
import type { DragReorderState } from './pane-drag-reorder'
import type { DragReorderCallbacks } from './pane-drag-reorder'
import { attachPaneDrag } from './pane-drag-pointer'
import type { ManagedPaneInternal } from './pane-manager-types'
import { safeFit } from './pane-tree-ops'
import {
attachPaneFitResizeObserver,
detachPaneFitResizeObserver
} from './pane-fit-resize-observer'
import { clearPendingSplitScrollRestore } from './pane-split-scroll'
import { buildDefaultTerminalOptions } from './pane-terminal-options'
import { activateOrcaTerminalUnicodeProvider } from './pane-terminal-unicode-provider'
import { attachTerminalMouseWheelMultiplier } from './pane-terminal-mouse-wheel'
import { attachTerminalScrollIntentTracking } from './terminal-scroll-intent'
import { attachDomRendererFocusClassSync } from './pane-dom-focus-class-sync'
import {
ENABLE_WEBGL_RENDERER,
attachWebgl,
cancelPendingWebglRefresh,
disposeWebgl
} from './pane-webgl-renderer'
import { shouldFocusTerminalFromPanePointerDown } from './pane-pointer-focus'
import { attachWebgl, cancelPendingWebglRefresh, disposeWebgl } from './pane-webgl-renderer'
// ---------------------------------------------------------------------------
// Pane creation, terminal open/close, addon management
// ---------------------------------------------------------------------------
function getTerminalUrlOpenHint(): string {
return navigator.userAgent.includes('Mac')
? 'click to open or ⇧+click for system browser'
: 'click to open or Shift+click for system browser'
}
export function createPaneDOM(
id: number,
leafId: TerminalLeafId,
options: PaneManagerOptions,
dragState: DragReorderState,
dragCallbacks: DragReorderCallbacks,
onPointerDown: (id: number, options?: { focusTerminal?: boolean }) => void,
onMouseEnter: (id: number, event: MouseEvent) => void
): ManagedPaneInternal {
// Create .pane container
const container = document.createElement('div')
container.className = 'pane'
container.dataset.paneId = String(id)
container.dataset.leafId = leafId
// Create .xterm-container — baseline layout (position, width, height, margin)
// is CSS-driven (see main.css .xterm-container) so that the data-has-title
// attribute override can shift the terminal down without racing safeFit().
const xtermContainer = document.createElement('div')
xtermContainer.className = 'xterm-container'
container.appendChild(xtermContainer)
// Build terminal options
const userOpts = options.terminalOptions?.(id) ?? {}
const terminalOpts: ITerminalOptions = {
...buildDefaultTerminalOptions(),
...userOpts
}
const terminal = new Terminal(terminalOpts)
const fitAddon = new FitAddon()
const searchAddon = new SearchAddon()
const unicode11Addon = new Unicode11Addon()
const openLinkHint = getTerminalUrlOpenHint()
// URL tooltip element — Ghostty-style bottom-left hint on hover
const linkTooltip = document.createElement('div')
linkTooltip.className = 'pane-link-tooltip'
linkTooltip.classList.add('xterm-hover')
linkTooltip.style.cssText =
'display:none;position:absolute;bottom:4px;left:8px;z-index:40;' +
'padding:5px 8px;border-radius:4px;font-size:11px;font-family:inherit;' +
'color:#a1a1aa;background:rgba(24,24,27,0.85);border:1px solid rgba(63,63,70,0.6);' +
'pointer-events:none;max-width:80%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'
// Ghostty-style drag handle — appears at top of pane on hover when 2+ panes
const dragHandle = document.createElement('div')
dragHandle.className = 'pane-drag-handle'
container.appendChild(dragHandle)
const paneDragCleanup = attachPaneDrag(dragHandle, id, dragState, dragCallbacks)
const webLinksAddon = new WebLinksAddon(
options.onLinkClick ? (event, uri) => options.onLinkClick!(event, uri) : undefined,
{
hover: (_event, uri) => {
if (uri) {
linkTooltip.textContent = `${uri} (${openLinkHint})`
linkTooltip.style.display = ''
}
},
leave: () => {
linkTooltip.style.display = 'none'
}
}
)
const serializeAddon = new SerializeAddon()
const panePointerDownHandler = (event: PointerEvent): void => {
onPointerDown(id, {
focusTerminal: shouldFocusTerminalFromPanePointerDown(event.target)
})
}
const paneMouseEnterHandler = (event: MouseEvent): void => {
onMouseEnter(id, event)
}
const pane: ManagedPaneInternal = {
id,
leafId,
stablePaneId: leafId,
terminal,
container,
xtermContainer,
linkTooltip,
terminalTuiScrollSensitivity: options.terminalTuiScrollSensitivity,
terminalGpuAcceleration: options.terminalGpuAcceleration ?? 'auto',
gpuRenderingEnabled: ENABLE_WEBGL_RENDERER,
webglAttachmentDeferred: false,
webglDisabledAfterContextLoss: false,
hasComplexScriptOutput: false,
fitAddon,
fitResizeObserver: null,
pendingInitialFitRafId: null,
pendingWebglRefreshRafId: null,
pendingObservedFitRafId: null,
searchAddon,
serializeAddon,
unicode11Addon,
webLinksAddon,
webglAddon: null,
ligaturesAddon: null,
panePointerDownHandler,
paneMouseEnterHandler,
paneDragCleanup,
compositionHandler: null,
focusClassSyncCleanup: null,
pendingSplitScrollState: null,
pendingSplitScrollRafIds: [],
pendingSplitScrollTimerId: null,
pendingSplitScrollBufferDisposable: null,
debugLabel: options.debugLabel ?? null
}
// Focus handler: clicking a pane makes it active and explicitly focuses
// the terminal. We must call focus: true here because after DOM reparenting
// (e.g. splitPane moves the original pane into a flex container), xterm.js's
// native click-to-focus on its internal textarea may not fire reliably.
container.addEventListener('pointerdown', panePointerDownHandler)
// Focus-follows-mouse handler: when the setting is enabled, hovering a
// pane makes it active. All gating (feature flag, drag-in-progress,
// window focus, etc.) lives in the PaneManager callback — this layer
// just forwards the event.
container.addEventListener('mouseenter', paneMouseEnterHandler)
return pane
}
export { createPaneDOM } from './pane-dom-creation'
/** Open terminal into its container and load addons. Must be called after the container is in the DOM. */
export function openTerminal(pane: ManagedPaneInternal): void {
@@ -203,6 +52,11 @@ export function openTerminal(pane: ManagedPaneInternal): void {
attachTerminalMouseWheelMultiplier(terminal, {
getTuiMouseWheelMultiplier: terminalTuiScrollSensitivity
})
pane.terminalScrollIntentDisposable = attachTerminalScrollIntentTracking(
terminal,
xtermContainer,
pane.leafId
)
// Activate Orca's Unicode 11 width shim *before* any caller-driven write. CJK / emoji /
// ZWJ codepoints get baked into the buffer at the active unicode version on
@@ -343,6 +197,8 @@ export function disposePane(
pane.paneDragCleanup = null
pane.focusClassSyncCleanup?.()
pane.focusClassSyncCleanup = null
pane.terminalScrollIntentDisposable?.dispose()
pane.terminalScrollIntentDisposable = null
if (pane.compositionHandler) {
pane.terminal.element?.removeEventListener('compositionstart', pane.compositionHandler, true)
pane.compositionHandler = null
@@ -135,6 +135,8 @@ export type ManagedPaneInternal = {
compositionHandler: (() => void) | null
// Stored so disposePane() can remove DOM-renderer focus synchronization.
focusClassSyncCleanup?: (() => void) | null
// Stored so disposePane() can remove user-scroll intent listeners.
terminalScrollIntentDisposable?: IDisposable | null
// Why: splitPane reparents DOM; its delayed restore owns scroll until the
// browser settles, so intermediate fits must not compete with it.
pendingSplitScrollState: ScrollState | null
@@ -7,6 +7,10 @@ import {
writeForegroundTerminalChunk,
type ForegroundTerminalOutputTarget
} from './pane-terminal-foreground-render-settle'
import {
captureTerminalWriteScrollIntent,
enforceTerminalWriteScrollIntent
} from './terminal-scroll-intent'
type TerminalOutputTarget = ForegroundTerminalOutputTarget
@@ -600,6 +604,38 @@ function hasDrainableBacklog(): boolean {
return false
}
function writeBackgroundTerminalChunk(terminal: TerminalOutputTarget, data: string): void {
const scrollIntent = captureTerminalWriteScrollIntent(terminal)
if (!scrollIntent) {
terminal.write(data)
return
}
if (terminal.write.length < 2) {
terminal.write(data)
enforceTerminalWriteScrollIntent(terminal, scrollIntent)
return
}
terminal.write(data, () => {
enforceTerminalWriteScrollIntent(terminal, scrollIntent)
})
}
function writeForegroundTerminalChunkWithIntent(
terminal: TerminalOutputTarget,
data: string,
options: {
forceViewportRefresh: boolean
followupViewportRefresh: boolean
}
): void {
const scrollIntent = captureTerminalWriteScrollIntent(terminal)
writeForegroundTerminalChunk(terminal, data, {
forceViewportRefresh: options.forceViewportRefresh,
followupViewportRefresh: options.followupViewportRefresh,
onParsed: () => enforceTerminalWriteScrollIntent(terminal, scrollIntent)
})
}
function takeNextDrainableEntry(): QueueEntry | null {
for (const entry of queuedByTerminal.values()) {
if (!isEntryDrainable(entry)) {
@@ -619,7 +655,7 @@ function writeQueuedChunk(entry: QueueEntry): 'foreground' | 'background' | null
try {
entry.beforeWrite?.(queuedWrite.data)
if (queuedWrite.foreground) {
writeForegroundTerminalChunk(
writeForegroundTerminalChunkWithIntent(
entry.terminal,
queuedWrite.stripTransientCursorShows
? removeTransientCursorShowSequences(queuedWrite.data)
@@ -630,7 +666,7 @@ function writeQueuedChunk(entry: QueueEntry): 'foreground' | 'background' | null
}
)
} else {
entry.terminal.write(queuedWrite.data)
writeBackgroundTerminalChunk(entry.terminal, queuedWrite.data)
}
} catch {
// Why: pane.terminal.dispose() can race with a queued late-arriving PTY ping;
@@ -823,12 +859,12 @@ export function writeTerminalOutput(
debugState.foregroundWriteCount++
}
options.beforeWrite?.(data)
writeForegroundTerminalChunk(
writeForegroundTerminalChunkWithIntent(
terminal,
options.stripTransientCursorShows ? removeTransientCursorShowSequences(data) : data,
{
forceViewportRefresh: options.forceForegroundRefresh,
followupViewportRefresh: options.followupForegroundRefresh
forceViewportRefresh: options.forceForegroundRefresh === true,
followupViewportRefresh: options.followupForegroundRefresh === true
}
)
return
@@ -896,7 +932,7 @@ export function flushTerminalOutput(
try {
entry.beforeWrite?.(queuedWrite.data)
if (queuedWrite.foreground) {
writeForegroundTerminalChunk(
writeForegroundTerminalChunkWithIntent(
terminal,
queuedWrite.stripTransientCursorShows
? removeTransientCursorShowSequences(queuedWrite.data)
@@ -907,7 +943,7 @@ export function flushTerminalOutput(
}
)
} else {
terminal.write(queuedWrite.data)
writeBackgroundTerminalChunk(terminal, queuedWrite.data)
}
} catch {
// Why: pane.terminal.dispose() can race with a queued late-arriving PTY ping;
@@ -3,13 +3,15 @@ import type {
DropZone,
ManagedPane,
ManagedPaneInternal,
PaneStyleOptions,
ScrollState
PaneStyleOptions
} from './pane-manager-types'
import { createDivider, disposeDivider } from './pane-divider'
import { getFitOverrideForPty } from './mobile-fit-overrides'
import { disposeWebgl, attachWebgl } from './pane-webgl-renderer'
import { captureScrollState, restoreScrollStateAfterLayout } from './pane-scroll'
import {
captureTerminalWriteScrollIntent,
enforceTerminalWriteScrollIntent
} from './terminal-scroll-intent'
export { captureScrollState, restoreScrollState } from './pane-scroll'
@@ -58,18 +60,18 @@ function canMeasurePaneForFit(pane: ManagedPane): boolean {
return dims.cols >= MIN_PANE_FIT_COLS && dims.rows >= MIN_PANE_FIT_ROWS
}
function captureScrollStateForFit(pane: ManagedPane): ScrollState | null {
function canPreserveScrollIntentForFit(pane: ManagedPane): boolean {
// Why: split reparent has its own delayed restore; restoring here can fight that timer.
return 'pendingSplitScrollState' in pane && (pane as ManagedPaneInternal).pendingSplitScrollState
? null
: captureScrollState(pane.terminal)
return !(
'pendingSplitScrollState' in pane && (pane as ManagedPaneInternal).pendingSplitScrollState
)
}
export function safeFit(pane: ManagedPane): void {
if (!canMeasurePaneForFit(pane)) {
return
}
let scrollState: ScrollState | null = null
let scrollIntent = null as ReturnType<typeof captureTerminalWriteScrollIntent>
let shouldRestoreScroll = false
try {
// Why: when a mobile client has resized this PTY to phone dimensions,
@@ -81,8 +83,10 @@ export function safeFit(pane: ManagedPane): void {
const override = ptyId ? getFitOverrideForPty(ptyId) : null
if (override) {
if (pane.terminal.cols !== override.cols || pane.terminal.rows !== override.rows) {
scrollState = captureScrollStateForFit(pane)
shouldRestoreScroll = true
if (canPreserveScrollIntentForFit(pane)) {
scrollIntent = captureTerminalWriteScrollIntent(pane.terminal)
shouldRestoreScroll = true
}
pane.terminal.resize(override.cols, override.rows)
}
return
@@ -95,15 +99,17 @@ export function safeFit(pane: ManagedPane): void {
// churn, which was causing visible terminal blinking while resizing.
return
}
scrollState = captureScrollStateForFit(pane)
shouldRestoreScroll = true
if (canPreserveScrollIntentForFit(pane)) {
scrollIntent = captureTerminalWriteScrollIntent(pane.terminal)
shouldRestoreScroll = true
}
pane.fitAddon.fit()
} catch {
// Container may not have dimensions yet
} finally {
if (shouldRestoreScroll && scrollState) {
if (shouldRestoreScroll) {
try {
restoreScrollStateAfterLayout(pane.terminal, scrollState)
enforceTerminalWriteScrollIntent(pane.terminal, scrollIntent)
} catch {
// Why: xterm can temporarily expose a terminal whose renderer has not
// initialized dimensions yet during SSH reattach/layout. Fit is best-effort.
@@ -0,0 +1,317 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
attachTerminalScrollIntentTracking,
captureTerminalWriteScrollIntent,
enforceTerminalCurrentScrollIntent,
enforceTerminalWriteScrollIntent,
getTerminalScrollIntentKind,
markTerminalFollowOutput,
markTerminalPinnedViewport,
syncTerminalScrollIntentFromViewport,
syncTerminalScrollIntentSoon
} from './terminal-scroll-intent'
function createTerminal({
viewportY,
baseY,
type = 'normal'
}: {
viewportY: number
baseY: number
type?: 'normal' | 'alternate'
}) {
const terminal = {
buffer: {
active: {
type,
viewportY,
baseY
}
},
scrollToBottom: vi.fn(() => {
terminal.buffer.active.viewportY = terminal.buffer.active.baseY
}),
scrollToLine: vi.fn((line: number) => {
terminal.buffer.active.viewportY = line
})
}
return terminal
}
class TestElement extends EventTarget {
parentElement: TestElement | null = null
readonly classList = {
contains: (className: string): boolean => this.className.split(/\s+/).includes(className)
}
constructor(public className = '') {
super()
}
append(child: TestElement): void {
child.parentElement = this
}
closest(selector: string): TestElement | null {
if (!selector.startsWith('.')) {
return null
}
const className = selector.slice(1)
if (this.classList.contains(className)) {
return this
}
return this.parentElement?.closest(selector) ?? null
}
dispatchEvent(event: Event): boolean {
if (!event.target) {
Object.defineProperty(event, 'target', {
configurable: true,
value: this
})
}
const result = super.dispatchEvent(event)
if (event.bubbles && this.parentElement) {
this.parentElement.dispatchEvent(event)
}
return result
}
}
describe('terminal scroll intent', () => {
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})
it('infers followOutput when the viewport is at the bottom', () => {
const terminal = createTerminal({ viewportY: 100, baseY: 100 })
expect(getTerminalScrollIntentKind(terminal)).toBe('followOutput')
})
it('infers pinnedViewport when the viewport is above the bottom', () => {
const terminal = createTerminal({ viewportY: 42, baseY: 100 })
expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport')
})
it('preserves a pinned viewport after output moves xterm to bottom', () => {
const terminal = createTerminal({ viewportY: 42, baseY: 100 })
markTerminalPinnedViewport(terminal)
const snapshot = captureTerminalWriteScrollIntent(terminal)
terminal.buffer.active.baseY = 125
terminal.buffer.active.viewportY = 125
enforceTerminalWriteScrollIntent(terminal, snapshot)
expect(terminal.scrollToLine).toHaveBeenCalledWith(42)
expect(terminal.buffer.active.viewportY).toBe(42)
expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport')
})
it('follows output after output advances while following', () => {
const terminal = createTerminal({ viewportY: 100, baseY: 100 })
markTerminalFollowOutput(terminal)
const snapshot = captureTerminalWriteScrollIntent(terminal)
terminal.buffer.active.baseY = 125
terminal.buffer.active.viewportY = 0
enforceTerminalWriteScrollIntent(terminal, snapshot)
expect(terminal.scrollToBottom).toHaveBeenCalledTimes(1)
expect(terminal.buffer.active.viewportY).toBe(125)
})
it('does not preserve across buffer type changes', () => {
const terminal = createTerminal({ viewportY: 42, baseY: 100 })
markTerminalPinnedViewport(terminal)
const snapshot = captureTerminalWriteScrollIntent(terminal)
terminal.buffer.active.type = 'alternate'
terminal.buffer.active.viewportY = 0
enforceTerminalWriteScrollIntent(terminal, snapshot)
expect(terminal.scrollToLine).not.toHaveBeenCalled()
expect(terminal.buffer.active.viewportY).toBe(0)
})
it('syncs intent from the current viewport after user scroll settles', () => {
const terminal = createTerminal({ viewportY: 100, baseY: 100 })
terminal.buffer.active.viewportY = 50
syncTerminalScrollIntentFromViewport(terminal)
expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport')
})
it('tracks upward wheel immediately and records the settled viewport', async () => {
const frameCallbacks: FrameRequestCallback[] = []
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
frameCallbacks.push(callback)
return frameCallbacks.length
})
vi.stubGlobal('Element', TestElement)
const terminal = createTerminal({ viewportY: 100, baseY: 100 })
const host = new TestElement() as unknown as HTMLElement
const disposable = attachTerminalScrollIntentTracking(terminal, host)
const wheelUp = new Event('wheel') as WheelEvent
Object.defineProperty(wheelUp, 'deltaY', { value: -10 })
host.dispatchEvent(wheelUp)
expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport')
terminal.buffer.active.viewportY = 80
await Promise.resolve()
terminal.buffer.active.viewportY = 0
enforceTerminalCurrentScrollIntent(terminal)
expect(terminal.scrollToLine).toHaveBeenLastCalledWith(80)
disposable.dispose()
})
it('returns to followOutput after a downward wheel settles at the bottom', async () => {
const frameCallbacks: FrameRequestCallback[] = []
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
frameCallbacks.push(callback)
return frameCallbacks.length
})
vi.stubGlobal('Element', TestElement)
const terminal = createTerminal({ viewportY: 50, baseY: 100 })
const host = new TestElement() as unknown as HTMLElement
const disposable = attachTerminalScrollIntentTracking(terminal, host)
const wheelDown = new Event('wheel') as WheelEvent
Object.defineProperty(wheelDown, 'deltaY', { value: 10 })
host.dispatchEvent(wheelDown)
expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport')
terminal.buffer.active.viewportY = 100
await Promise.resolve()
expect(getTerminalScrollIntentKind(terminal)).toBe('followOutput')
disposable.dispose()
})
it('keeps sampling briefly after wheel so delayed xterm scroll updates win', async () => {
const frameCallbacks: FrameRequestCallback[] = []
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
frameCallbacks.push(callback)
return frameCallbacks.length
})
vi.useFakeTimers()
vi.stubGlobal('Element', TestElement)
const terminal = createTerminal({ viewportY: 100, baseY: 100 })
const host = new TestElement() as unknown as HTMLElement
const disposable = attachTerminalScrollIntentTracking(terminal, host)
const wheelUp = new Event('wheel') as WheelEvent
Object.defineProperty(wheelUp, 'deltaY', { value: -10 })
host.dispatchEvent(wheelUp)
expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport')
await Promise.resolve()
frameCallbacks.shift()?.(16)
expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport')
terminal.buffer.active.viewportY = 76
frameCallbacks.shift()?.(32)
frameCallbacks.shift()?.(48)
terminal.buffer.active.viewportY = 100
enforceTerminalCurrentScrollIntent(terminal)
expect(terminal.scrollToLine).toHaveBeenLastCalledWith(76)
disposable.dispose()
})
it('keeps a pane-keyed pinned viewport across a remounted empty terminal', () => {
vi.stubGlobal('Element', TestElement)
const firstTerminal = createTerminal({ viewportY: 76, baseY: 100 })
const firstHost = new TestElement() as unknown as HTMLElement
const firstDisposable = attachTerminalScrollIntentTracking(firstTerminal, firstHost, 'leaf-1')
markTerminalPinnedViewport(firstTerminal)
const remountedTerminal = createTerminal({ viewportY: 0, baseY: 0 })
const remountedHost = new TestElement() as unknown as HTMLElement
const remountedDisposable = attachTerminalScrollIntentTracking(
remountedTerminal,
remountedHost,
'leaf-1'
)
syncTerminalScrollIntentFromViewport(remountedTerminal)
remountedTerminal.buffer.active.baseY = 100
remountedTerminal.buffer.active.viewportY = 100
enforceTerminalCurrentScrollIntent(remountedTerminal)
expect(remountedTerminal.scrollToLine).toHaveBeenCalledWith(76)
expect(getTerminalScrollIntentKind(remountedTerminal)).toBe('pinnedViewport')
firstDisposable.dispose()
remountedDisposable.dispose()
})
it('tracks pointer-driven scrollbar scrolls without using output scroll as intent', () => {
vi.stubGlobal('Element', TestElement)
const terminal = createTerminal({ viewportY: 100, baseY: 100 })
const hostElement = new TestElement()
const viewport = new TestElement('xterm-viewport')
hostElement.append(viewport)
const host = hostElement as unknown as HTMLElement
const disposable = attachTerminalScrollIntentTracking(terminal, host)
terminal.buffer.active.viewportY = 50
host.dispatchEvent(new Event('scroll'))
expect(getTerminalScrollIntentKind(terminal)).toBe('followOutput')
viewport.dispatchEvent(new Event('pointerdown', { bubbles: true }))
viewport.dispatchEvent(new Event('scroll', { bubbles: true }))
expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport')
disposable.dispose()
})
it('does not treat terminal body pointer activity as scrollbar intent', () => {
vi.stubGlobal('Element', TestElement)
const terminal = createTerminal({ viewportY: 100, baseY: 100 })
const hostElement = new TestElement()
const body = new TestElement()
hostElement.append(body)
const host = hostElement as unknown as HTMLElement
const disposable = attachTerminalScrollIntentTracking(terminal, host)
body.dispatchEvent(new Event('pointerdown', { bubbles: true }))
terminal.buffer.active.viewportY = 50
host.dispatchEvent(new Event('scroll'))
expect(getTerminalScrollIntentKind(terminal)).toBe('followOutput')
disposable.dispose()
})
it('updates a manually pinned intent after xterm-handled keyboard scrolling settles', async () => {
const frameCallbacks: FrameRequestCallback[] = []
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
frameCallbacks.push(callback)
return frameCallbacks.length
})
const terminal = createTerminal({ viewportY: 100, baseY: 100 })
markTerminalPinnedViewport(terminal)
terminal.buffer.active.viewportY = 75
syncTerminalScrollIntentSoon(terminal)
await Promise.resolve()
terminal.buffer.active.viewportY = 0
enforceTerminalCurrentScrollIntent(terminal)
expect(terminal.scrollToLine).toHaveBeenLastCalledWith(75)
})
it('enforces current intent once for visibility resume', () => {
const terminal = createTerminal({ viewportY: 40, baseY: 100 })
markTerminalPinnedViewport(terminal)
terminal.buffer.active.viewportY = 0
enforceTerminalCurrentScrollIntent(terminal)
expect(terminal.scrollToLine).toHaveBeenCalledWith(40)
})
})
@@ -0,0 +1,293 @@
import type { IDisposable } from '@xterm/xterm'
type TerminalScrollIntentKind = 'followOutput' | 'pinnedViewport'
type BufferType = 'normal' | 'alternate'
type TerminalScrollIntentTarget = {
buffer?: {
active?: {
type?: string
viewportY?: number
baseY?: number
}
}
scrollToBottom?: () => void
scrollToLine?: (line: number) => void
}
type TerminalScrollIntentKey = string
type TerminalScrollIntent = {
kind: TerminalScrollIntentKind
bufferType: BufferType
viewportY: number
baseY: number
}
type TerminalScrollIntentWriteSnapshot = {
kind: TerminalScrollIntentKind
bufferType: BufferType
viewportY: number
}
const terminalScrollIntentByTerminal = new WeakMap<
TerminalScrollIntentTarget,
TerminalScrollIntent
>()
const terminalScrollIntentKeyByTerminal = new WeakMap<
TerminalScrollIntentTarget,
TerminalScrollIntentKey
>()
const terminalScrollIntentByKey = new Map<TerminalScrollIntentKey, TerminalScrollIntent>()
const BOTTOM_TOLERANCE_ROWS = 1
function readBufferSnapshot(
terminal: TerminalScrollIntentTarget
): { bufferType: BufferType; viewportY: number; baseY: number } | null {
const buffer = terminal.buffer?.active
const viewportY = buffer?.viewportY
const baseY = buffer?.baseY
if (typeof viewportY !== 'number' || typeof baseY !== 'number') {
return null
}
return {
bufferType: buffer?.type === 'alternate' ? 'alternate' : 'normal',
viewportY,
baseY
}
}
function isAtBottom(viewportY: number, baseY: number): boolean {
return viewportY >= baseY - BOTTOM_TOLERANCE_ROWS
}
function writeIntent(
terminal: TerminalScrollIntentTarget,
kind: TerminalScrollIntentKind
): TerminalScrollIntent | null {
const snapshot = readBufferSnapshot(terminal)
if (!snapshot) {
return null
}
const intent = { kind, ...snapshot }
terminalScrollIntentByTerminal.set(terminal, intent)
const key = terminalScrollIntentKeyByTerminal.get(terminal)
if (key) {
terminalScrollIntentByKey.set(key, intent)
}
return intent
}
function readStoredIntent(terminal: TerminalScrollIntentTarget): TerminalScrollIntent | undefined {
const terminalIntent = terminalScrollIntentByTerminal.get(terminal)
if (terminalIntent) {
return terminalIntent
}
const key = terminalScrollIntentKeyByTerminal.get(terminal)
return key ? terminalScrollIntentByKey.get(key) : undefined
}
function bindTerminalScrollIntentKey(
terminal: TerminalScrollIntentTarget,
key: TerminalScrollIntentKey | undefined
): TerminalScrollIntent | undefined {
if (!key) {
return terminalScrollIntentByTerminal.get(terminal)
}
terminalScrollIntentKeyByTerminal.set(terminal, key)
const existing = terminalScrollIntentByKey.get(key)
if (existing) {
terminalScrollIntentByTerminal.set(terminal, existing)
}
return existing
}
function clampViewportY(viewportY: number, baseY: number): number {
return Math.max(0, Math.min(viewportY, baseY))
}
function safeScrollCall(fn: () => void): boolean {
try {
fn()
return true
} catch (err) {
if (err instanceof TypeError && /dimensions/.test(err.message)) {
return false
}
throw err
}
}
export function markTerminalFollowOutput(terminal: TerminalScrollIntentTarget): void {
writeIntent(terminal, 'followOutput')
}
export function markTerminalPinnedViewport(terminal: TerminalScrollIntentTarget): void {
writeIntent(terminal, 'pinnedViewport')
}
export function syncTerminalScrollIntentFromViewport(
terminal: TerminalScrollIntentTarget,
options: { preservePinnedAtBottom?: boolean } = {}
): void {
const snapshot = readBufferSnapshot(terminal)
if (!snapshot) {
return
}
const existing = readStoredIntent(terminal)
// Why: a remounted/replayed terminal can briefly report an empty or shorter
// scrollback. That transient state must not erase a durable pinned viewport.
if (existing?.kind === 'pinnedViewport' && snapshot.baseY < existing.baseY) {
terminalScrollIntentByTerminal.set(terminal, existing)
return
}
if (
options.preservePinnedAtBottom &&
existing?.kind === 'pinnedViewport' &&
isAtBottom(snapshot.viewportY, snapshot.baseY)
) {
return
}
writeIntent(
terminal,
isAtBottom(snapshot.viewportY, snapshot.baseY) ? 'followOutput' : 'pinnedViewport'
)
}
export function syncTerminalScrollIntentSoon(
terminal: TerminalScrollIntentTarget,
options: { preservePinnedAtBottom?: boolean } = {}
): void {
const sync = (): void => syncTerminalScrollIntentFromViewport(terminal, options)
queueMicrotask(sync)
requestAnimationFrame(sync)
requestAnimationFrame(() => requestAnimationFrame(sync))
setTimeout(sync, 80)
}
export function getTerminalScrollIntentKind(
terminal: TerminalScrollIntentTarget
): TerminalScrollIntentKind {
const existing = readStoredIntent(terminal)
if (existing) {
return existing.kind
}
const snapshot = readBufferSnapshot(terminal)
if (!snapshot) {
return 'followOutput'
}
return isAtBottom(snapshot.viewportY, snapshot.baseY) ? 'followOutput' : 'pinnedViewport'
}
export function captureTerminalWriteScrollIntent(
terminal: TerminalScrollIntentTarget
): TerminalScrollIntentWriteSnapshot | null {
const snapshot = readBufferSnapshot(terminal)
if (!snapshot) {
return null
}
const existing = readStoredIntent(terminal)
const kind =
existing?.kind ??
(isAtBottom(snapshot.viewportY, snapshot.baseY) ? 'followOutput' : 'pinnedViewport')
return {
kind,
bufferType: snapshot.bufferType,
viewportY: snapshot.viewportY
}
}
export function enforceTerminalWriteScrollIntent(
terminal: TerminalScrollIntentTarget,
snapshot: TerminalScrollIntentWriteSnapshot | null
): void {
if (!snapshot) {
return
}
const current = readBufferSnapshot(terminal)
if (!current || current.bufferType !== snapshot.bufferType) {
return
}
if (snapshot.kind === 'followOutput') {
if (safeScrollCall(() => terminal.scrollToBottom?.())) {
writeIntent(terminal, 'followOutput')
}
return
}
const targetY = clampViewportY(snapshot.viewportY, current.baseY)
if (current.viewportY !== targetY) {
safeScrollCall(() => terminal.scrollToLine?.(targetY))
}
writeIntent(terminal, 'pinnedViewport')
}
export function enforceTerminalCurrentScrollIntent(terminal: TerminalScrollIntentTarget): void {
const existing = readStoredIntent(terminal)
const snapshot = existing
? {
kind: existing.kind,
bufferType: existing.bufferType,
viewportY: existing.viewportY
}
: captureTerminalWriteScrollIntent(terminal)
enforceTerminalWriteScrollIntent(terminal, snapshot)
}
export function attachTerminalScrollIntentTracking(
terminal: TerminalScrollIntentTarget,
host: HTMLElement,
intentKey?: TerminalScrollIntentKey
): IDisposable {
if (!bindTerminalScrollIntentKey(terminal, intentKey)) {
syncTerminalScrollIntentFromViewport(terminal)
}
let pointerScrollActive = false
const onWheel = (event: WheelEvent): void => {
if (event.deltaY < 0) {
markTerminalPinnedViewport(terminal)
syncTerminalScrollIntentSoon(terminal, { preservePinnedAtBottom: true })
return
}
syncTerminalScrollIntentSoon(terminal)
}
const onPointerDown = (event: PointerEvent): void => {
const target = event.target
pointerScrollActive =
typeof Element !== 'undefined' &&
target instanceof Element &&
(target.classList.contains('xterm-viewport') || target.closest('.xterm-viewport') !== null)
}
const onPointerDone = (): void => {
if (!pointerScrollActive) {
return
}
pointerScrollActive = false
syncTerminalScrollIntentFromViewport(terminal)
}
const onScroll = (): void => {
if (pointerScrollActive) {
syncTerminalScrollIntentFromViewport(terminal)
}
}
host.addEventListener('wheel', onWheel, { capture: true, passive: true })
host.addEventListener('pointerdown', onPointerDown, true)
host.addEventListener('scroll', onScroll, true)
globalThis.addEventListener?.('pointerup', onPointerDone, true)
globalThis.addEventListener?.('pointercancel', onPointerDone, true)
return {
dispose: () => {
host.removeEventListener('wheel', onWheel, true)
host.removeEventListener('pointerdown', onPointerDown, true)
host.removeEventListener('scroll', onScroll, true)
globalThis.removeEventListener?.('pointerup', onPointerDone, true)
globalThis.removeEventListener?.('pointercancel', onPointerDone, true)
}
}
}
@@ -200,4 +200,84 @@ describe('project-host workspace target resolution', () => {
reason: 'setup-not-ready'
})
})
// Regression: selecting a project in the new-workspace dropdown must not be
// pinned to the host of the currently-active workspace. Each project below is
// set up on exactly one (different) host; picking the other project while the
// current host is given only as a preference must resolve to that project's
// own host instead of returning '' (the silent no-op the dropdown showed).
describe('cross-host project selection', () => {
const repos = [makeRepo('local-repo'), makeRepo('remote-repo', { connectionId: 'remote-1' })]
const projects = [
makeProject('repo:local-repo', ['local-repo']),
makeProject('repo:remote-repo', ['remote-repo'])
]
const projectHostSetups = [
makeSetup('local-repo', 'repo:local-repo', 'local', 'local-repo'),
makeSetup('remote-repo', 'repo:remote-repo', 'ssh:remote-1', 'remote-repo')
]
it('resolves a remote-only project while the current host is local', () => {
expect(
resolveWorkspaceCreationRepoId({
eligibleRepos: repos,
projects,
projectHostSetups,
projectId: 'repo:remote-repo',
focusedHostScope: 'local'
})
).toBe('remote-repo')
})
it('resolves a local-only project while the current host is remote', () => {
expect(
resolveWorkspaceCreationRepoId({
eligibleRepos: repos,
projects,
projectHostSetups,
projectId: 'repo:local-repo',
focusedHostScope: 'ssh:remote-1'
})
).toBe('local-repo')
})
it('still prefers the current host when the project is set up on it', () => {
const multiHostProjects = [makeProject('repo:multi', ['multi-local', 'multi-remote'])]
const multiHostSetups = [
makeSetup('multi-local', 'repo:multi', 'local', 'multi-local'),
makeSetup('multi-remote', 'repo:multi', 'ssh:remote-1', 'multi-remote')
]
expect(
resolveWorkspaceCreationRepoId({
eligibleRepos: [
makeRepo('multi-local'),
makeRepo('multi-remote', { connectionId: 'remote-1' })
],
projects: multiHostProjects,
projectHostSetups: multiHostSetups,
projectId: 'repo:multi',
focusedHostScope: 'local'
})
).toBe('multi-local')
})
it('still reports unavailable for an explicit project+host with no ready setup', () => {
// The strict projectId+hostId path (used by the explicit "Run on" host
// picker) keeps its hard match — only the project-dropdown call site
// changed to pass the host as a preference.
expect(
resolveWorkspaceCreationTarget({
eligibleRepos: repos,
projects,
projectHostSetups,
projectId: 'repo:remote-repo',
hostId: 'local'
})
).toEqual({
status: 'unavailable',
reason: 'project-not-set-up-on-host'
})
})
})
})
@@ -119,6 +119,30 @@ describe('resumeSleepingAgentSessionsForWorktree', () => {
expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBe(record)
})
it('skips hibernated stable panes after their live PTY binding is cleared', () => {
const paneKey = makePaneKey('tab-1', LEAF_ID)
const record = makeRecord({ paneKey, origin: 'worktree-sleep', state: 'done' })
useAppStore.setState({
tabsByWorktree: { 'wt-1': [makeTerminalTab('tab-1', 'wt-1')] },
terminalLayoutsByTabId: {
'tab-1': {
root: { type: 'leaf', leafId: LEAF_ID },
activeLeafId: LEAF_ID,
expandedLeafId: null
}
},
sleepingAgentSessionsByPaneKey: { [record.paneKey]: record }
} as never)
const launched = resumeSleepingAgentSessionsForWorktree('wt-1')
const state = useAppStore.getState()
expect(launched).toBe(0)
expect(state.tabsByWorktree['wt-1']).toHaveLength(1)
expect(state.pendingStartupByTabId).toEqual({})
expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBe(record)
})
it('skips legacy numeric pane-key records owned by a preserved tab wake hint', () => {
const record = makeRecord({ paneKey: 'tab-1:0', origin: 'worktree-sleep' })
useAppStore.setState({
@@ -11,7 +11,11 @@ import {
resolveTuiAgentLaunchEnv
} from '../../../shared/tui-agent-launch-defaults'
import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume'
import type { TerminalTab } from '../../../shared/types'
import type {
TerminalLayoutSnapshot,
TerminalPaneLayoutNode,
TerminalTab
} from '../../../shared/types'
import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../shared/stable-pane-id'
import { translate } from '@/i18n/i18n'
import { AGENT_STATUS_STALE_AFTER_MS } from '../../../shared/agent-status-types'
@@ -141,13 +145,27 @@ function hasRestorableLegacyTabPty(
return Boolean(tab.ptyId) || (ptyIdsByTabId[tab.id]?.length ?? 0) > 0
}
function layoutContainsLeaf(
node: TerminalPaneLayoutNode | null | undefined,
leafId: string
): boolean {
if (!node) {
return false
}
if (node.type === 'leaf') {
return node.leafId === leafId
}
return layoutContainsLeaf(node.first, leafId) || layoutContainsLeaf(node.second, leafId)
}
function hasMatchingStablePaneLayout(
tabId: string,
leafId: string,
terminalLayoutsByTabId: ReturnType<typeof useAppStore.getState>['terminalLayoutsByTabId']
terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot | undefined>
): boolean {
const ptyIdsByLeafId = terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId
return Boolean(ptyIdsByLeafId && Object.hasOwn(ptyIdsByLeafId, leafId))
// Why: hibernation intentionally clears the live PTY binding after the pane
// exits, but the preserved leaf still owns cold-restore for its session.
return layoutContainsLeaf(terminalLayoutsByTabId[tabId]?.root, leafId)
}
function findSameWorktreeTab(
+3
View File
@@ -74,6 +74,9 @@ export type AiVaultScanIssue = {
export type AiVaultListArgs = {
limit?: number
force?: boolean
// Active workspace/project paths. The global result is recency-capped, so these
// guarantee a scoped view still surfaces its own (possibly older) sessions.
scopePaths?: readonly string[]
}
export type AiVaultListResult = {
+1
View File
@@ -286,6 +286,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
floatingTerminalTriggerLocation: 'floating-button',
notifications: getDefaultNotificationSettings(),
diffDefaultView: 'inline',
diffWordWrap: false,
combinedDiffFileTreeVisibleByDefault: false,
promptCacheTimerEnabled: false,
promptCacheTtlMs: 300_000,
+78
View File
@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest'
import { deriveGitRemoteIdentity, normalizeGitRemoteUrl } from './git-remote-identity'
describe('normalizeGitRemoteUrl', () => {
it('normalizes HTTPS and SSH GitHub remotes to the same canonical key', () => {
expect(normalizeGitRemoteUrl('https://github.com/example/sample-app.git')).toBe(
'github.com/example/sample-app'
)
expect(normalizeGitRemoteUrl('git@github.com:example/sample-app.git')).toBe(
'github.com/example/sample-app'
)
expect(normalizeGitRemoteUrl('ssh://git@github.com/example/sample-app.git')).toBe(
'github.com/example/sample-app'
)
expect(normalizeGitRemoteUrl('https://GitHub.com/example/sample-app.git')).toBe(
'github.com/example/sample-app'
)
})
it('preserves nested GitLab/self-hosted paths', () => {
expect(normalizeGitRemoteUrl('git@gitlab.company.test:platform/tools/sample-app.git')).toBe(
'gitlab.company.test/platform/tools/sample-app'
)
})
it('ignores explicit URL ports in canonical keys', () => {
expect(normalizeGitRemoteUrl('ssh://git@git.company.test:2222/team/sample-app.git')).toBe(
'git.company.test/team/sample-app'
)
})
it('preserves path case for case-sensitive hosted remotes', () => {
expect(normalizeGitRemoteUrl('git@Git.Company.Test:Team/Sample-App.git')).toBe(
'git.company.test/Team/Sample-App'
)
expect(normalizeGitRemoteUrl('https://git.company.test/Team/Sample-App.git')).toBe(
'git.company.test/Team/Sample-App'
)
})
it('rejects Windows local filesystem remotes', () => {
expect(normalizeGitRemoteUrl('C:\\Repos\\sample-app.git')).toBeNull()
expect(normalizeGitRemoteUrl('C:/Repos/sample-app.git')).toBeNull()
})
})
describe('deriveGitRemoteIdentity', () => {
it('prefers upstream, then origin, then the first named remote', () => {
expect(
deriveGitRemoteIdentity(
[
'origin\tgit@git.company.test:forks/sample-app.git (fetch)',
'origin\tgit@git.company.test:forks/sample-app.git (push)',
'upstream\thttps://git.company.test/team/sample-app.git (fetch)',
'upstream\thttps://git.company.test/team/sample-app.git (push)'
].join('\n')
)
).toEqual({
canonicalKey: 'git.company.test/team/sample-app',
remoteName: 'upstream',
remoteUrl: 'https://git.company.test/team/sample-app.git'
})
expect(
deriveGitRemoteIdentity('origin\tgit@git.company.test:team/sample-app.git (fetch)')
).toMatchObject({
canonicalKey: 'git.company.test/team/sample-app',
remoteName: 'origin'
})
expect(
deriveGitRemoteIdentity('mirror\tgit@git.company.test:team/sample-app.git (fetch)')
).toMatchObject({
canonicalKey: 'git.company.test/team/sample-app',
remoteName: 'mirror'
})
})
})
+103
View File
@@ -0,0 +1,103 @@
export type GitRemoteIdentity = {
canonicalKey: string
remoteName: string
remoteUrl: string
}
type GitRemoteEntry = {
name: string
url: string
}
function stripGitSuffix(path: string): string {
return path.endsWith('.git') ? path.slice(0, -4) : path
}
function normalizeRemotePath(path: string): string {
return stripGitSuffix(path.replace(/^\/+/, '').replace(/\/+$/, ''))
}
function normalizeRemoteHost(host: string): string {
return host.trim().toLowerCase()
}
function isLocalFilesystemRemote(remoteUrl: string): boolean {
return /^[A-Za-z]:[\\/]/.test(remoteUrl)
}
export function normalizeGitRemoteUrl(remoteUrl: string): string | null {
const trimmed = remoteUrl.trim()
if (!trimmed) {
return null
}
if (isLocalFilesystemRemote(trimmed)) {
return null
}
const scpMatch = trimmed.includes('://') ? null : /^([^@\s:]+@)?([^:\s]+):(.+)$/.exec(trimmed)
if (scpMatch) {
const host = normalizeRemoteHost(scpMatch[2] ?? '')
const path = normalizeRemotePath(scpMatch[3] ?? '')
return host && path ? `${host}/${path}` : null
}
try {
const parsed = new URL(trimmed)
const host = normalizeRemoteHost(parsed.hostname)
const path = normalizeRemotePath(parsed.pathname)
return host && path ? `${host}/${path}` : null
} catch {
return null
}
}
export function parseGitRemoteVerboseOutput(stdout: string): GitRemoteEntry[] {
const entries: GitRemoteEntry[] = []
for (const rawLine of stdout.split(/\r?\n/)) {
const line = rawLine.trim()
if (!line.endsWith('(fetch)')) {
continue
}
const match = /^(\S+)\s+(.+?)\s+\(fetch\)$/.exec(line)
if (!match) {
continue
}
const name = match[1]?.trim()
const url = match[2]?.trim()
if (name && url) {
entries.push({ name, url })
}
}
return entries
}
function primaryRemoteSortKey(entry: GitRemoteEntry): number {
if (entry.name === 'upstream') {
return 0
}
if (entry.name === 'origin') {
return 1
}
return 2
}
export function deriveGitRemoteIdentity(stdout: string): GitRemoteIdentity | null {
const entries = parseGitRemoteVerboseOutput(stdout)
.map((entry) => ({
...entry,
canonicalKey: normalizeGitRemoteUrl(entry.url)
}))
.filter((entry): entry is GitRemoteEntry & { canonicalKey: string } => !!entry.canonicalKey)
.sort((left, right) => {
const priority = primaryRemoteSortKey(left) - primaryRemoteSortKey(right)
return priority === 0 ? left.name.localeCompare(right.name) : priority
})
const selected = entries[0]
return selected
? {
canonicalKey: selected.canonicalKey,
remoteName: selected.name,
remoteUrl: selected.url
}
: null
}
@@ -168,6 +168,119 @@ describe('project host setup projection', () => {
])
})
it('groups same-project records across local, SSH, and runtime hosts by git remote identity', () => {
const projection = projectHostSetupProjectionFromRepos([
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'
}
}),
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'
}
}),
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'
}
})
])
expect(projection.projects).toHaveLength(1)
expect(projection.projects[0]).toMatchObject({
id: 'git:git.company.test/team/sample-app',
displayName: 'sample-app',
sourceRepoIds: ['local-sample-app', 'ssh-sample-app', 'runtime-sample-app'],
gitRemoteIdentity: {
canonicalKey: 'git.company.test/team/sample-app',
remoteName: 'origin'
}
})
expect(projection.setups.map((setup) => setup.hostId)).toEqual([
'local',
'ssh:build%20server',
'runtime:dev-container'
])
expect(
getProjectHostSetupsForProject(projection.setups, 'git:git.company.test/team/sample-app')
).toHaveLength(3)
})
it('keeps same-named cross-host records separate when there is no shared repo identity', () => {
const projection = projectHostSetupProjectionFromRepos([
repo({
id: 'local-sample-app',
path: '/Users/alice/work/sample-app',
displayName: 'sample-app'
}),
repo({
id: 'ssh-sample-app',
path: '/srv/unrelated/sample-app',
displayName: 'sample-app',
connectionId: 'staging server'
}),
repo({
id: 'runtime-sample-app',
path: '/workspace/sample-app',
displayName: 'sample-app',
executionHostId: 'runtime:preview'
})
])
// Why: display names are labels, not identity. A future fix needs a
// normalized git remote identity or an explicit user link before merging.
expect(projection.projects).toHaveLength(3)
})
it('does not collapse case-distinct remote paths for self-hosted git remotes', () => {
const projection = projectHostSetupProjectionFromRepos([
repo({
id: 'uppercase-repo',
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'
}
}),
repo({
id: 'lowercase-repo',
path: '/home/alice/src/sample-app',
displayName: 'sample-app',
connectionId: 'build server',
gitRemoteIdentity: {
canonicalKey: 'git.company.test/team/sample-app',
remoteName: 'origin',
remoteUrl: 'git@git.company.test:team/sample-app.git'
}
})
])
expect(projection.projects.map((project) => project.id)).toEqual([
'git:git.company.test/Team/Sample-App',
'git:git.company.test/team/sample-app'
])
})
it('ignores malformed provider identity values', () => {
const projection = projectHostSetupProjectionFromRepos([
repo({
+26 -5
View File
@@ -41,6 +41,17 @@ function getProjectProviderIdentity(
: null
}
function getProjectGitRemoteIdentity(
repo: Pick<Repo, 'gitRemoteIdentity'>
): NonNullable<Repo['gitRemoteIdentity']> | null {
const identity = repo.gitRemoteIdentity
const canonicalKey =
typeof identity?.canonicalKey === 'string' ? identity.canonicalKey.trim() : ''
const remoteName = typeof identity?.remoteName === 'string' ? identity.remoteName.trim() : ''
const remoteUrl = typeof identity?.remoteUrl === 'string' ? identity.remoteUrl.trim() : ''
return canonicalKey && remoteName && remoteUrl ? { canonicalKey, remoteName, remoteUrl } : null
}
/** True when the repo resolves to a GitHub provider identity (via explicit
* upstream or a GitHub-sourced avatar icon). Used to scope GitHub-CLI setup
* prompts to users who actually have GitHub-backed projects. */
@@ -48,20 +59,29 @@ export function isGitHubBackedRepo(repo: Pick<Repo, 'upstream' | 'repoIcon'>): b
return getProjectProviderIdentity(repo) !== null
}
export function getProjectIdentityKey(repo: Pick<Repo, 'id' | 'upstream' | 'repoIcon'>): string {
export function getProjectIdentityKey(
repo: Pick<Repo, 'id' | 'upstream' | 'repoIcon' | 'gitRemoteIdentity'>
): string {
const identity = getProjectProviderIdentity(repo)
if (!identity) {
return `repo:${repo.id}`
if (identity) {
return `github:${normalizeIdentityPart(identity.owner)}/${normalizeIdentityPart(identity.repo)}`
}
return `github:${normalizeIdentityPart(identity.owner)}/${normalizeIdentityPart(identity.repo)}`
const gitRemoteIdentity = getProjectGitRemoteIdentity(repo)
if (gitRemoteIdentity) {
return `git:${gitRemoteIdentity.canonicalKey}`
}
return `repo:${repo.id}`
}
function getProjectId(repo: Pick<Repo, 'id' | 'upstream' | 'repoIcon'>): string {
function getProjectId(
repo: Pick<Repo, 'id' | 'upstream' | 'repoIcon' | 'gitRemoteIdentity'>
): string {
return getProjectIdentityKey(repo)
}
function createProjectFromRepo(repo: Repo, now: number): Project {
const identity = getProjectProviderIdentity(repo)
const gitRemoteIdentity = getProjectGitRemoteIdentity(repo)
return {
id: getProjectId(repo),
displayName: repo.displayName,
@@ -69,6 +89,7 @@ function createProjectFromRepo(repo: Repo, now: number): Project {
...(repo.repoIcon !== undefined ? { repoIcon: repo.repoIcon } : {}),
...(repo.kind ? { kind: repo.kind } : {}),
...(identity ? { providerIdentity: identity } : {}),
...(gitRemoteIdentity ? { gitRemoteIdentity } : {}),
sourceRepoIds: [repo.id],
createdAt: repo.addedAt || now,
updatedAt: repo.addedAt || now
+36
View File
@@ -35,6 +35,7 @@ import type { ClaudeAgentTeamsMode } from './claude-agent-teams-tmux-compat'
import type { TerminalCustomTheme } from './terminal-custom-themes'
import type { UiLanguage } from './ui-language'
import type { ForkSyncMode } from './git-fork-sync'
import type { GitRemoteIdentity } from './git-remote-identity'
import type {
GlobalWindowsRuntimeDefault,
LocalWindowsRuntimePreference
@@ -109,6 +110,7 @@ export type Project = {
repoIcon?: RepoIcon | null
kind?: RepoKind
providerIdentity?: ProjectProviderIdentity
gitRemoteIdentity?: GitRemoteIdentity
/** Local Windows projects inherit the global runtime default unless this override is set. */
localWindowsRuntimePreference?: LocalWindowsRuntimePreference
sourceRepoIds: string[]
@@ -235,6 +237,7 @@ export type Repo = {
* default avatar (upstream owner, not the personal fork) and the fork
* indicator. Absent = not a fork, or fork status not yet resolved. */
upstream?: GitHubRepositoryIdentity | null
gitRemoteIdentity?: GitRemoteIdentity | null
addedAt: number
kind?: RepoKind
gitUsername?: string
@@ -1310,6 +1313,36 @@ export type PRComment = {
isBot?: boolean
}
export type GitHubIssueTimelineTarget = {
type: 'issue' | 'pr'
number: number
title: string
url: string
repository?: string
}
export type GitHubIssueTimelineItem = {
id: string
event:
| 'assigned'
| 'unassigned'
| 'mentioned'
| 'cross-referenced'
| 'closed'
| 'reopened'
| 'moved_columns_in_project'
actor: string
actorAvatarUrl: string
createdAt: string
assignee?: string
source?: GitHubIssueTimelineTarget
closer?: GitHubIssueTimelineTarget
stateReason?: string | null
previousColumnName?: string | null
columnName?: string | null
projectName?: string | null
}
export type GitHubCommentResult = { ok: true; comment: PRComment } | { ok: false; error: string }
export type IssueInfo = {
@@ -1428,6 +1461,8 @@ export type GitHubWorkItemDetails = {
item: Omit<GitHubWorkItem, 'repoId'>
body: string
comments: PRComment[]
/** Issue-only provider activity such as assignment, references, project moves, and state changes. */
timelineItems?: GitHubIssueTimelineItem[]
/** Only set for PRs. Head/base SHAs used by the Files tab to fetch per-file content. */
headSha?: string
baseSha?: string
@@ -2556,6 +2591,7 @@ export type GlobalSettings = {
* ~/.orca/keybindings.json; main migrates this once when present. */
keybindings?: KeybindingOverrides
diffDefaultView: 'inline' | 'side-by-side'
diffWordWrap: boolean
combinedDiffFileTreeVisibleByDefault: boolean
notifications: NotificationSettings
/** When true, a countdown timer is shown after a Claude agent becomes idle,
+251
View File
@@ -0,0 +1,251 @@
import { readFileSync } from 'node:fs'
import path from 'node:path'
export const EMOJI_TABLE_FIXTURE = readFileSync(
path.join(__dirname, 'fixtures', 'terminal-emoji-table.md'),
'utf8'
)
export const NARROW_TERMINAL_MAX_COLS = 120
export function longMarkdownTableScript(runId: string): string {
const names = [
['Sam Syntax', 'Compiler', 'Online', '😀', '9200', 'Semicolons are optional (rage ensues)'],
['Tori Token', 'Auth', 'Idle', '🚀', '4800', 'JWT expires during their standup'],
['Uma Unpin', 'Frontend', 'Online', '🔥', '3500', 'Absolute positioning enjoyer'],
['Vic Variable', 'Types', 'AFK', '💡', '6700', 'any is not a type, it is a cry for help'],
['Wally Watchdog', 'Security', 'Online', '📦', '8200', 'Found a vuln in your vuln scanner'],
['Xena XPath', 'DB', 'Idle', '🔐', '7300', 'Indexes everything, including the fridge'],
['Yuki Yank', 'CLI', 'Online', '🎯', '5900', 'rm -rf / is not a party trick'],
['Zane Zealot', 'OSS', 'Offline', '🤖', '10000', 'Contributor to 47 repos, sleeps never'],
['Artie ASCII', 'Docs', 'Online', '🧠', '2900', 'Wrote a novel in README comments'],
['Bianca Batch', 'ML', 'AFK', '💾', '9400', 'Training a model to write PR descriptions'],
['Carlos Cache', 'CDN', 'Idle', '⚙', '4900', 'Stale data is still data'],
['Diana Draft', 'Planning', 'Online', '📚', '1800', 'Needs 3 more sprints to estimate'],
['Edgar Exit', 'Ops', 'Online', '🔧', '7600', 'Graceful shutdown specialist'],
['Fiona Fallback', 'Resilience', 'Idle', '🧲', '5500', 'Circuit breaker connoisseur'],
['Gabe Garbage', 'GC', 'Offline', '🧹', '4100', 'Stop-the-world is my catchphrase'],
['Holly Hotfix', 'Release', 'Online', '🧪', '6300', 'Friday deploy champion'],
['Ira Idempotent', 'API', 'AFK', '🔁', '6900', 'PUT me in coach'],
['Jules Jitter', 'Mobile', 'Idle', '📱', '3200', 'Offline-first, coffee-second'],
['Ken Kafka', 'Streams', 'Online', '📡', '7100', 'Rebalancing is a lifestyle'],
['Luna Latency', 'Edge', 'Offline', '🧭', '4400', 'Response time measured in business days'],
['Max Marshal', 'Memory', 'Online', '🧩', '8700', "Leak-free since '24"],
['Nora Null', 'Safety', 'AFK', '❓', '3800', 'null is a person, not a value'],
['Otto Offset', 'Cursors', 'Idle', '👆', '2600', 'Infinite scroll for the infinite soul'],
['Pam Payload', 'Serialization', 'Online', '📦', '5800', 'JSON.stringify is my yoga'],
['Reed Regex', 'Matching', 'Offline', '🔍', '6800', 'Now I have two problems']
]
return `
const rows = ${JSON.stringify(names)}
const widths = [16, 14, 12, 6, 7, 42]
function isCombiningMark(codePoint) {
return (codePoint >= 0x0300 && codePoint <= 0x036f) ||
(codePoint >= 0xfe00 && codePoint <= 0xfe0f)
}
function isWideCodePoint(codePoint) {
return codePoint > 0xffff ||
(codePoint >= 0x1100 && codePoint <= 0x115f) ||
(codePoint >= 0x2e80 && codePoint <= 0xa4cf) ||
(codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
(codePoint >= 0xf900 && codePoint <= 0xfaff) ||
(codePoint >= 0xfe10 && codePoint <= 0xfe6f) ||
(codePoint >= 0xff00 && codePoint <= 0xff60) ||
(codePoint >= 0xffe0 && codePoint <= 0xffe6)
}
function cellWidth(text) {
let width = 0
for (const char of String(text)) {
const codePoint = char.codePointAt(0)
if (codePoint === undefined || isCombiningMark(codePoint)) continue
width += isWideCodePoint(codePoint) ? 2 : 1
}
return width
}
function cell(value, width) {
const text = String(value)
return text + ' '.repeat(Math.max(1, width - cellWidth(text)))
}
function line(parts) {
return '| ' + parts.map((part, index) => cell(part, widths[index])).join(' | ') + ' |'
}
const outputRows = []
outputRows.push(line(['Name', 'Team', 'Status', 'Icon', 'Score', 'Notes']))
outputRows.push('|-' + widths.map((width) => '-'.repeat(width)).join('-|-') + '-|')
for (let repeat = 0; repeat < 4; repeat += 1) {
for (const row of rows) outputRows.push(line(row))
}
async function writeStdout(chunk) {
await new Promise((resolve) => process.stdout.write(chunk, resolve))
if (process.platform === 'win32') await new Promise((resolve) => setTimeout(resolve, 8))
}
await writeStdout('\\x1b[?2026h\\x1b[2J\\x1b[H')
for (const row of outputRows) {
await writeStdout(row + '\\n')
}
await writeStdout('\\x1b[?2026l')
await writeStdout('LONG_TABLE_SCROLL_RESTORE_${runId}\\n')
`
}
export function emojiFixtureMarkdownTableScript(table: string, runId: string): string {
const marker = `EMOJI_FIXTURE_TABLE_RESTORE_${runId}`
const widthMarker = `EMOJI_FIXTURE_TABLE_WIDTH_${runId}`
return `
const table = ${JSON.stringify(table)}
const minimumWidths = [2, 5, 4, 7, 7, 4, 3, 4]
const preferredWidths = [5, 17, 10, 18, 30, 12, 10, 10]
const tableOverhead = preferredWidths.length * 3 + 1
const widthBudget = Math.max(
minimumWidths.reduce((sum, width) => sum + width, 0),
Math.min(
preferredWidths.reduce((sum, width) => sum + width, 0),
(process.stdout.columns || 100) - tableOverhead - 1
)
)
let remaining = widthBudget
let remainingPreferred = preferredWidths.reduce((sum, width) => sum + width, 0)
const widths = preferredWidths.map((preferred, index) => {
const minimum = minimumWidths[index]
const width = Math.max(minimum, Math.floor((remaining * preferred) / remainingPreferred))
remaining -= width
remainingPreferred -= preferred
return width
})
const generatedTableWidth = widths.reduce((sum, width) => sum + width, 0) + tableOverhead
const border = {
top: ['┌', '┬', '┐'],
middle: ['├', '┼', '┤'],
bottom: ['└', '┴', '┘'],
vertical: '│',
horizontal: '─'
}
function splitMarkdownRow(row) {
return row.trim().slice(1, -1).split('|').map((cell) => cell.trim())
}
function isSeparatorRow(row) {
return /^\\|(?:\\s*:?-+:?\\s*\\|)+\\s*$/.test(row)
}
function cellWidth(text) {
let width = 0
for (const char of String(text)) {
const codePoint = char.codePointAt(0)
if (codePoint === undefined || (codePoint >= 0x0300 && codePoint <= 0x036f)) continue
if (codePoint === 0xfe0f || codePoint === 0x200d) continue
width += codePoint > 0xffff || (codePoint >= 0x1100 && codePoint <= 0x115f) ? 2 : 1
}
return width
}
function padCell(value, width) {
const text = String(value)
return text + ' '.repeat(Math.max(0, width - cellWidth(text)))
}
function splitToWidth(text, width) {
const parts = []
let line = ''
for (const char of String(text)) {
const next = line + char
if (line && cellWidth(next) > width) {
parts.push(line)
line = char
} else {
line = next
}
}
if (line) parts.push(line)
return parts
}
function wrapCell(value, width) {
const words = String(value).split(/\\s+/)
const lines = []
let line = ''
for (const word of words) {
if (cellWidth(word) > width) {
if (line) {
lines.push(line)
line = ''
}
lines.push(...splitToWidth(word, width))
continue
}
const next = line ? line + ' ' + word : word
if (line && cellWidth(next) > width) {
lines.push(line)
line = word
} else {
line = next
}
}
if (line) lines.push(line)
return lines.length ? lines : ['']
}
function rule(parts) {
return parts[0] + widths.map((width) => border.horizontal.repeat(width + 2)).join(parts[1]) + parts[2]
}
function renderRow(cells) {
const wrappedCells = widths.map((width, index) => wrapCell(cells[index] ?? '', width))
const height = Math.max(...wrappedCells.map((cell) => cell.length))
const rows = []
for (let line = 0; line < height; line += 1) {
rows.push(
border.vertical +
widths
.map((width, index) => ' ' + padCell(wrappedCells[index][line] ?? '', width) + ' ')
.join(border.vertical) +
border.vertical
)
}
return rows
}
async function writeStdout(chunk) {
await new Promise((resolve) => process.stdout.write(chunk, resolve))
if (process.platform === 'win32') await new Promise((resolve) => setTimeout(resolve, 8))
}
const parsedRows = table
.split(/\\r?\\n/)
.filter((row) => row.trim().startsWith('|') && !isSeparatorRow(row))
.map(splitMarkdownRow)
const rendered = [rule(border.top)]
for (const [index, row] of parsedRows.entries()) {
rendered.push(...renderRow(row))
rendered.push(rule(index === parsedRows.length - 1 ? border.bottom : border.middle))
}
await writeStdout('\\x1b[?2026h\\x1b[2J\\x1b[H')
for (const line of rendered) {
await writeStdout(line + '\\r\\n')
}
await writeStdout('\\x1b[?2026l')
await writeStdout('${widthMarker}:' + generatedTableWidth + '\\r\\n')
await writeStdout('${marker}\\r\\n')
`
}
export function emojiFixtureTableWidthMarker(runId: string): string {
return `EMOJI_FIXTURE_TABLE_WIDTH_${runId}:`
}
export function narrowSignerMarkdownTableScript(runId: string): string {
const marker = `NARROW_SIGNER_TABLE_RESTORE_${runId}`
const rows = [
'| # | Status | Signer | Action |',
'| ---: | --- | --- | --- |',
'| 1 | signed | did:key:z6Mkuw5kQqz1QvZ9f3d2aB7f19f0cAC7B4F3c9E725aD19cD12e6A8B3F4c5D6e7F8a9B0c1D2e3F4a5B6c7D8e9F0a1B2c3D4e5F6a7B8c9D0e1F2 | approve deployment |',
'| 2 | waiting | did:web:example.signing.service:teams:release:prod:primary-key-2026-06-08-with-extra-qualifiers-and-long-human-readable-suffix | counter-sign |',
'| 3 | signed | 0x742d35Cc6634C0532925a3b844Bc454e4438f44e9E8F12A7C4D9B6530F9D2C8E7A6B5C4D3E2F1A0B998877665544332211 | archive receipt |'
]
const repeatedRows = Array.from({ length: 8 }, (_, index) =>
rows.concat(`| ${index + 4} | signed | signer-row-${index}-${'a'.repeat(96)} | verify |`)
).flat()
return `
const rows = ${JSON.stringify(repeatedRows)}
async function writeStdout(chunk) {
await new Promise((resolve) => process.stdout.write(chunk, resolve))
if (process.platform === 'win32') await new Promise((resolve) => setTimeout(resolve, 8))
}
await writeStdout('\\x1b[?2026h\\x1b[2J\\x1b[H')
for (const row of rows) {
await writeStdout(row + '\\r\\n')
}
await writeStdout('\\x1b[?2026l')
await writeStdout('${marker}\\r\\n')
`
}
@@ -1,5 +1,5 @@
import { randomUUID } from 'node:crypto'
import { readFileSync, rmSync, writeFileSync } from 'node:fs'
import { rmSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import type { Page, TestInfo } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
@@ -23,6 +23,14 @@ import {
} from './terminal-column-probes'
import { nodeTerminalCommand } from './terminal-node-command'
import { waitForPtyShellEcho } from './terminal-pty-readiness'
import {
EMOJI_TABLE_FIXTURE,
NARROW_TERMINAL_MAX_COLS,
emojiFixtureMarkdownTableScript,
emojiFixtureTableWidthMarker,
longMarkdownTableScript,
narrowSignerMarkdownTableScript
} from './terminal-long-table-fixtures'
type TerminalRenderDiagnostics = {
cols: number
@@ -54,255 +62,6 @@ type LongTableDebugWindow = Window & {
}
}
const EMOJI_TABLE_FIXTURE = readFileSync(
path.join(__dirname, 'fixtures', 'terminal-emoji-table.md'),
'utf8'
)
const NARROW_TERMINAL_MAX_COLS = 120
function longMarkdownTableScript(runId: string): string {
const names = [
['Sam Syntax', 'Compiler', 'Online', '😀', '9200', 'Semicolons are optional (rage ensues)'],
['Tori Token', 'Auth', 'Idle', '🚀', '4800', 'JWT expires during their standup'],
['Uma Unpin', 'Frontend', 'Online', '🔥', '3500', 'Absolute positioning enjoyer'],
['Vic Variable', 'Types', 'AFK', '💡', '6700', 'any is not a type, it is a cry for help'],
['Wally Watchdog', 'Security', 'Online', '📦', '8200', 'Found a vuln in your vuln scanner'],
['Xena XPath', 'DB', 'Idle', '🔐', '7300', 'Indexes everything, including the fridge'],
['Yuki Yank', 'CLI', 'Online', '🎯', '5900', 'rm -rf / is not a party trick'],
['Zane Zealot', 'OSS', 'Offline', '🤖', '10000', 'Contributor to 47 repos, sleeps never'],
['Artie ASCII', 'Docs', 'Online', '🧠', '2900', 'Wrote a novel in README comments'],
['Bianca Batch', 'ML', 'AFK', '💾', '9400', 'Training a model to write PR descriptions'],
['Carlos Cache', 'CDN', 'Idle', '⚙', '4900', 'Stale data is still data'],
['Diana Draft', 'Planning', 'Online', '📚', '1800', 'Needs 3 more sprints to estimate'],
['Edgar Exit', 'Ops', 'Online', '🔧', '7600', 'Graceful shutdown specialist'],
['Fiona Fallback', 'Resilience', 'Idle', '🧲', '5500', 'Circuit breaker connoisseur'],
['Gabe Garbage', 'GC', 'Offline', '🧹', '4100', 'Stop-the-world is my catchphrase'],
['Holly Hotfix', 'Release', 'Online', '🧪', '6300', 'Friday deploy champion'],
['Ira Idempotent', 'API', 'AFK', '🔁', '6900', 'PUT me in coach'],
['Jules Jitter', 'Mobile', 'Idle', '📱', '3200', 'Offline-first, coffee-second'],
['Ken Kafka', 'Streams', 'Online', '📡', '7100', 'Rebalancing is a lifestyle'],
['Luna Latency', 'Edge', 'Offline', '🧭', '4400', 'Response time measured in business days'],
['Max Marshal', 'Memory', 'Online', '🧩', '8700', "Leak-free since '24"],
['Nora Null', 'Safety', 'AFK', '❓', '3800', 'null is a person, not a value'],
['Otto Offset', 'Cursors', 'Idle', '👆', '2600', 'Infinite scroll for the infinite soul'],
['Pam Payload', 'Serialization', 'Online', '📦', '5800', 'JSON.stringify is my yoga'],
['Reed Regex', 'Matching', 'Offline', '🔍', '6800', 'Now I have two problems']
]
return `
const rows = ${JSON.stringify(names)}
const widths = [16, 14, 12, 6, 7, 42]
function isCombiningMark(codePoint) {
return (codePoint >= 0x0300 && codePoint <= 0x036f) ||
(codePoint >= 0xfe00 && codePoint <= 0xfe0f)
}
function isWideCodePoint(codePoint) {
return codePoint > 0xffff ||
(codePoint >= 0x1100 && codePoint <= 0x115f) ||
(codePoint >= 0x2e80 && codePoint <= 0xa4cf) ||
(codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
(codePoint >= 0xf900 && codePoint <= 0xfaff) ||
(codePoint >= 0xfe10 && codePoint <= 0xfe6f) ||
(codePoint >= 0xff00 && codePoint <= 0xff60) ||
(codePoint >= 0xffe0 && codePoint <= 0xffe6)
}
function cellWidth(text) {
let width = 0
for (const char of String(text)) {
const codePoint = char.codePointAt(0)
if (codePoint === undefined || isCombiningMark(codePoint)) continue
width += isWideCodePoint(codePoint) ? 2 : 1
}
return width
}
function cell(value, width) {
const text = String(value)
return text + ' '.repeat(Math.max(1, width - cellWidth(text)))
}
function line(parts) {
return '| ' + parts.map((part, index) => cell(part, widths[index])).join(' | ') + ' |'
}
const outputRows = []
outputRows.push(line(['Name', 'Team', 'Status', 'Icon', 'Score', 'Notes']))
outputRows.push('|-' + widths.map((width) => '-'.repeat(width)).join('-|-') + '-|')
for (let repeat = 0; repeat < 4; repeat += 1) {
for (const row of rows) outputRows.push(line(row))
}
async function writeStdout(chunk) {
await new Promise((resolve) => process.stdout.write(chunk, resolve))
if (process.platform === 'win32') await new Promise((resolve) => setTimeout(resolve, 8))
}
await writeStdout('\\x1b[?2026h\\x1b[2J\\x1b[H')
for (const row of outputRows) {
await writeStdout(row + '\\n')
}
await writeStdout('\\x1b[?2026l')
await writeStdout('LONG_TABLE_SCROLL_RESTORE_${runId}\\n')
`
}
function emojiFixtureMarkdownTableScript(table: string, runId: string): string {
const marker = `EMOJI_FIXTURE_TABLE_RESTORE_${runId}`
const widthMarker = `EMOJI_FIXTURE_TABLE_WIDTH_${runId}`
return `
const table = ${JSON.stringify(table)}
const minimumWidths = [2, 5, 4, 7, 7, 4, 3, 4]
const preferredWidths = [5, 17, 10, 18, 30, 12, 10, 10]
const tableOverhead = preferredWidths.length * 3 + 1
const widthBudget = Math.max(
minimumWidths.reduce((sum, width) => sum + width, 0),
Math.min(
preferredWidths.reduce((sum, width) => sum + width, 0),
(process.stdout.columns || 100) - tableOverhead - 1
)
)
let remaining = widthBudget
let remainingPreferred = preferredWidths.reduce((sum, width) => sum + width, 0)
const widths = preferredWidths.map((preferred, index) => {
const minimum = minimumWidths[index]
const width = Math.max(minimum, Math.floor((remaining * preferred) / remainingPreferred))
remaining -= width
remainingPreferred -= preferred
return width
})
const generatedTableWidth = widths.reduce((sum, width) => sum + width, 0) + tableOverhead
const border = {
top: ['┌', '┬', '┐'],
middle: ['├', '┼', '┤'],
bottom: ['└', '┴', '┘'],
vertical: '│',
horizontal: '─'
}
function splitMarkdownRow(row) {
return row.trim().slice(1, -1).split('|').map((cell) => cell.trim())
}
function isSeparatorRow(row) {
return /^\\|(?:\\s*:?-+:?\\s*\\|)+\\s*$/.test(row)
}
function cellWidth(text) {
let width = 0
for (const char of String(text)) {
const codePoint = char.codePointAt(0)
if (codePoint === undefined || (codePoint >= 0x0300 && codePoint <= 0x036f)) continue
if (codePoint === 0xfe0f || codePoint === 0x200d) continue
width += codePoint > 0xffff || (codePoint >= 0x1100 && codePoint <= 0x115f) ? 2 : 1
}
return width
}
function padCell(value, width) {
const text = String(value)
return text + ' '.repeat(Math.max(0, width - cellWidth(text)))
}
function splitToWidth(text, width) {
const parts = []
let line = ''
for (const char of String(text)) {
const next = line + char
if (line && cellWidth(next) > width) {
parts.push(line)
line = char
} else {
line = next
}
}
if (line) parts.push(line)
return parts
}
function wrapCell(value, width) {
const words = String(value).split(/\\s+/)
const lines = []
let line = ''
for (const word of words) {
if (cellWidth(word) > width) {
if (line) {
lines.push(line)
line = ''
}
lines.push(...splitToWidth(word, width))
continue
}
const next = line ? line + ' ' + word : word
if (line && cellWidth(next) > width) {
lines.push(line)
line = word
} else {
line = next
}
}
if (line) lines.push(line)
return lines.length ? lines : ['']
}
function rule(parts) {
return parts[0] + widths.map((width) => border.horizontal.repeat(width + 2)).join(parts[1]) + parts[2]
}
function renderRow(cells) {
const wrappedCells = widths.map((width, index) => wrapCell(cells[index] ?? '', width))
const height = Math.max(...wrappedCells.map((cell) => cell.length))
const rows = []
for (let line = 0; line < height; line += 1) {
rows.push(
border.vertical +
widths
.map((width, index) => ' ' + padCell(wrappedCells[index][line] ?? '', width) + ' ')
.join(border.vertical) +
border.vertical
)
}
return rows
}
async function writeStdout(chunk) {
await new Promise((resolve) => process.stdout.write(chunk, resolve))
if (process.platform === 'win32') await new Promise((resolve) => setTimeout(resolve, 8))
}
const parsedRows = table
.split(/\\r?\\n/)
.filter((row) => row.trim().startsWith('|') && !isSeparatorRow(row))
.map(splitMarkdownRow)
const rendered = [rule(border.top)]
for (const [index, row] of parsedRows.entries()) {
rendered.push(...renderRow(row))
rendered.push(rule(index === parsedRows.length - 1 ? border.bottom : border.middle))
}
await writeStdout('\\x1b[?2026h\\x1b[2J\\x1b[H')
for (const line of rendered) {
await writeStdout(line + '\\r\\n')
}
await writeStdout('\\x1b[?2026l')
await writeStdout('${widthMarker}:' + generatedTableWidth + '\\r\\n')
await writeStdout('${marker}\\r\\n')
`
}
function emojiFixtureTableWidthMarker(runId: string): string {
return `EMOJI_FIXTURE_TABLE_WIDTH_${runId}:`
}
function narrowSignerMarkdownTableScript(runId: string): string {
const marker = `NARROW_SIGNER_TABLE_RESTORE_${runId}`
const rows = [
'| # | Status | Signer | Action |',
'| ---: | --- | --- | --- |',
'| 1 | signed | did:key:z6Mkuw5kQqz1QvZ9f3d2aB7f19f0cAC7B4F3c9E725aD19cD12e6A8B3F4c5D6e7F8a9B0c1D2e3F4a5B6c7D8e9F0a1B2c3D4e5F6a7B8c9D0e1F2 | approve deployment |',
'| 2 | waiting | did:web:example.signing.service:teams:release:prod:primary-key-2026-06-08-with-extra-qualifiers-and-long-human-readable-suffix | counter-sign |',
'| 3 | signed | 0x742d35Cc6634C0532925a3b844Bc454e4438f44e9E8F12A7C4D9B6530F9D2C8E7A6B5C4D3E2F1A0B998877665544332211 | archive receipt |'
]
const repeatedRows = Array.from({ length: 8 }, (_, index) =>
rows.concat(`| ${index + 4} | signed | signer-row-${index}-${'a'.repeat(96)} | verify |`)
).flat()
return `
const rows = ${JSON.stringify(repeatedRows)}
async function writeStdout(chunk) {
await new Promise((resolve) => process.stdout.write(chunk, resolve))
if (process.platform === 'win32') await new Promise((resolve) => setTimeout(resolve, 8))
}
await writeStdout('\\x1b[?2026h\\x1b[2J\\x1b[H')
for (const row of rows) {
await writeStdout(row + '\\r\\n')
}
await writeStdout('\\x1b[?2026l')
await writeStdout('${marker}\\r\\n')
`
}
async function setNarrowTerminalViewport(page: Page): Promise<void> {
await page.setViewportSize({ width: 900, height: 820 })
await page.waitForTimeout(250)
@@ -0,0 +1,176 @@
import { randomUUID } from 'node:crypto'
import { rmSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import type { Page } from '@stablyai/playwright-test'
import { expect, test } from './helpers/orca-app'
import {
ensureTerminalVisible,
getAllWorktreeIds,
switchToWorktree,
waitForActiveWorktree,
waitForSessionReady
} from './helpers/store'
import {
getTerminalContent,
sendToTerminal,
waitForActivePanePtyId,
waitForActiveTerminalManager
} from './helpers/terminal'
import { nodeTerminalCommand } from './terminal-node-command'
import { waitForPtyShellEcho } from './terminal-pty-readiness'
type ViewportSample = { at: number; viewportY: number; baseY: number }
function scrollbackFixtureScript(runId: string): string {
return `
async function writeStdout(chunk) {
await new Promise((resolve) => process.stdout.write(chunk, resolve))
if (process.platform === 'win32') await new Promise((resolve) => setTimeout(resolve, 8))
}
await writeStdout('\\x1b[?2026h\\x1b[2J\\x1b[H')
for (let index = 0; index < 180; index += 1) {
await writeStdout('PINNED_VIEWPORT_SWITCH_${runId}_ROW_' + String(index).padStart(3, '0') + '\\n')
}
await writeStdout('\\x1b[?2026l')
await writeStdout('PINNED_VIEWPORT_SWITCH_${runId}_DONE\\n')
`
}
async function closeFeatureTips(page: Page): Promise<void> {
await page.evaluate(() => {
const store = window.__store
store?.getState().markFeatureTipsSeen(['orca-cli', 'cmd-j-palette', 'voice-dictation'])
if (store?.getState().activeModal === 'feature-tips') {
store.getState().closeModal()
}
})
}
async function pinActiveTerminalNearBottom(page: Page): Promise<{
tabId: string
targetViewportY: number
baseY: number
}> {
return page.evaluate(() => {
const store = window.__store
const state = store?.getState()
const worktreeId = state?.activeWorktreeId
const tabId =
state?.activeTabType === 'terminal'
? state.activeTabId
: worktreeId
? (state?.activeTabIdByWorktree?.[worktreeId] ?? null)
: null
const manager = tabId ? window.__paneManagers?.get(tabId) : null
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
if (!tabId || !pane) {
throw new Error('Active terminal pane unavailable')
}
const target = pane.container.querySelector<HTMLElement>('.xterm') ?? pane.container
target.dispatchEvent(
new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
deltaMode: WheelEvent.DOM_DELTA_PIXEL,
deltaY: -240
})
)
const buffer = pane.terminal.buffer.active
const targetViewportY = Math.max(0, buffer.baseY - 6)
pane.terminal.scrollToLine(targetViewportY)
pane.container
.querySelector<HTMLElement>('.xterm-viewport')
?.dispatchEvent(new Event('scroll', { bubbles: true }))
return { tabId, targetViewportY, baseY: buffer.baseY }
})
}
async function sampleTerminalViewportDuringReturn(
page: Page,
tabId: string,
durationMs: number
): Promise<ViewportSample[]> {
return page.evaluate(
({ tabId, durationMs }) =>
new Promise<ViewportSample[]>((resolve) => {
const samples: ViewportSample[] = []
const startedAt = performance.now()
const sample = (): void => {
const manager = window.__paneManagers?.get(tabId)
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
const buffer = pane?.terminal?.buffer?.active
if (buffer) {
samples.push({
at: Math.round(performance.now() - startedAt),
viewportY: buffer.viewportY,
baseY: buffer.baseY
})
}
if (performance.now() - startedAt >= durationMs) {
resolve(samples)
return
}
requestAnimationFrame(sample)
}
requestAnimationFrame(sample)
}),
{ tabId, durationMs }
)
}
test.describe('Terminal pinned viewport worktree switch', () => {
test('does not jump or flash when returning to a viewport pinned just above bottom', async ({
orcaPage,
testRepoPath
}) => {
await waitForSessionReady(orcaPage)
await closeFeatureTips(orcaPage)
const firstWorktreeId = await waitForActiveWorktree(orcaPage)
const secondWorktreeId = (await getAllWorktreeIds(orcaPage)).find(
(id) => id !== firstWorktreeId
)
test.skip(!secondWorktreeId, 'pinned viewport repro needs the seeded secondary worktree')
if (!secondWorktreeId) {
return
}
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
const ptyId = await waitForActivePanePtyId(orcaPage)
await waitForPtyShellEcho(orcaPage, ptyId, 15_000)
const runId = randomUUID()
const scriptPath = path.join(testRepoPath, `.orca-pinned-viewport-${runId}.mjs`)
writeFileSync(scriptPath, scrollbackFixtureScript(runId))
try {
await sendToTerminal(orcaPage, ptyId, `${nodeTerminalCommand([scriptPath])}\r`)
await expect
.poll(() => getTerminalContent(orcaPage, 30_000), {
timeout: 10_000,
message: 'pinned viewport fixture did not reach terminal scrollback'
})
.toContain(`PINNED_VIEWPORT_SWITCH_${runId}_DONE`)
const pinned = await pinActiveTerminalNearBottom(orcaPage)
expect(pinned.baseY).toBeGreaterThan(20)
await orcaPage.waitForTimeout(50)
await switchToWorktree(orcaPage, secondWorktreeId)
await waitForActiveTerminalManager(orcaPage, 30_000)
await orcaPage.waitForTimeout(250)
const samplesPromise = sampleTerminalViewportDuringReturn(orcaPage, pinned.tabId, 450)
await switchToWorktree(orcaPage, firstWorktreeId)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
const samples = await samplesPromise
expect(samples.length).toBeGreaterThan(0)
expect(samples.filter((sample) => sample.viewportY <= 1)).toEqual([])
expect(samples.filter((sample) => sample.viewportY >= sample.baseY - 1)).toEqual([])
expect(
samples.filter((sample) => Math.abs(sample.viewportY - pinned.targetViewportY) > 1)
).toEqual([])
} finally {
rmSync(scriptPath, { force: true })
}
})
})

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