diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index c07a2cc32e0..ce603bc764d 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -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 diff --git a/README.md b/README.md index dd6aa3d24de..eea0b479af1 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Total downloads across all releases License Join the Orca Discord + Follow Orca on X Supported platforms: macOS, Windows, and Linux

@@ -35,7 +36,7 @@ Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere. -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) @@ -228,7 +229,7 @@ yay -S stably-orca-bin Pair with your desktop app to monitor and steer your agents from your phone. - **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [Download the APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk) +- **Android:** [Download the APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk) --- diff --git a/docs/readme/README.es.md b/docs/readme/README.es.md index b15598c68bb..cb78aa38095 100644 --- a/docs/readme/README.es.md +++ b/docs/readme/README.es.md @@ -7,6 +7,7 @@ Descargas totales en todas las versiones Licencia Únete al Discord de Orca + Sigue a Orca en X Plataformas compatibles: macOS, Windows y Linux

@@ -35,7 +36,7 @@ Supervisa y dirige a tus agentes desde el teléfono — recibe una notificación cuando un agente termine y envía instrucciones de seguimiento desde cualquier lugar. -[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) +[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) @@ -226,7 +227,7 @@ yay -S stably-orca-bin Vincúlala con tu app de escritorio para supervisar y dirigir a tus agentes desde el teléfono. - **iOS:** [Descargar desde App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk) +- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk) --- diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md index 665bebbfe3f..e2d93abd1f8 100644 --- a/docs/readme/README.ja.md +++ b/docs/readme/README.ja.md @@ -7,6 +7,7 @@ 全リリースの合計ダウンロード数 ライセンス Orca の Discord に参加 + X で Orca をフォロー 対応プラットフォーム: macOS、Windows、Linux

@@ -35,7 +36,7 @@ スマートフォンからエージェントを監視・操作 — エージェントの完了を通知で受け取り、どこからでもフォローアップを送信できます。 -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile) @@ -226,7 +227,7 @@ yay -S stably-orca-bin デスクトップアプリとペアリングして、スマートフォンからエージェントを監視・操作できます。 - **iOS:** [App Store からダウンロード](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk) +- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk) --- diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md index 5771cf35b49..4a63f512b9f 100644 --- a/docs/readme/README.ko.md +++ b/docs/readme/README.ko.md @@ -7,6 +7,7 @@ 전체 릴리스 누적 다운로드 수 라이선스 Orca Discord 참여 + X에서 Orca 팔로우 지원 플랫폼: macOS, Windows, Linux

@@ -35,7 +36,7 @@ 휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다. -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile) @@ -226,7 +227,7 @@ yay -S stably-orca-bin 데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요. - **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk) +- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk) --- diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index 05c3f462019..b3e97dd77cf 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -7,6 +7,7 @@ 所有版本的总下载量 许可证 加入 Orca Discord + 在 X 上关注 Orca 支持的平台:macOS、Windows 和 Linux

@@ -35,7 +36,7 @@ 用手机监控并指挥你的智能体 — 智能体完成时收到通知,随时随地发送后续指令。 -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile) @@ -226,7 +227,7 @@ yay -S stably-orca-bin 与桌面应用配对,用手机监控并指挥你的智能体。 - **iOS:** [从 App Store 下载](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk) +- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk) --- diff --git a/docs/terminal-scroll-intent-architecture.md b/docs/terminal-scroll-intent-architecture.md new file mode 100644 index 00000000000..b686f240546 --- /dev/null +++ b/docs/terminal-scroll-intent-architecture.md @@ -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. diff --git a/package.json b/package.json index 8a40788458a..0375d9786ed 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.97", + "version": "1.4.98-rc.0", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", diff --git a/src/main/agent-hooks/remote-hook-service-installers.test.ts b/src/main/agent-hooks/remote-hook-service-installers.test.ts index f3a288f5536..3cd61db5556 100644 --- a/src/main/agent-hooks/remote-hook-service-installers.test.ts +++ b/src/main/agent-hooks/remote-hook-service-installers.test.ts @@ -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 + _managed?: unknown } + expect(hooks._managed).toEqual({ 'external-manager': { Stop: [0] } }) for (const eventName of [ 'SessionStart', 'UserPromptSubmit', diff --git a/src/main/ai-vault/session-scanner-scope-discovery.ts b/src/main/ai-vault/session-scanner-scope-discovery.ts new file mode 100644 index 00000000000..7fae046cff3 --- /dev/null +++ b/src/main/ai-vault/session-scanner-scope-discovery.ts @@ -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//` 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 + issues: AiVaultScanIssue[] +}): Promise { + if (args.scopePaths.length === 0 || args.limit <= 0) { + return [] + } + const scopeProjectPrefixes = claudeProjectScopePrefixes(args.scopePaths) + const collected = new Map() + 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 { + const prefixes = new Set() + 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) { + 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 +): Promise { + 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 { + 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 { + 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 { + 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 + limit: number + excludedFilePaths: ReadonlySet +}): Promise { + 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, + 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(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 + } +} diff --git a/src/main/ai-vault/session-scanner-scope.test.ts b/src/main/ai-vault/session-scanner-scope.test.ts new file mode 100644 index 00000000000..ec98563898c --- /dev/null +++ b/src/main/ai-vault/session-scanner-scope.test.ts @@ -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) { + 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 { + 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') + }) +}) diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts index fc7cf389b7d..b6c6ae96412 100644 --- a/src/main/ai-vault/session-scanner-types.ts +++ b/src/main/ai-vault/session-scanner-types.ts @@ -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 } diff --git a/src/main/ai-vault/session-scanner.ts b/src/main/ai-vault/session-scanner.ts index 484dbbc6339..181bf1466ea 100644 --- a/src/main/ai-vault/session-scanner.ts +++ b/src/main/ai-vault/session-scanner.ts @@ -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() + 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 + platform: NodeJS.Platform + issues: AiVaultScanIssue[] +}): Promise { + 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 diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index fd09a86fc08..cfee7b8ad10 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -144,6 +144,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings terminalWindowsPowerShellImplementation: 'powershell.exe', enableGitHubAttribution: true, ...overrides, + diffWordWrap: overrides.diffWordWrap ?? false, localWindowsRuntimeDefault: overrides.localWindowsRuntimeDefault ?? { kind: 'windows-host' }, leftSidebarAppearanceMode: overrides.leftSidebarAppearanceMode ?? 'default', appFontFamily, diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index 3d899b25eae..1f52a6470a0 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -148,6 +148,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings terminalWindowsPowerShellImplementation: 'powershell.exe', enableGitHubAttribution: true, ...overrides, + diffWordWrap: overrides.diffWordWrap ?? false, localWindowsRuntimeDefault: overrides.localWindowsRuntimeDefault ?? { kind: 'windows-host' }, leftSidebarAppearanceMode: overrides.leftSidebarAppearanceMode ?? 'default', appFontFamily, diff --git a/src/main/codex/config-toml-trust.test.ts b/src/main/codex/config-toml-trust.test.ts index 749a40f56d3..dcc33693037 100644 --- a/src/main/codex/config-toml-trust.test.ts +++ b/src/main/codex/config-toml-trust.test.ts @@ -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 = [ diff --git a/src/main/codex/config-toml-trust.ts b/src/main/codex/config-toml-trust.ts index 0b52b9edc2c..2993f646a34 100644 --- a/src/main/codex/config-toml-trust.ts +++ b/src/main/codex/config-toml-trust.ts @@ -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 { + 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 { - const result = new Map() + const result = new HookTrustEntryMap() if (!existsSync(configPath)) { return result } @@ -707,7 +757,7 @@ export function readHookTrustEntries(configPath: string): Map { 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 + _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 + _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 + _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') diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index 15c415f94c0..9bab400080f 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -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): 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 diff --git a/src/main/github/work-item-details.test.ts b/src/main/github/work-item-details.test.ts index 2a7dc2fda85..a36ac97e0fa 100644 --- a/src/main/github/work-item-details.test.ts +++ b/src/main/github/work-item-details.test.ts @@ -81,58 +81,215 @@ describe('getWorkItemDetails', () => { acquireMock.mockResolvedValue(undefined) }) - it('uses the collapsed GraphQL issue query as the hot path', async () => { + it('uses the collapsed GraphQL issue query with timeline activity enrichment', async () => { getWorkItemMock.mockResolvedValueOnce({ id: 'issue:923', type: 'issue', number: 923, title: 'Use upstream issues', state: 'open', - url: 'https://github.com/stablyai/orca/issues/923', + url: 'https://github.com/acme/widgets/issues/923', labels: [], updatedAt: '2026-04-01T00:00:00Z', - author: 'octocat' + author: 'issue-author' }) - getIssueOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify({ - data: { - repository: { - issue: { - body: 'Issue body', - assignees: { nodes: [{ login: 'jinjing' }] }, - participants: { - nodes: [{ login: 'octocat', avatarUrl: 'https://x/y', name: 'Octo Cat' }] - }, - comments: { - nodes: [ - { - databaseId: 7, - body: 'first', - createdAt: '2026-04-01T00:00:00Z', - url: 'https://github.com/stablyai/orca/issues/923#issuecomment-7', - author: { login: 'octocat', avatarUrl: 'https://x/y' } - } - ] + getIssueOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + const timelineEvents = [ + { + id: 101, + event: 'assigned', + actor: { login: 'timeline-actor', avatar_url: 'https://x/timeline-actor' }, + assignee: { login: 'assigned-user' }, + created_at: '2026-04-01T01:00:00Z' + }, + { + id: 102, + event: 'cross-referenced', + actor: { login: 'timeline-actor', avatar_url: 'https://x/timeline-actor' }, + created_at: '2026-04-01T02:00:00Z', + source: { + issue: { + number: 6180, + title: 'Synthetic reference PR', + html_url: 'https://github.com/acme/widgets/pull/6180', + repository: { owner: { login: 'acme' }, name: 'widgets' }, + pull_request: {} + } + } + }, + { + id: 103, + event: 'moved_columns_in_project', + actor: { login: 'github-project-automation', avatar_url: 'https://x/bot' }, + created_at: '2026-04-01T03:00:00Z', + previous_column_name: 'Doing', + project_column_name: 'Complete', + project: { name: 'Example Project' } + }, + { + id: 104, + event: 'closed', + actor: { login: 'timeline-actor', avatar_url: 'https://x/timeline-actor' }, + created_at: '2026-04-01T04:00:00Z', + state_reason: 'completed', + closer: { + number: 6180, + title: 'Synthetic reference PR', + html_url: 'https://github.com/acme/widgets/pull/6180', + repository: { owner: { login: 'acme' }, name: 'widgets' }, + pull_request: {} + } + } + ] + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + data: { + repository: { + issue: { + body: 'Issue body', + assignees: { nodes: [{ login: 'assigned-user' }] }, + participants: { + nodes: [{ login: 'issue-author', avatarUrl: 'https://x/y', name: 'Issue Author' }] + }, + comments: { + nodes: [ + { + databaseId: 7, + body: 'first', + createdAt: '2026-04-01T00:00:00Z', + url: 'https://github.com/acme/widgets/issues/923#issuecomment-7', + author: { login: 'issue-author', avatarUrl: 'https://x/y' } + } + ] + } } } } - } + }) + }) + .mockResolvedValueOnce({ + stdout: timelineEvents.map((event) => JSON.stringify(event)).join('\n') }) - }) const details = await getWorkItemDetails('/repo-root', 923, 'issue') expect(getWorkItemMock).toHaveBeenCalledWith('/repo-root', 923, 'issue', undefined) - // Why: a single gh subprocess call replaces the previous REST + REST + GraphQL fan-out. - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) expect(ghExecFileAsyncMock.mock.calls[0][0][0]).toBe('api') expect(ghExecFileAsyncMock.mock.calls[0][0][1]).toBe('graphql') + expect(ghExecFileAsyncMock.mock.calls[1][0]).toEqual([ + 'api', + '--cache', + '60s', + 'repos/acme/widgets/issues/923/timeline?per_page=100&page=1', + '--jq', + '.[] | @json' + ]) expect(details?.body).toBe('Issue body') - expect(details?.assignees).toEqual(['jinjing']) + expect(details?.assignees).toEqual(['assigned-user']) expect(details?.comments).toHaveLength(1) expect(details?.comments[0].id).toBe(7) - expect(details?.participants?.[0]?.login).toBe('octocat') + expect(details?.timelineItems).toMatchObject([ + { event: 'assigned', actor: 'timeline-actor', assignee: 'assigned-user' }, + { + event: 'cross-referenced', + source: { + type: 'pr', + number: 6180, + repository: 'acme/widgets' + } + }, + { + event: 'moved_columns_in_project', + actor: 'github-project-automation', + previousColumnName: 'Doing', + columnName: 'Complete', + projectName: 'Example Project' + }, + { + event: 'closed', + stateReason: 'completed', + closer: { type: 'pr', number: 6180 } + } + ]) + expect(details?.participants?.[0]?.login).toBe('issue-author') + }) + + it('caps issue timeline pagination by supported activity items', async () => { + getWorkItemMock.mockResolvedValueOnce({ + id: 'issue:923', + type: 'issue', + number: 923, + title: 'Use upstream issues', + state: 'open', + url: 'https://github.com/acme/widgets/issues/923', + labels: [], + updatedAt: '2026-04-01T00:00:00Z', + author: 'issue-author' + }) + getIssueOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + const makeTimelineEvent = (page: number, index: number, event: string): string => + JSON.stringify({ + id: `${page}:${index}`, + event, + actor: { login: 'issue-author', avatar_url: 'https://x/y' }, + assignee: { login: `assignee-${page}-${index}` }, + created_at: '2026-04-01T00:00:00Z' + }) + const makeTimelinePage = (page: number, supportedCount: number): string => + Array.from({ length: 100 }, (_, index) => + makeTimelineEvent(page, index, index < supportedCount ? 'assigned' : 'subscribed') + ).join('\n') + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + data: { + repository: { + issue: { + body: 'Issue body', + assignees: { nodes: [] }, + participants: { nodes: [] }, + comments: { nodes: [] } + } + } + } + }) + }) + .mockResolvedValueOnce({ stdout: makeTimelinePage(1, 0) }) + .mockResolvedValueOnce({ stdout: makeTimelinePage(2, 0) }) + .mockResolvedValueOnce({ stdout: makeTimelinePage(3, 10) }) + .mockResolvedValueOnce({ stdout: makeTimelinePage(4, 100) }) + .mockResolvedValueOnce({ stdout: makeTimelinePage(5, 100) }) + .mockResolvedValueOnce({ stdout: makeTimelinePage(6, 100) }) + + const details = await getWorkItemDetails('/repo-root', 923, 'issue') + + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(7) + expect(ghExecFileAsyncMock.mock.calls[1][0]).toContain( + 'repos/acme/widgets/issues/923/timeline?per_page=100&page=1' + ) + expect(ghExecFileAsyncMock.mock.calls[2][0]).toContain( + 'repos/acme/widgets/issues/923/timeline?per_page=100&page=2' + ) + expect(ghExecFileAsyncMock.mock.calls[3][0]).toContain( + 'repos/acme/widgets/issues/923/timeline?per_page=100&page=3' + ) + expect(ghExecFileAsyncMock.mock.calls[6][0]).toContain( + 'repos/acme/widgets/issues/923/timeline?per_page=100&page=6' + ) + expect( + ghExecFileAsyncMock.mock.calls.some((call) => + call[0].includes('repos/acme/widgets/issues/923/timeline?per_page=100&page=7') + ) + ).toBe(false) + const timelineItems = details?.timelineItems + expect(timelineItems).toHaveLength(300) + if (!timelineItems) { + throw new Error('Expected timeline items to be present') + } + expect(timelineItems.at(0)).toMatchObject({ assignee: 'assignee-3-0' }) + expect(timelineItems.at(-1)).toMatchObject({ assignee: 'assignee-6-89' }) }) it('falls back to REST + GraphQL when the collapsed issue query fails', async () => { @@ -142,17 +299,18 @@ describe('getWorkItemDetails', () => { number: 923, title: 'Use upstream issues', state: 'open', - url: 'https://github.com/stablyai/orca/issues/923', + url: 'https://github.com/acme/widgets/issues/923', labels: [], updatedAt: '2026-04-01T00:00:00Z', - author: 'octocat' + author: 'issue-author' }) - getIssueOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' }) + getIssueOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) // Collapsed GraphQL throws → fallback path picks up. ghExecFileAsyncMock .mockRejectedValueOnce(new Error('GraphQL error')) .mockResolvedValueOnce({ stdout: JSON.stringify({ body: 'Issue body' }) }) .mockResolvedValueOnce({ stdout: '[]' }) + .mockResolvedValueOnce({ stdout: '[]' }) .mockResolvedValueOnce({ stdout: JSON.stringify({ data: { repository: { issue: { participants: { nodes: [] } } } } @@ -166,12 +324,24 @@ describe('getWorkItemDetails', () => { expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 2, - ['api', '--cache', '60s', 'repos/stablyai/orca/issues/923'], + ['api', '--cache', '60s', 'repos/acme/widgets/issues/923'], { cwd: '/repo-root' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 3, - ['api', '--cache', '60s', 'repos/stablyai/orca/issues/923/comments?per_page=100'], + ['api', '--cache', '60s', 'repos/acme/widgets/issues/923/comments?per_page=100'], + { cwd: '/repo-root' } + ) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 4, + [ + 'api', + '--cache', + '60s', + 'repos/acme/widgets/issues/923/timeline?per_page=100&page=1', + '--jq', + '.[] | @json' + ], { cwd: '/repo-root' } ) expect(details?.body).toBe('Issue body') @@ -190,19 +360,20 @@ describe('getWorkItemDetails', () => { number: 923, title: 'Use upstream issues', state: 'open', - url: 'https://github.com/stablyai/orca/issues/923', + url: 'https://github.com/acme/widgets/issues/923', labels: [], updatedAt: '2026-04-01T00:00:00Z', - author: 'octocat' + author: 'issue-author' }) - getIssueOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' }) + getIssueOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify({ body: 'Issue body', assignees: [] }) }) .mockResolvedValueOnce({ stdout: '[]' }) + .mockResolvedValueOnce({ stdout: '[]' }) const details = await getWorkItemDetails('/repo-root', 923, 'issue') - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(3) expect(ghExecFileAsyncMock.mock.calls.some((call) => call[0][1] === 'graphql')).toBe(false) expect(noteRateLimitSpendMock).not.toHaveBeenCalled() expect(details?.body).toBe('Issue body') @@ -216,31 +387,33 @@ describe('getWorkItemDetails', () => { number: 923, title: 'Use upstream issues', state: 'open', - url: 'https://github.com/stablyai/orca/issues/923', + url: 'https://github.com/acme/widgets/issues/923', labels: [], updatedAt: '2026-04-01T00:00:00Z', - author: 'octocat' + author: 'issue-author' }) - getIssueOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify({ - data: { - repository: { - issue: { - body: 'Remote issue body', - assignees: { nodes: [] }, - participants: { nodes: [] }, - comments: { nodes: [] } + getIssueOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + data: { + repository: { + issue: { + body: 'Remote issue body', + assignees: { nodes: [] }, + participants: { nodes: [] }, + comments: { nodes: [] } + } } } - } + }) }) - }) + .mockResolvedValueOnce({ stdout: '[]' }) - const details = await getWorkItemDetails('/home/jinwoo/orca', 923, 'issue', 'openclaw-2') + const details = await getWorkItemDetails('/home/tester/widgets', 923, 'issue', 'ssh-test-1') - expect(getWorkItemMock).toHaveBeenCalledWith('/home/jinwoo/orca', 923, 'issue', 'openclaw-2') - expect(getIssueOwnerRepoMock).toHaveBeenCalledWith('/home/jinwoo/orca', 'openclaw-2') + expect(getWorkItemMock).toHaveBeenCalledWith('/home/tester/widgets', 923, 'issue', 'ssh-test-1') + expect(getIssueOwnerRepoMock).toHaveBeenCalledWith('/home/tester/widgets', 'ssh-test-1') expect(ghExecFileAsyncMock.mock.calls[0][1]).toEqual({}) expect(details?.body).toBe('Remote issue body') }) @@ -253,17 +426,17 @@ describe('getWorkItemDetails', () => { number: 42, title: 'Review drawer WSL', state: 'open', - url: 'https://github.com/stablyai/orca/pull/42', + url: 'https://github.com/acme/widgets/pull/42', labels: [], updatedAt: '2026-04-01T00:00:00Z', - author: 'octocat' + author: 'pr-author' }) - getOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' }) + getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) getPRCommentsMock.mockResolvedValue([]) getPRChecksMock.mockResolvedValue([]) ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { const target = args.at(-1) - if (target === 'repos/stablyai/orca/pulls/42') { + if (target === 'repos/acme/widgets/pulls/42') { return { stdout: JSON.stringify({ body: 'PR body', @@ -272,7 +445,7 @@ describe('getWorkItemDetails', () => { }) } } - if (target === 'repos/stablyai/orca/pulls/42/files?per_page=100') { + if (target === 'repos/acme/widgets/pulls/42/files?per_page=100') { return { stdout: '[]' } } const query = args.find((arg) => arg.startsWith('query=')) ?? '' diff --git a/src/main/github/work-item-details.ts b/src/main/github/work-item-details.ts index e79f13577a3..6029613fb3d 100644 --- a/src/main/github/work-item-details.ts +++ b/src/main/github/work-item-details.ts @@ -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 +): Promise { + 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 } } diff --git a/src/main/ipc/ai-vault.ts b/src/main/ipc/ai-vault.ts index 5e0c839ee6b..f001579440d 100644 --- a/src/main/ipc/ai-vault.ts +++ b/src/main/ipc/ai-vault.ts @@ -22,7 +22,11 @@ let inflightKey: string | null = null let handlerOptions: AiVaultHandlerOptions = {} async function listAiVaultSessions(args?: AiVaultListArgs): Promise { - 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 scanAiVaultSessions({ limit: args?.limit, + scopePaths: args?.scopePaths, additionalCodexSessionsDirs, wslHomeDirs: await getAiVaultWslHomeDirs() }))() diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index 43d048a2290..b2c008071bf 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -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', diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 6163cd9749e..bdca932b335 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -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 } : {}), diff --git a/src/main/repo-git-remote-identity-enrichment.test.ts b/src/main/repo-git-remote-identity-enrichment.test.ts new file mode 100644 index 00000000000..81a8d2dbd55 --- /dev/null +++ b/src/main/repo-git-remote-identity-enrichment.test.ts @@ -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, '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 { + 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 } { + 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(): { + promise: Promise + resolve: (value: T) => void +} { + let resolve!: (value: T) => void + const promise = new Promise((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() + 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() + 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() + }) +}) diff --git a/src/main/repo-git-remote-identity-enrichment.ts b/src/main/repo-git-remote-identity-enrichment.ts new file mode 100644 index 00000000000..166b4d0bec8 --- /dev/null +++ b/src/main/repo-git-remote-identity-enrichment.ts @@ -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, 'gitRemoteIdentity'>): Repo | null +} + +type EnrichmentOptions = { + onChanged?: () => void +} + +const inFlightProbesByLocation = new Map>() +const noIdentityRetryAfterByLocation = new Map() + +function getRepoLocationKey(repo: Pick): 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 { + 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 { + 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 { + await Promise.all(inFlightProbesByLocation.values()) +} + +export function resetRepoGitRemoteIdentityEnrichmentForTests(): void { + inFlightProbesByLocation.clear() + noIdentityRetryAfterByLocation.clear() +} diff --git a/src/main/repo-git-remote-identity.ts b/src/main/repo-git-remote-identity.ts new file mode 100644 index 00000000000..f5ba64a301a --- /dev/null +++ b/src/main/repo-git-remote-identity.ts @@ -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 { + 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 + } +} diff --git a/src/main/repo-icon-autodetect.test.ts b/src/main/repo-icon-autodetect.test.ts index ea2ea2ea8cb..9788050dfc8 100644 --- a/src/main/repo-icon-autodetect.test.ts +++ b/src/main/repo-icon-autodetect.test.ts @@ -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 + }) + }) }) diff --git a/src/main/repo-icon-autodetect.ts b/src/main/repo-icon-autodetect.ts index f9f0b52a6fd..0b31ea761cc 100644 --- a/src/main/repo-icon-autodetect.ts +++ b/src/main/repo-icon-autodetect.ts @@ -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 } : {}) } } diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index fef93b96e19..931aa167a8f 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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?.() ?? [] } diff --git a/src/main/runtime/orchestration/coordinator.test.ts b/src/main/runtime/orchestration/coordinator.test.ts index da82c14bd19..5979306d16c 100644 --- a/src/main/runtime/orchestration/coordinator.test.ts +++ b/src/main/runtime/orchestration/coordinator.test.ts @@ -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() diff --git a/src/main/runtime/orchestration/coordinator.ts b/src/main/runtime/orchestration/coordinator.ts index 587dc4db7a8..897290c329e 100644 --- a/src/main/runtime/orchestration/coordinator.ts +++ b/src/main/runtime/orchestration/coordinator.ts @@ -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) + } } } diff --git a/src/main/runtime/orchestration/lifecycle-reconciliation.ts b/src/main/runtime/orchestration/lifecycle-reconciliation.ts index 9036c145534..a413924c59a 100644 --- a/src/main/runtime/orchestration/lifecycle-reconciliation.ts +++ b/src/main/runtime/orchestration/lifecycle-reconciliation.ts @@ -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' } diff --git a/src/main/runtime/rpc/methods/orchestration.test.ts b/src/main/runtime/rpc/methods/orchestration.test.ts index 368b77b2072..8fd0da2bfa6 100644 --- a/src/main/runtime/rpc/methods/orchestration.test.ts +++ b/src/main/runtime/rpc/methods/orchestration.test.ts @@ -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', () => { diff --git a/src/main/runtime/rpc/methods/orchestration.ts b/src/main/runtime/rpc/methods/orchestration.ts index 64a0b9969a7..705791047cf 100644 --- a/src/main/runtime/rpc/methods/orchestration.ts +++ b/src/main/runtime/rpc/methods/orchestration.ts @@ -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 } diff --git a/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts b/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts index 5e986e6d129..53eb1644819 100644 --- a/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts +++ b/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts @@ -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', diff --git a/src/main/runtime/rpc/methods/repo.ts b/src/main/runtime/rpc/methods/repo.ts index 580feeddd44..0fcf5226444 100644 --- a/src/main/runtime/rpc/methods/repo.ts +++ b/src/main/runtime/rpc/methods/repo.ts @@ -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({ diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index cd85c6c9f91..57346996889 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -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; diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index a417419855e..1ff788a6c95 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -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 ( + + ) + } + + 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')}{' '} + {assignee} + + ) + } + if (activity.event === 'unassigned') { + return ( + <> + {translate('auto.components.GitHubItemDialog.timeline.unassigned', 'unassigned')}{' '} + {assignee} + + ) + } + 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')}{' '} + {activity.previousColumnName} + + ) : null} + {hasTo ? ( + <> + {' '} + {translate('auto.components.GitHubItemDialog.timeline.to', 'to')}{' '} + {activity.columnName} + + ) : null} + {activity.projectName ? ( + <> + {' '} + {translate('auto.components.GitHubItemDialog.timeline.in', 'in')}{' '} + {activity.projectName} + + ) : 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 ( +
+ + + + {activity.actorAvatarUrl ? ( + + ) : null} +
+
+ {activity.actor} + {renderTimelineActivityMessage(activity)} + + {formatRelativeTime(activity.createdAt)} + +
+
+
+ ) + } + + const renderIssueConversationEntry = (entry: IssueConversationEntry): React.JSX.Element => + entry.kind === 'comment' + ? renderCommentCard(entry.comment) + : renderTimelineActivity(entry.activity) + return (
- + {item.type === 'issue' ? ( + + ) : ( + + )} - {translate('auto.components.GitHubItemDialog.1506916c09', 'Comments')} + {item.type === 'issue' + ? translate('auto.components.GitHubItemDialog.timeline.activity', 'Activity') + : translate('auto.components.GitHubItemDialog.1506916c09', 'Comments')} - {comments.length > 0 && ( + {comments.length + (item.type === 'issue' ? resolvedTimelineItems.length : 0) > 0 && ( - {comments.length} + {comments.length + (item.type === 'issue' ? resolvedTimelineItems.length : 0)} )}
@@ -3342,7 +3569,20 @@ function ConversationTab({
)} - {comments.length === 0 ? ( + {item.type === 'issue' ? ( + issueConversationEntries.length === 0 ? ( +
+ {translate( + 'auto.components.GitHubItemDialog.timeline.noActivity', + 'No activity yet.' + )} +
+ ) : ( +
+ {issueConversationEntries.map(renderIssueConversationEntry)} +
+ ) + ) : comments.length === 0 ? (
{translate('auto.components.GitHubItemDialog.5a94f3d0e9', 'No comments yet.')}
@@ -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([]) @@ -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>(() => 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} diff --git a/src/renderer/src/components/NewWorkspaceComposerModal.tsx b/src/renderer/src/components/NewWorkspaceComposerModal.tsx index 9480d94f698..7923a3ad7a9 100644 --- a/src/renderer/src/components/NewWorkspaceComposerModal.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerModal.tsx @@ -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(() => { diff --git a/src/renderer/src/components/comment-reply-target-state.test.ts b/src/renderer/src/components/comment-reply-target-state.test.ts index bc4c050fa8e..f19cc94a620 100644 --- a/src/renderer/src/components/comment-reply-target-state.test.ts +++ b/src/renderer/src/components/comment-reply-target-state.test.ts @@ -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 } + ]) + }) }) diff --git a/src/renderer/src/components/comment-reply-target-state.ts b/src/renderer/src/components/comment-reply-target-state.ts index f25d9a69468..c6027d83321 100644 --- a/src/renderer/src/components/comment-reply-target-state.ts +++ b/src/renderer/src/components/comment-reply-target-state.ts @@ -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[] diff --git a/src/renderer/src/components/editor/CombinedDiffViewer.tsx b/src/renderer/src/components/editor/CombinedDiffViewer.tsx index f140c5b392b..61dc1ffec0e 100644 --- a/src/renderer/src/components/editor/CombinedDiffViewer.tsx +++ b/src/renderer/src/components/editor/CombinedDiffViewer.tsx @@ -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')} + diff --git a/src/renderer/src/components/editor/DiffSectionBody.tsx b/src/renderer/src/components/editor/DiffSectionBody.tsx index 7792e902e17..87994196dc6 100644 --- a/src/renderer/src/components/editor/DiffSectionBody.tsx +++ b/src/renderer/src/components/editor/DiffSectionBody.tsx @@ -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 @@ -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, diff --git a/src/renderer/src/components/editor/DiffSectionItem.tsx b/src/renderer/src/components/editor/DiffSectionItem.tsx index 8cbe52dd7cb..71fe21cf983 100644 --- a/src/renderer/src/components/editor/DiffSectionItem.tsx +++ b/src/renderer/src/components/editor/DiffSectionItem.tsx @@ -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} diff --git a/src/renderer/src/components/editor/DiffViewer.tsx b/src/renderer/src/components/editor/DiffViewer.tsx index 256810494f3..7f58688754f 100644 --- a/src/renderer/src/components/editor/DiffViewer.tsx +++ b/src/renderer/src/components/editor/DiffViewer.tsx @@ -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, diff --git a/src/renderer/src/components/editor/EditorPanelHeader.tsx b/src/renderer/src/components/editor/EditorPanelHeader.tsx index 0218c0dd058..f47c9190ccd 100644 --- a/src/renderer/src/components/editor/EditorPanelHeader.tsx +++ b/src/renderer/src/components/editor/EditorPanelHeader.tsx @@ -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({ )} void updateSettings({ diffWordWrap: !diffWordWrap })} onToggleMarkdownFrontmatter={onToggleMarkdownFrontmatter} onExportMarkdownToPdf={onExportMarkdownToPdf} /> diff --git a/src/renderer/src/components/editor/EditorPanelMarkdownActionsMenu.tsx b/src/renderer/src/components/editor/EditorPanelMarkdownActionsMenu.tsx index 0f71ae150e9..e7232096b8e 100644 --- a/src/renderer/src/components/editor/EditorPanelMarkdownActionsMenu.tsx +++ b/src/renderer/src/components/editor/EditorPanelMarkdownActionsMenu.tsx @@ -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({ + {isDiffSurface ? ( + <> + + {translate( + 'auto.components.editor.EditorPanelMarkdownActionsMenu.1eef809708', + 'Word Wrap' + )} + + {hasMarkdownActions ? : null} + + ) : null} {canShowMarkdownFrontmatterToggle ? ( <> ): 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) + }) +}) diff --git a/src/renderer/src/components/editor/combined-diff-section-cache-match.ts b/src/renderer/src/components/editor/combined-diff-section-cache-match.ts new file mode 100644 index 00000000000..a53e3a1aa43 --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff-section-cache-match.ts @@ -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 + ) + }) + ) +} diff --git a/src/renderer/src/components/editor/diff-editor-word-wrap-options.test.ts b/src/renderer/src/components/editor/diff-editor-word-wrap-options.test.ts new file mode 100644 index 00000000000..9d542cae07b --- /dev/null +++ b/src/renderer/src/components/editor/diff-editor-word-wrap-options.test.ts @@ -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' }) + }) +}) diff --git a/src/renderer/src/components/editor/diff-editor-word-wrap-options.ts b/src/renderer/src/components/editor/diff-editor-word-wrap-options.ts new file mode 100644 index 00000000000..c7f1e2cbea2 --- /dev/null +++ b/src/renderer/src/components/editor/diff-editor-word-wrap-options.ts @@ -0,0 +1,9 @@ +import type { editor } from 'monaco-editor' + +export function buildDiffEditorWordWrapOptions( + diffWordWrap: boolean | undefined +): Pick { + return { + wordWrap: diffWordWrap === true ? 'on' : 'off' + } +} diff --git a/src/renderer/src/components/mobile/mobile-platform-copy.ts b/src/renderer/src/components/mobile/mobile-platform-copy.ts index a5a3d5660ed..1539a590535 100644 --- a/src/renderer/src/components/mobile/mobile-platform-copy.ts +++ b/src/renderer/src/components/mobile/mobile-platform-copy.ts @@ -23,6 +23,6 @@ export const PLATFORM_COPY: Record< ) }, ctaLabel: 'Download APK', - url: 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk' + url: 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk' } } diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx index 39ac366ed62..5b00eaace39 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx @@ -1,10 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' -import { - buildAiVaultResumeCommandForWorktree, - buildAiVaultResumeStartupForWorktree -} from '@/lib/ai-vault-resume-command' -import { launchAiVaultSessionInNewTab } from '@/lib/launch-ai-vault-session' import { useAppStore } from '@/store' import { useActiveRepo, @@ -14,23 +9,28 @@ import { useRepoById, useRepos } from '@/store/selectors' +import { filterAiVaultSessions, groupAiVaultSessions } from './ai-vault-session-filters' import { - agentLabel, - deriveAiVaultWorkspaceScopePaths, - filterAiVaultSessions, - groupAiVaultSessions -} from './ai-vault-session-filters' + deriveAiVaultScopeSessionPaths, + deriveAiVaultWorkspaceScopePaths +} from './ai-vault-scope-paths' import { DEFAULT_AI_VAULT_SCOPE, getRestorableAiVaultScope, normalizeAiVaultScopeForContext } from './ai-vault-scope-state' import { buildAiVaultProjectContext } from './ai-vault-session-projects' +import { + resolveAiVaultSessionResumeActions, + resolveAiVaultSessionResumeState +} from './ai-vault-session-resume' +import { useAiVaultSessionLaunchActions } from './ai-vault-session-launch-actions' +import { useAiVaultSessionWorktreeMap } from './ai-vault-session-worktree' +import { useAiVaultOriginalPaneActions } from './ai-vault-original-pane-actions' import { AI_VAULT_AGENTS, type AiVaultAgent, type AiVaultGroup, - type AiVaultListResult, type AiVaultScope, type AiVaultSession, type AiVaultSort @@ -39,8 +39,7 @@ import { getLocalExecutionHostLabel } from '../../../../shared/execution-host' import { translate } from '@/i18n/i18n' import { AiVaultPanelHeader } from './AiVaultPanelHeader' import { AiVaultSessionVirtualList } from './AiVaultSessionVirtualList' - -const SESSION_LIMIT = 500 +import { useAiVaultSessionRefresh } from './ai-vault-session-refresh' export default function AiVaultPanel(): React.JSX.Element { const activeWorktree = useActiveWorktree() @@ -50,21 +49,16 @@ export default function AiVaultPanel(): React.JSX.Element { const allWorktrees = useAllWorktrees() const projectHostSetupProjection = useProjectHostSetupProjection() const settings = useAppStore((s) => s.settings) - const agentCmdOverrides = settings?.agentCmdOverrides ?? {} + const agentCmdOverrides = settings?.agentCmdOverrides + const { getOriginalPaneTarget, jumpToOriginalPane, jumpToWorktree } = + useAiVaultOriginalPaneActions() const [query, setQuery] = useState('') const [scope, setScope] = useState(DEFAULT_AI_VAULT_SCOPE) const [sort, setSort] = useState('updated') const [group, setGroup] = useState('project') const [hideEmptySessions, setHideEmptySessions] = useState(true) const [agents, setAgents] = useState([...AI_VAULT_AGENTS]) - const [sessions, setSessions] = useState([]) - const [scanResult, setScanResult] = useState(null) - const [loading, setLoading] = useState(false) - const [error, setError] = useState(null) const [collapsedGroups, setCollapsedGroups] = useState>(() => new Set()) - const refreshIdRef = useRef(0) - const refreshInFlightRef = useRef(false) - const mountedRef = useRef(true) const userChangedScopeRef = useRef(false) const preferredScopeRef = useRef(DEFAULT_AI_VAULT_SCOPE) @@ -75,7 +69,31 @@ export default function AiVaultPanel(): React.JSX.Element { () => deriveAiVaultWorkspaceScopePaths(activeWorktree ?? null, allWorktrees), [activeWorktree, allWorktrees] ) - const projectContext = useMemo( + const projectScopeContext = useMemo( + () => + buildAiVaultProjectContext({ + repos, + worktrees: allWorktrees, + projectHostSetupProjection, + activeRepo, + activeWorktree, + sessions: [] + }), + [activeRepo, activeWorktree, allWorktrees, projectHostSetupProjection, repos] + ) + const activeProjectKey = projectScopeContext.activeProjectKey + const projectLabelByKey = projectScopeContext.projectLabelByKey + // Sent to the scanner so scoped views surface sessions older than the global cap. + const scopePaths = useMemo( + () => + deriveAiVaultScopeSessionPaths(activeWorktree ?? null, allWorktrees, { + activeProjectKey, + projectHostSetupProjection + }), + [activeProjectKey, activeWorktree, allWorktrees, projectHostSetupProjection] + ) + const { error, loading, refresh, scanResult, sessions } = useAiVaultSessionRefresh(scopePaths) + const sessionProjectById = useMemo( () => buildAiVaultProjectContext({ repos, @@ -84,12 +102,20 @@ export default function AiVaultPanel(): React.JSX.Element { activeRepo, activeWorktree, sessions - }), + }).sessionProjectById, [activeRepo, activeWorktree, allWorktrees, projectHostSetupProjection, repos, sessions] ) - const activeProjectKey = projectContext.activeProjectKey - const projectLabelByKey = projectContext.projectLabelByKey - const sessionProjectById = projectContext.sessionProjectById + const sessionWorktreeById = useAiVaultSessionWorktreeMap({ + sessions, + worktrees: allWorktrees, + activeWorktreeId: activeWorktree?.id ?? null + }) + const { buildResumeStartup, copyResumeCommand, handleResume } = useAiVaultSessionLaunchActions({ + activeWorktree: activeWorktree ?? null, + allWorktrees, + repos, + agentCmdOverrides + }) const hasAllAgentsSelected = agents.length === AI_VAULT_AGENTS.length const viewAdjustmentCount = (hasAllAgentsSelected ? 0 : 1) + @@ -122,51 +148,6 @@ export default function AiVaultPanel(): React.JSX.Element { } }, [activeProjectKey, activeWorktreePath, scope]) - const refresh = useCallback(async (args: { force?: boolean } = {}): Promise => { - if (refreshInFlightRef.current) { - return - } - - refreshInFlightRef.current = true - const refreshId = refreshIdRef.current + 1 - refreshIdRef.current = refreshId - setLoading(true) - setError(null) - try { - const result = await window.api.aiVault.listSessions({ - limit: SESSION_LIMIT, - force: args.force - }) - if (!mountedRef.current || refreshIdRef.current !== refreshId) { - return - } - setScanResult(result) - setSessions(result.sessions) - } catch (err) { - if (mountedRef.current && refreshIdRef.current === refreshId) { - setError(err instanceof Error ? err.message : String(err)) - } - } finally { - refreshInFlightRef.current = false - if (mountedRef.current && refreshIdRef.current === refreshId) { - setLoading(false) - } - } - }, []) - - useEffect(() => { - mountedRef.current = true - return () => { - mountedRef.current = false - refreshIdRef.current += 1 - refreshInFlightRef.current = false - } - }, []) - - useEffect(() => { - void refresh() - }, [refresh]) - const filteredSessions = useMemo( () => filterAiVaultSessions(sessions, { @@ -203,41 +184,6 @@ export default function AiVaultPanel(): React.JSX.Element { [filteredSessions, group, projectLabelByKey, sessionProjectById] ) - const buildResumeCommand = useCallback( - (session: AiVaultSession): string => - buildAiVaultResumeCommandForWorktree({ - state: useAppStore.getState(), - worktreeId: activeWorktree?.id ?? null, - session, - commandOverride: agentCmdOverrides[session.agent] - }), - [activeWorktree?.id, agentCmdOverrides, settings] - ) - - const buildResumeStartup = useCallback( - (session: AiVaultSession) => - buildAiVaultResumeStartupForWorktree({ - state: useAppStore.getState(), - worktreeId: activeWorktree?.id ?? null, - session, - commandOverride: agentCmdOverrides[session.agent] - }), - [activeWorktree?.id, agentCmdOverrides, settings] - ) - - const copyResumeCommand = useCallback( - async (session: AiVaultSession): Promise => { - await window.api.ui.writeClipboardText(buildResumeCommand(session)) - toast.success( - translate( - 'auto.components.right.sidebar.AiVaultPanel.resumeCommandCopied', - 'Resume command copied' - ) - ) - }, - [buildResumeCommand] - ) - const copyText = useCallback(async (text: string, label: string): Promise => { await window.api.ui.writeClipboardText(text) toast.success( @@ -247,40 +193,26 @@ export default function AiVaultPanel(): React.JSX.Element { ) }, []) - const handleResume = useCallback( - (session: AiVaultSession): void => { - if (!activeWorktree) { - toast.error( - translate( - 'auto.components.right.sidebar.AiVaultPanel.openWorkspaceBeforeResuming', - 'Open a workspace before resuming a session.' - ) - ) - return - } - if (isRemoteWorktree) { - toast.error( - translate( - 'auto.components.right.sidebar.AiVaultPanel.localWorkspacesOnly', - 'Resume from history is only available in local workspaces.' - ) - ) - return - } - launchAiVaultSessionInNewTab({ - agent: session.agent, - worktreeId: activeWorktree.id, - ...buildResumeStartup(session) - }) - toast.success( - translate( - 'auto.components.right.sidebar.AiVaultPanel.agentSessionQueued', - '{{value0}} session queued', - { value0: agentLabel(session.agent) } - ) - ) - }, - [activeWorktree, buildResumeStartup, isRemoteWorktree] + const getSessionResumeState = useCallback( + (session: AiVaultSession) => + resolveAiVaultSessionResumeState({ + worktreeInfo: sessionWorktreeById.get(session.id) ?? null, + activeWorktreeId: activeWorktree?.id ?? null, + worktrees: allWorktrees, + repos + }), + [activeWorktree?.id, allWorktrees, repos, sessionWorktreeById] + ) + + const getSessionResumeActions = useCallback( + (session: AiVaultSession) => + resolveAiVaultSessionResumeActions({ + worktreeInfo: sessionWorktreeById.get(session.id) ?? null, + activeWorktreeId: activeWorktree?.id ?? null, + worktrees: allWorktrees, + repos + }), + [activeWorktree?.id, allWorktrees, repos, sessionWorktreeById] ) const setAgentEnabled = useCallback((agent: AiVaultAgent, enabled: boolean) => { @@ -377,11 +309,16 @@ export default function AiVaultPanel(): React.JSX.Element { sessionsCount={sessions.length} filteredSessionsCount={filteredSessions.length} error={error} - resumeDisabled={!activeWorktree || isRemoteWorktree} buildResumeStartup={buildResumeStartup} + getSessionResumeState={getSessionResumeState} + getSessionResumeActions={getSessionResumeActions} + getOriginalPaneTarget={getOriginalPaneTarget} + getWorktreeInfo={(session) => sessionWorktreeById.get(session.id) ?? null} onToggleGroup={toggleGroup} + onJumpToOriginalPane={jumpToOriginalPane} + onJumpToWorktree={jumpToWorktree} onResume={handleResume} - onCopyResume={(session) => void copyResumeCommand(session)} + onCopyResume={(session, worktreeId) => void copyResumeCommand(session, worktreeId)} onCopyId={(session) => void copyText( session.sessionId, diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx index 387bc877f7b..58923a53479 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx @@ -1,33 +1,47 @@ import type React from 'react' -import { Copy, Play } from 'lucide-react' +import { FileJson, FolderGit2, MessageSquare, Play } from 'lucide-react' import { Button } from '@/components/ui/button' -import { AgentIcon } from '@/lib/agent-catalog' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' import type { AiVaultSession } from '../../../../shared/ai-vault-types' -import { agentLabel } from './ai-vault-session-filters' import { translate } from '@/i18n/i18n' -import { recentSessionConversationTurns } from './ai-vault-session-display' +import { sessionDetailConversationTurns } from './ai-vault-session-display' +import { + aiVaultWorktreeCompactPath, + aiVaultWorktreeStatusLabel, + shouldShowAiVaultWorktreeStatusBadge, + type AiVaultSessionWorktreeInfo +} from './ai-vault-session-worktree' export function SessionInlineDetails({ id, session, - onResume, - onCopyResume, - resumeDisabled + worktreeInfo, + resumeActions, + onResumeInWorktree, + onResumeInNewTab, + onOpenLog }: { id: string session: AiVaultSession - onResume: () => void - onCopyResume: () => void - resumeDisabled: boolean + worktreeInfo: AiVaultSessionWorktreeInfo | null + resumeActions: { + worktree: { worktreeId: string | null; disabled: boolean } + newTab: { worktreeId: string | null; disabled: boolean } + } + onResumeInWorktree: () => void + onResumeInNewTab: () => void + onOpenLog: () => void }): React.JSX.Element { - const updatedAt = session.updatedAt ?? session.modifiedAt - const recentTurns = recentSessionConversationTurns(session, 3) + const showResumeInWorktree = Boolean(resumeActions.worktree.worktreeId) + const showResumeInNewTab = !showResumeInWorktree || Boolean(resumeActions.newTab.worktreeId) + const detailTurns = sessionDetailConversationTurns(session, 3) + const worktreeDisplay = worktreeInfo return (
event.stopPropagation()} onClick={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()} @@ -36,83 +50,88 @@ export function SessionInlineDetails({ event.stopPropagation() }} > -
- -
- {session.title} -
-
- +
} label={translate( 'auto.components.right.sidebar.AiVaultSessionDetails.latestTurns', 'Latest turns' )} > - {recentTurns.length > 0 ? ( -
- {recentTurns.map((turn, index) => ( -
0 ? ( +
+ {detailTurns.map((turn, index) => ( + - {conversationRoleLabel(turn.role)} - {turn.text} -
+ role={turn.role} + text={turn.text} + /> ))}
) : ( -
- {translate( + + /> )} -
- - - - {agentLabel(session.agent)} - · - - {translate( - 'auto.components.right.sidebar.AiVaultSessionDetails.messageCount', - '{{value0}} msgs', - { value0: session.messageCount } + {worktreeDisplay ? ( + } + label={translate( + 'auto.components.right.sidebar.AiVaultSessionDetails.worktree', + 'Worktree' )} - - · - -
+ > + + + ) : null}
-
- +
+ {showResumeInWorktree ? ( + + ) : null} + {showResumeInNewTab ? ( + + ) : null}
@@ -136,18 +152,110 @@ export function SessionInlineDetails({ } function SessionReceiptSection({ + icon, label, children }: { + icon: React.ReactNode label: string children: React.ReactNode }): React.JSX.Element { return ( -
-
- {label} +
+
+ {icon} + {label}
{children} +
+ ) +} + +function ConversationTurnCard({ + role, + text +}: { + role: AiVaultSession['previewMessages'][number]['role'] + text: string +}): React.JSX.Element { + const isUserTurn = role === 'user' + + return ( +
+
+ {conversationRoleLabel(role)} +
+

+ {text} +

+
+ ) +} + +function WorktreeMetadataLines({ + worktreeInfo +}: { + worktreeInfo: AiVaultSessionWorktreeInfo +}): React.JSX.Element { + const compactPath = aiVaultWorktreeCompactPath(worktreeInfo.path) + const pathLine = + compactPath && compactPath !== worktreeInfo.label ? compactPath : worktreeInfo.path + const showPathLine = Boolean(pathLine) && pathLine !== worktreeInfo.label + + return ( +
+
+ {shouldShowAiVaultWorktreeStatusBadge(worktreeInfo.status) ? ( + <> + + {aiVaultWorktreeStatusLabel(worktreeInfo.status)} + + · + + ) : null} + + {worktreeInfo.label} + +
+ {showPathLine ? ( + + ) : null} +
+ ) +} + +function WorktreePathHint({ + compactPath, + fullPath +}: { + compactPath: string + fullPath: string +}): React.JSX.Element { + return ( + + +
+ {compactPath} +
+
+ + {fullPath} + +
+ ) +} + +function SessionDetailEmptyState({ message }: { message: string }): React.JSX.Element { + return ( +
+ {message}
) } diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx index e63479ee269..eab39c0f2d3 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx @@ -1,6 +1,6 @@ import { useCallback } from 'react' import type React from 'react' -import { Copy, FileJson, FolderOpen, Play } from 'lucide-react' +import { Copy, FileJson, FolderOpen, LocateFixed, PanelTopOpen, Play } from 'lucide-react' import { DropdownMenuItem, DropdownMenuSeparator } from '@/components/ui/dropdown-menu' import { ContextMenu, @@ -9,8 +9,13 @@ import { ContextMenuSeparator, ContextMenuTrigger } from '@/components/ui/context-menu' +import { Badge } from '@/components/ui/badge' +import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' import { AgentIcon } from '@/lib/agent-catalog' import { cn } from '@/lib/utils' +import { useRepoById } from '@/store/selectors' +import { resolveRepoBadgeColor } from '../../../../shared/repo-badge-color' +import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree-id' import { AI_VAULT_SESSION_DRAG_START_EVENT, writeAiVaultSessionDragData @@ -22,14 +27,27 @@ import { translate } from '@/i18n/i18n' import { SessionInlineDetails, SessionTime } from './AiVaultSessionDetails' import { latestSessionConversationTurn } from './ai-vault-session-display' import { SessionRowTrailingActions } from './SessionRowTrailingActions' +import type { AiVaultSessionResumeActions } from './ai-vault-session-resume' +import { + aiVaultWorktreeStatusLabel, + shouldShowAiVaultWorktreeStatusBadge, + type AiVaultSessionWorktreeInfo +} from './ai-vault-session-worktree' export function VaultSessionRow({ session, resumeStartup, + worktreeInfo, detailsExpanded, resumeDisabled, onToggleDetails, + onJumpToOriginalPane, + onJumpToWorktree, onResume, + resumeLabel, + resumeActions, + onResumeInWorktree, + onResumeInNewTab, onCopyResume, onCopyId, onCopyPath, @@ -39,10 +57,17 @@ export function VaultSessionRow({ }: { session: AiVaultSession resumeStartup: AiVaultResumeStartup + worktreeInfo: AiVaultSessionWorktreeInfo | null detailsExpanded: boolean resumeDisabled: boolean onToggleDetails: () => void + onJumpToOriginalPane?: () => void + onJumpToWorktree?: () => void onResume: () => void + resumeLabel: string + resumeActions: AiVaultSessionResumeActions + onResumeInWorktree: () => void + onResumeInNewTab: () => void onCopyResume: () => void onCopyId: () => void onCopyPath: () => void @@ -80,7 +105,10 @@ export function VaultSessionRow({
{ onToggleDetails() }} @@ -89,7 +117,7 @@ export function VaultSessionRow({
{session.title} @@ -100,7 +128,11 @@ export function VaultSessionRow({ detailsId={detailsId} detailsTooltip={detailsTooltip} resumeDisabled={resumeDisabled} + resumeLabel={resumeLabel} + worktreeInfo={worktreeInfo} onToggleDetails={onToggleDetails} + onJumpToOriginalPane={onJumpToOriginalPane} + onJumpToWorktree={onJumpToWorktree} onResume={onResume} onCopyResume={onCopyResume} onCopyId={onCopyId} @@ -111,29 +143,44 @@ export function VaultSessionRow({ onStartResumeDrag={startResumeDrag} />
-
- {latestTurn ? ( - <> - - {conversationRoleLabel(latestTurn.role)} - - : {latestTurn.text} - - ) : ( - translate( - 'auto.components.right.sidebar.AiVaultSessionRow.noPreviewAvailable', - 'No conversation preview available' - ) - )} -
- + {detailsExpanded && worktreeInfo ? ( +
+ +
+ ) : null} + {!detailsExpanded ? ( + <> +
+ {latestTurn ? ( + <> + + {conversationRoleLabel(latestTurn.role)} + + : {latestTurn.text} + + ) : ( + translate( + 'auto.components.right.sidebar.AiVaultSessionRow.noPreviewAvailable', + 'No conversation preview available' + ) + )} +
+ + + ) : null} {detailsExpanded ? ( ) : null}
@@ -142,6 +189,9 @@ export function VaultSessionRow({ void + onJumpToOriginalPane?: () => void + onJumpToWorktree?: () => void onCopyResume: () => void onCopyId: () => void onCopyPath: () => void @@ -181,12 +237,25 @@ export function SessionActionMenuItems({ return ( <> + {onJumpToOriginalPane ? ( + + + {translate( + 'auto.components.right.sidebar.AiVaultSessionRow.jumpToOriginalPane', + 'Jump to Original Pane' + )} + + ) : null} + + + {translate( + 'auto.components.right.sidebar.AiVaultSessionRow.jumpToWorktree', + 'Jump to Worktree' + )} + - {translate( - 'auto.components.right.sidebar.AiVaultSessionRow.resumeInNewTab', - 'Resume in New Tab' - )} + {resumeLabel} @@ -231,26 +300,74 @@ function getSessionDetailsId(sessionId: string): string { return `ai-vault-session-details-${sessionId.replace(/[^A-Za-z0-9_-]/g, '-')}` } -function SessionMetadata({ session, updatedAt }: { session: AiVaultSession; updatedAt: string }) { +function SessionMetadata({ + session, + updatedAt, + worktreeInfo +}: { + session: AiVaultSession + updatedAt: string + worktreeInfo: AiVaultSessionWorktreeInfo | null +}) { return ( -
- - - - {agentLabel(session.agent)} - - {translate( - 'auto.components.right.sidebar.AiVaultSessionRow.messageCount', - '{{value0}} msgs', - { value0: session.messageCount } - )} - - · - +
+
+ + + + {agentLabel(session.agent)} + + {translate( + 'auto.components.right.sidebar.AiVaultSessionRow.messageCount', + '{{value0}} msgs', + { value0: session.messageCount } + )} + + · + +
+ {worktreeInfo ? : null}
) } +function SessionWorktreeLine({ + worktreeInfo +}: { + worktreeInfo: AiVaultSessionWorktreeInfo +}): React.JSX.Element { + const repoId = worktreeInfo.worktreeId + ? (splitWorktreeIdForFilesystem(worktreeInfo.worktreeId)?.repoId ?? null) + : null + const repo = useRepoById(repoId) + + return ( +
+ {shouldShowAiVaultWorktreeStatusBadge(worktreeInfo.status) ? ( + + {worktreeStatusLabel(worktreeInfo.status)} + + ) : null} + + + +
+ ) +} + +function worktreeStatusLabel(status: AiVaultSessionWorktreeInfo['status']): string { + return aiVaultWorktreeStatusLabel(status) +} + function conversationRoleLabel(role: AiVaultSession['previewMessages'][number]['role']): string { if (role === 'user') { return translate('auto.components.right.sidebar.AiVaultSessionRow.userRole', 'You') diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx index 9a9cfdf7a9b..bac36ba8aa8 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx @@ -8,6 +8,16 @@ import { getActiveStickyHeaderIndexForScroll } from '../sidebar/worktree-list-vi import { EmptyState, SessionLoadingState, VaultGroupHeader } from './AiVaultPanelControls' import { VaultSessionRow } from './AiVaultSessionRow' import type { AiVaultSessionGroup } from './ai-vault-session-filters' +import type { AiVaultOriginalPaneTarget } from './ai-vault-original-pane' +import { + aiVaultSessionResumeLabel, + type AiVaultSessionResumeActions, + type AiVaultSessionResumeState +} from './ai-vault-session-resume' +import { + canJumpToAiVaultSessionWorktree, + type AiVaultSessionWorktreeInfo +} from './ai-vault-session-worktree' import { extractVaultVirtualRowIndexes, getVaultStickyHeaderIndexes, @@ -16,7 +26,7 @@ import { } from './ai-vault-virtual-rows' const VAULT_ROW_OVERSCAN = 8 -const VAULT_EXPANDED_SESSION_ROW_ESTIMATED_HEIGHT = 360 +const VAULT_EXPANDED_SESSION_ROW_ESTIMATED_HEIGHT = 420 type AiVaultListRow = | { type: 'group'; group: AiVaultSessionGroup } @@ -29,9 +39,14 @@ export function AiVaultSessionVirtualList({ sessionsCount, filteredSessionsCount, error, - resumeDisabled, buildResumeStartup, + getOriginalPaneTarget, + getWorktreeInfo, + getSessionResumeState, + getSessionResumeActions, onToggleGroup, + onJumpToOriginalPane, + onJumpToWorktree, onResume, onCopyResume, onCopyId, @@ -46,11 +61,16 @@ export function AiVaultSessionVirtualList({ sessionsCount: number filteredSessionsCount: number error: string | null - resumeDisabled: boolean - buildResumeStartup: (session: AiVaultSession) => AiVaultResumeStartup + buildResumeStartup: (session: AiVaultSession, worktreeId?: string | null) => AiVaultResumeStartup + getOriginalPaneTarget: (session: AiVaultSession) => AiVaultOriginalPaneTarget | null + getWorktreeInfo: (session: AiVaultSession) => AiVaultSessionWorktreeInfo | null + getSessionResumeState: (session: AiVaultSession) => AiVaultSessionResumeState + getSessionResumeActions: (session: AiVaultSession) => AiVaultSessionResumeActions onToggleGroup: (key: string) => void - onResume: (session: AiVaultSession) => void - onCopyResume: (session: AiVaultSession) => void + onJumpToOriginalPane: (session: AiVaultSession) => void + onJumpToWorktree: (worktreeId: string) => void + onResume: (session: AiVaultSession, worktreeId: string) => void + onCopyResume: (session: AiVaultSession, worktreeId?: string | null) => void onCopyId: (session: AiVaultSession) => void onCopyPath: (session: AiVaultSession) => void onOpenLog: (session: AiVaultSession) => void @@ -163,10 +183,15 @@ export function AiVaultSessionVirtualList({ measureElement={virtualizer.measureElement} collapsedGroups={collapsedGroups} expandedSessionIds={expandedSessionIds} - resumeDisabled={resumeDisabled} buildResumeStartup={buildResumeStartup} + getOriginalPaneTarget={getOriginalPaneTarget} + getWorktreeInfo={getWorktreeInfo} + getSessionResumeState={getSessionResumeState} + getSessionResumeActions={getSessionResumeActions} onToggleGroup={onToggleGroup} onToggleSessionDetails={toggleSessionDetails} + onJumpToOriginalPane={onJumpToOriginalPane} + onJumpToWorktree={onJumpToWorktree} onResume={onResume} onCopyResume={onCopyResume} onCopyId={onCopyId} @@ -190,10 +215,15 @@ function AiVaultVirtualRow({ measureElement, collapsedGroups, expandedSessionIds, - resumeDisabled, buildResumeStartup, + getOriginalPaneTarget, + getWorktreeInfo, + getSessionResumeState, + getSessionResumeActions, onToggleGroup, onToggleSessionDetails, + onJumpToOriginalPane, + onJumpToWorktree, onResume, onCopyResume, onCopyId, @@ -209,12 +239,17 @@ function AiVaultVirtualRow({ measureElement: (node: Element | null) => void collapsedGroups: ReadonlySet expandedSessionIds: ReadonlySet - resumeDisabled: boolean - buildResumeStartup: (session: AiVaultSession) => AiVaultResumeStartup + buildResumeStartup: (session: AiVaultSession, worktreeId?: string | null) => AiVaultResumeStartup + getOriginalPaneTarget: (session: AiVaultSession) => AiVaultOriginalPaneTarget | null + getWorktreeInfo: (session: AiVaultSession) => AiVaultSessionWorktreeInfo | null + getSessionResumeState: (session: AiVaultSession) => AiVaultSessionResumeState + getSessionResumeActions: (session: AiVaultSession) => AiVaultSessionResumeActions onToggleGroup: (key: string) => void onToggleSessionDetails: (sessionId: string) => void - onResume: (session: AiVaultSession) => void - onCopyResume: (session: AiVaultSession) => void + onJumpToOriginalPane: (session: AiVaultSession) => void + onJumpToWorktree: (worktreeId: string) => void + onResume: (session: AiVaultSession, worktreeId: string) => void + onCopyResume: (session: AiVaultSession, worktreeId?: string | null) => void onCopyId: (session: AiVaultSession) => void onCopyPath: (session: AiVaultSession) => void onOpenLog: (session: AiVaultSession) => void @@ -226,6 +261,14 @@ function AiVaultVirtualRow({ } const isActiveStickyHeader = row.type === 'group' && activeStickyHeaderIndex === index + const originalPaneTarget = row.type === 'session' ? getOriginalPaneTarget(row.session) : null + const worktreeInfo = row.type === 'session' ? getWorktreeInfo(row.session) : null + const worktreeJumpId = canJumpToAiVaultSessionWorktree(worktreeInfo) + ? worktreeInfo?.worktreeId + : null + const resumeState = row.type === 'session' ? getSessionResumeState(row.session) : null + const resumeActions = row.type === 'session' ? getSessionResumeActions(row.session) : null + const resumeLabel = resumeState ? aiVaultSessionResumeLabel(resumeState) : '' return (
onToggleSessionDetails(row.session.id)} - onResume={() => onResume(row.session)} - onCopyResume={() => onCopyResume(row.session)} + onJumpToOriginalPane={ + originalPaneTarget ? () => onJumpToOriginalPane(row.session) : undefined + } + onJumpToWorktree={worktreeJumpId ? () => onJumpToWorktree(worktreeJumpId) : undefined} + onResume={() => { + if (resumeState?.worktreeId) { + onResume(row.session, resumeState.worktreeId) + } + }} + onResumeInWorktree={() => { + if (resumeActions?.worktree.worktreeId) { + onResume(row.session, resumeActions.worktree.worktreeId) + } + }} + onResumeInNewTab={() => { + if (resumeActions?.newTab.worktreeId) { + onResume(row.session, resumeActions.newTab.worktreeId) + } + }} + onCopyResume={() => onCopyResume(row.session, resumeState?.worktreeId)} onCopyId={() => onCopyId(row.session)} onCopyPath={() => onCopyPath(row.session)} onOpenLog={() => onOpenLog(row.session)} diff --git a/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx b/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx index 144f1a3d98f..f0552b3fab3 100644 --- a/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx +++ b/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx @@ -1,6 +1,13 @@ import { useCallback, useEffect, useRef } from 'react' import type React from 'react' -import { ChevronDown, GripVertical, MoreHorizontal, Play } from 'lucide-react' +import { + ChevronDown, + GripVertical, + LocateFixed, + MoreHorizontal, + PanelTopOpen, + Play +} from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { @@ -14,6 +21,10 @@ import type { AiVaultSession } from '../../../../shared/ai-vault-types' import { agentLabel } from './ai-vault-session-filters' import { translate } from '@/i18n/i18n' import { SessionActionMenuItems } from './AiVaultSessionRow' +import { + aiVaultWorktreeJumpTooltip, + type AiVaultSessionWorktreeInfo +} from './ai-vault-session-worktree' // Why: hover-only actions live on the title row and collapse on hover-capable // devices so the prompt keeps the full width until the row is hovered. @@ -36,7 +47,11 @@ export function SessionRowTrailingActions({ detailsId, detailsTooltip, resumeDisabled, + resumeLabel, + worktreeInfo, onToggleDetails, + onJumpToOriginalPane, + onJumpToWorktree, onResume, onCopyResume, onCopyId, @@ -51,7 +66,11 @@ export function SessionRowTrailingActions({ detailsId: string detailsTooltip: string resumeDisabled: boolean + resumeLabel: string + worktreeInfo: AiVaultSessionWorktreeInfo | null onToggleDetails: () => void + onJumpToOriginalPane?: () => void + onJumpToWorktree?: () => void onResume: () => void onCopyResume: () => void onCopyId: () => void @@ -77,6 +96,8 @@ export function SessionRowTrailingActions({ window.dispatchEvent(new Event(AI_VAULT_SESSION_DRAG_END_EVENT)) }, []) + const jumpToWorktreeTooltip = aiVaultWorktreeJumpTooltip(worktreeInfo) + // Cleanup drag state on unmount if a drag was in progress useEffect(() => { return () => { @@ -126,17 +147,53 @@ export function SessionRowTrailingActions({ )} + {onJumpToOriginalPane ? ( + + + + + + {translate( + 'auto.components.right.sidebar.AiVaultSessionRow.jumpToOriginalPane', + 'Jump to Original Pane' + )} + + + ) : null} + + + + {jumpToWorktreeTooltip} + + - {translate( - 'auto.components.right.sidebar.AiVaultSessionRow.resumeInNewTab', - 'Resume in New Tab' - )} + {resumeLabel}
@@ -219,7 +273,10 @@ export function SessionRowTrailingActions({ ) } + +function WorktreeJumpTooltipTrigger({ + disabled, + ariaLabel, + onJumpToWorktree +}: { + disabled: boolean + ariaLabel: string + onJumpToWorktree?: () => void +}): React.JSX.Element { + const button = ( + + ) + + if (!disabled) { + return {button} + } + + return ( + + event.stopPropagation()} + > + {button} + + + ) +} diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index 45da0012ddd..74fa804f48b 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -6501,7 +6501,9 @@ export function CommitArea({ aria-describedby={describedBy || undefined} // Why: reserve right padding so typed text does not slide under the // absolute-positioned Generate icon in the top-right corner. - className={`mt-0.5 min-h-14 w-full resize-none rounded-md border border-border bg-background px-2 py-1.5 text-xs text-foreground outline-none placeholder:text-muted-foreground/70 focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 ${ + // Why: match Input surface tokens and pin disabled:border-input so + // Chromium's UA disabled styles don't wash out the field outline. + className={`mt-0.5 min-h-14 w-full resize-none appearance-none rounded-md border border-input bg-background shadow-xs px-2 py-1.5 text-xs text-foreground outline-none placeholder:text-muted-foreground/70 focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:border-input disabled:bg-background disabled:text-foreground disabled:shadow-xs dark:bg-input/30 dark:disabled:bg-input/30 ${ showGenerate ? 'pr-8' : '' }`} /> diff --git a/src/renderer/src/components/right-sidebar/ai-vault-original-pane-actions.ts b/src/renderer/src/components/right-sidebar/ai-vault-original-pane-actions.ts new file mode 100644 index 00000000000..8e6ebf5f947 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-original-pane-actions.ts @@ -0,0 +1,74 @@ +import { useCallback } from 'react' +import { useShallow } from 'zustand/react/shallow' +import { toast } from 'sonner' +import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane' +import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { useAppStore } from '@/store' +import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import { translate } from '@/i18n/i18n' +import { findOriginalAiVaultSessionPane } from './ai-vault-original-pane' + +export function useAiVaultOriginalPaneActions(): { + getOriginalPaneTarget: ( + session: AiVaultSession + ) => ReturnType + jumpToOriginalPane: (session: AiVaultSession) => void + jumpToWorktree: (worktreeId: string) => void +} { + const originalPaneLookupState = useAppStore( + useShallow((s) => ({ + agentStatusByPaneKey: s.agentStatusByPaneKey, + retainedAgentsByPaneKey: s.retainedAgentsByPaneKey, + sleepingAgentSessionsByPaneKey: s.sleepingAgentSessionsByPaneKey, + tabsByWorktree: s.tabsByWorktree, + terminalLayoutsByTabId: s.terminalLayoutsByTabId + })) + ) + + const getOriginalPaneTarget = useCallback( + (session: AiVaultSession) => findOriginalAiVaultSessionPane(originalPaneLookupState, session), + [originalPaneLookupState] + ) + + const jumpToOriginalPane = useCallback((session: AiVaultSession): void => { + const target = findOriginalAiVaultSessionPane(useAppStore.getState(), session) + if (!target) { + toast.error( + translate( + 'auto.components.right.sidebar.AiVaultPanel.originalPaneUnavailable', + 'Original pane is no longer available.' + ) + ) + return + } + + if (!activateAndRevealWorktree(target.worktreeId)) { + toast.error( + translate( + 'auto.components.right.sidebar.AiVaultPanel.worktreeUnavailable', + 'Worktree is no longer available.' + ) + ) + return + } + const state = useAppStore.getState() + state.setActiveTabType('terminal') + activateTabAndFocusPane(target.tabId, target.leafId, { + flashFocusedPane: true, + scrollToBottomIfOutputSinceLastView: true + }) + }, []) + + const jumpToWorktree = useCallback((worktreeId: string): void => { + if (!activateAndRevealWorktree(worktreeId)) { + toast.error( + translate( + 'auto.components.right.sidebar.AiVaultPanel.worktreeUnavailable', + 'Worktree is no longer available.' + ) + ) + } + }, []) + + return { getOriginalPaneTarget, jumpToOriginalPane, jumpToWorktree } +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-original-pane.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-original-pane.test.ts new file mode 100644 index 00000000000..cec8a30a270 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-original-pane.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, it } from 'vitest' +import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { findOriginalAiVaultSessionPane } from './ai-vault-original-pane' + +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const OTHER_LEAF_ID = '22222222-2222-4222-8222-222222222222' + +const baseSession: AiVaultSession = { + id: 'codex:session-1', + agent: 'codex', + sessionId: 'session-1', + title: 'Fix the pane focus', + cwd: '/repo', + branch: null, + model: null, + filePath: '/home/ada/.codex/session-1.jsonl', + codexHome: null, + createdAt: null, + updatedAt: '2026-06-24T10:00:00.000Z', + modifiedAt: '2026-06-24T10:00:00.000Z', + messageCount: 2, + totalTokens: 42, + previewMessages: [], + resumeCommand: "codex resume 'session-1'" +} + +function makeTab(id = 'tab-1', worktreeId = 'wt-1') { + return { + id, + ptyId: null, + worktreeId, + title: 'Agent', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +function makeLayout(leafId = LEAF_ID) { + return { + root: { type: 'leaf' as const, leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: 'pty-1' } + } +} + +function makeState(overrides: Record = {}) { + return { + agentStatusByPaneKey: {}, + retainedAgentsByPaneKey: {}, + sleepingAgentSessionsByPaneKey: {}, + tabsByWorktree: { 'wt-1': [makeTab()] }, + terminalLayoutsByTabId: { 'tab-1': makeLayout() }, + ...overrides + } as never +} + +function makeEntry(overrides: Partial = {}): AgentStatusEntry { + const paneKey = makePaneKey('tab-1', LEAF_ID) + return { + state: 'working', + prompt: 'continue', + updatedAt: 1, + stateStartedAt: 1, + agentType: 'codex', + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + stateHistory: [], + providerSession: { key: 'session_id', id: 'session-1' }, + ...overrides + } +} + +function makeSleepingRecord( + overrides: Partial = {} +): SleepingAgentSessionRecord { + return { + paneKey: makePaneKey('tab-1', LEAF_ID), + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'codex', + providerSession: { key: 'session_id', id: 'session-1' }, + prompt: 'continue', + state: 'working', + capturedAt: 1, + updatedAt: 1, + origin: 'live', + ...overrides + } +} + +describe('findOriginalAiVaultSessionPane', () => { + it('finds a live pane with a matching provider session', () => { + const entry = makeEntry() + + const target = findOriginalAiVaultSessionPane( + makeState({ agentStatusByPaneKey: { [entry.paneKey]: entry } }), + baseSession + ) + + expect(target).toEqual({ + paneKey: entry.paneKey, + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: LEAF_ID + }) + }) + + it('finds a unique live pane by prompt when provider session is not known yet', () => { + const entry = makeEntry({ + providerSession: undefined, + prompt: 'Fix the pane focus' + }) + + const target = findOriginalAiVaultSessionPane( + makeState({ agentStatusByPaneKey: { [entry.paneKey]: entry } }), + baseSession + ) + + expect(target?.paneKey).toBe(entry.paneKey) + }) + + it('does not prompt-match when multiple panes are plausible', () => { + const firstPaneKey = makePaneKey('tab-1', LEAF_ID) + const secondPaneKey = makePaneKey('tab-2', OTHER_LEAF_ID) + const first = makeEntry({ paneKey: firstPaneKey, providerSession: undefined }) + const second = makeEntry({ + paneKey: secondPaneKey, + tabId: 'tab-2', + providerSession: undefined + }) + + const target = findOriginalAiVaultSessionPane( + makeState({ + agentStatusByPaneKey: { + [first.paneKey]: first, + [second.paneKey]: second + }, + tabsByWorktree: { 'wt-1': [makeTab('tab-1'), makeTab('tab-2')] }, + terminalLayoutsByTabId: { + 'tab-1': makeLayout(LEAF_ID), + 'tab-2': makeLayout(OTHER_LEAF_ID) + } + }), + baseSession + ) + + expect(target).toBeNull() + }) + + it('does not return a target when the original leaf is gone', () => { + const entry = makeEntry() + + const target = findOriginalAiVaultSessionPane( + makeState({ + agentStatusByPaneKey: { [entry.paneKey]: entry }, + terminalLayoutsByTabId: { 'tab-1': makeLayout(OTHER_LEAF_ID) } + }), + baseSession + ) + + expect(target).toBeNull() + }) + + it('finds a retained completed pane when the tab and layout still exist', () => { + const entry = makeEntry({ state: 'done' }) + + const target = findOriginalAiVaultSessionPane( + makeState({ + retainedAgentsByPaneKey: { + [entry.paneKey]: { + entry, + worktreeId: 'wt-1', + tab: makeTab(), + agentType: 'codex', + startedAt: 1 + } + } + }), + baseSession + ) + + expect(target?.leafId).toBe(LEAF_ID) + }) + + it('finds a preserved sleeping pane from a matching session record', () => { + const record = makeSleepingRecord() + + const target = findOriginalAiVaultSessionPane( + makeState({ sleepingAgentSessionsByPaneKey: { [record.paneKey]: record } }), + baseSession + ) + + expect(target?.paneKey).toBe(record.paneKey) + }) + + it('resolves legacy numeric pane keys through the current tab layout', () => { + const record = makeSleepingRecord({ paneKey: 'tab-1:1' }) + + const target = findOriginalAiVaultSessionPane( + makeState({ sleepingAgentSessionsByPaneKey: { [record.paneKey]: record } }), + baseSession + ) + + expect(target?.leafId).toBe(LEAF_ID) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-original-pane.ts b/src/renderer/src/components/right-sidebar/ai-vault-original-pane.ts new file mode 100644 index 00000000000..a878789891c --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-original-pane.ts @@ -0,0 +1,262 @@ +import type { AppState } from '@/store/types' +import { resolveRuntimePaneTitleLeafId } from '@/lib/runtime-pane-title-leaf-id' +import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../../shared/stable-pane-id' +import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode } from '../../../../shared/types' + +export type AiVaultOriginalPaneTarget = { + paneKey: string + worktreeId: string + tabId: string + leafId: string +} + +type OriginalPaneState = Pick< + AppState, + | 'agentStatusByPaneKey' + | 'retainedAgentsByPaneKey' + | 'sleepingAgentSessionsByPaneKey' + | 'tabsByWorktree' + | 'terminalLayoutsByTabId' +> + +function agentMatches(session: AiVaultSession, agent: string | undefined): boolean { + return agent === session.agent +} + +function providerSessionMatches(session: AiVaultSession, providerSessionId: string | undefined) { + return providerSessionId === session.sessionId +} + +function normalizeMatchText(value: string | null | undefined): string { + return value?.trim().replace(/\s+/g, ' ').toLowerCase() ?? '' +} + +function longEnoughForPrefixMatch(value: string): boolean { + return value.length >= 24 +} + +function textMatchesSessionPrompt(sessionText: string, candidateText: string): boolean { + if (!sessionText || !candidateText) { + return false + } + if (sessionText === candidateText) { + return true + } + return ( + longEnoughForPrefixMatch(sessionText) && + longEnoughForPrefixMatch(candidateText) && + (sessionText.startsWith(candidateText) || candidateText.startsWith(sessionText)) + ) +} + +function sessionPromptCandidates(session: AiVaultSession): string[] { + const candidates = new Set() + const title = normalizeMatchText(session.title) + if (title) { + candidates.add(title) + } + for (const message of session.previewMessages) { + if (message.role !== 'user') { + continue + } + const text = normalizeMatchText(message.text) + if (text) { + candidates.add(text) + } + } + return [...candidates] +} + +function entryPromptCandidates(entry: { + prompt?: string + stateHistory?: readonly { prompt: string }[] +}): string[] { + const candidates = new Set() + const prompt = normalizeMatchText(entry.prompt) + if (prompt) { + candidates.add(prompt) + } + for (const historyEntry of entry.stateHistory ?? []) { + const text = normalizeMatchText(historyEntry.prompt) + if (text) { + candidates.add(text) + } + } + return [...candidates] +} + +function promptsMatchSession( + session: AiVaultSession, + entry: Parameters[0] +) { + const sessionCandidates = sessionPromptCandidates(session) + if (sessionCandidates.length === 0) { + return false + } + const entryCandidates = entryPromptCandidates(entry) + return sessionCandidates.some((sessionText) => + entryCandidates.some((entryText) => textMatchesSessionPrompt(sessionText, entryText)) + ) +} + +function layoutHasLeaf(node: TerminalPaneLayoutNode | null | undefined, leafId: string): boolean { + if (!node) { + return false + } + if (node.type === 'leaf') { + return node.leafId === leafId + } + return layoutHasLeaf(node.first, leafId) || layoutHasLeaf(node.second, leafId) +} + +function hasAvailableLeaf(layout: TerminalLayoutSnapshot | undefined, leafId: string): boolean { + return layoutHasLeaf(layout?.root, leafId) || Boolean(layout?.ptyIdsByLeafId?.[leafId]) +} + +function getTabOwnerWorktreeId( + state: OriginalPaneState, + tabId: string, + worktreeIdHint?: string +): string | null { + if ( + worktreeIdHint && + (state.tabsByWorktree[worktreeIdHint] ?? []).some((tab) => tab.id === tabId) + ) { + return worktreeIdHint + } + for (const [worktreeId, tabs] of Object.entries(state.tabsByWorktree)) { + if (tabs.some((tab) => tab.id === tabId)) { + return worktreeId + } + } + return null +} + +function resolveOriginalPaneTarget(args: { + state: OriginalPaneState + paneKey: string + worktreeIdHint?: string + tabIdHint?: string +}): AiVaultOriginalPaneTarget | null { + const { state, paneKey, worktreeIdHint, tabIdHint } = args + const stable = parsePaneKey(paneKey) + if (stable) { + if (tabIdHint && tabIdHint !== stable.tabId) { + return null + } + const worktreeId = getTabOwnerWorktreeId(state, stable.tabId, worktreeIdHint) + if ( + !worktreeId || + !hasAvailableLeaf(state.terminalLayoutsByTabId[stable.tabId], stable.leafId) + ) { + return null + } + return { paneKey, worktreeId, tabId: stable.tabId, leafId: stable.leafId } + } + + const legacy = parseLegacyNumericPaneKey(paneKey) + if (!legacy || (tabIdHint && tabIdHint !== legacy.tabId)) { + return null + } + const worktreeId = getTabOwnerWorktreeId(state, legacy.tabId, worktreeIdHint) + if (!worktreeId) { + return null + } + const layout = state.terminalLayoutsByTabId[legacy.tabId] + const leafId = resolveRuntimePaneTitleLeafId(layout, legacy.numericPaneId) + if (!leafId || !hasAvailableLeaf(layout, leafId)) { + return null + } + return { paneKey, worktreeId, tabId: legacy.tabId, leafId } +} + +export function findOriginalAiVaultSessionPane( + state: OriginalPaneState, + session: AiVaultSession +): AiVaultOriginalPaneTarget | null { + const promptMatchedTargets: AiVaultOriginalPaneTarget[] = [] + + for (const entry of Object.values(state.agentStatusByPaneKey)) { + if ( + agentMatches(session, entry.agentType) && + providerSessionMatches(session, entry.providerSession?.id) + ) { + const target = resolveOriginalPaneTarget({ + state, + paneKey: entry.paneKey, + worktreeIdHint: entry.worktreeId, + tabIdHint: entry.tabId + }) + if (target) { + return target + } + } + if ( + agentMatches(session, entry.agentType) && + entry.providerSession === undefined && + promptsMatchSession(session, entry) + ) { + const target = resolveOriginalPaneTarget({ + state, + paneKey: entry.paneKey, + worktreeIdHint: entry.worktreeId, + tabIdHint: entry.tabId + }) + if (target) { + promptMatchedTargets.push(target) + } + } + } + + for (const retained of Object.values(state.retainedAgentsByPaneKey)) { + if ( + agentMatches(session, retained.agentType) && + providerSessionMatches(session, retained.entry.providerSession?.id) + ) { + const target = resolveOriginalPaneTarget({ + state, + paneKey: retained.entry.paneKey, + worktreeIdHint: retained.worktreeId, + tabIdHint: retained.entry.tabId ?? retained.tab.id + }) + if (target) { + return target + } + } + if ( + agentMatches(session, retained.agentType) && + retained.entry.providerSession === undefined && + promptsMatchSession(session, retained.entry) + ) { + const target = resolveOriginalPaneTarget({ + state, + paneKey: retained.entry.paneKey, + worktreeIdHint: retained.worktreeId, + tabIdHint: retained.entry.tabId ?? retained.tab.id + }) + if (target) { + promptMatchedTargets.push(target) + } + } + } + + for (const record of Object.values(state.sleepingAgentSessionsByPaneKey)) { + if ( + agentMatches(session, record.agent) && + providerSessionMatches(session, record.providerSession.id) + ) { + const target = resolveOriginalPaneTarget({ + state, + paneKey: record.paneKey, + worktreeIdHint: record.worktreeId, + tabIdHint: record.tabId + }) + if (target) { + return target + } + } + } + + return promptMatchedTargets.length === 1 ? promptMatchedTargets[0] : null +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-scope-paths.ts b/src/renderer/src/components/right-sidebar/ai-vault-scope-paths.ts new file mode 100644 index 00000000000..6aad12c03bd --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-scope-paths.ts @@ -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 | null, + liveWorktrees: readonly Pick[] = [] +): 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[] = [], + 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 { + const setupsByRepoId = new Map() + 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 | { 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, + liveWorktrees: readonly Pick[] +): 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 + ) +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-display.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-display.test.ts index 38d83f3efca..b341558c628 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-display.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-display.test.ts @@ -3,6 +3,7 @@ import type { AiVaultSession } from '../../../../shared/ai-vault-types' import { latestSessionConversationTurn, recentSessionConversationTurns, + sessionDetailConversationTurns, sessionPreviewSearchText } from './ai-vault-session-display' @@ -70,4 +71,22 @@ describe('ai vault session display', () => { }) ).toBe('tool-only transcript') }) + + it('drops title-matching turns and adjacent duplicates from detail turns', () => { + const session: AiVaultSession = { + ...baseSession, + title: 'Fix the flaky golden tests', + previewMessages: [ + { role: 'user', text: 'Fix the flaky golden tests', timestamp: null }, + { role: 'assistant', text: 'I updated the fixture ordering', timestamp: null }, + { role: 'assistant', text: 'I updated the fixture ordering', timestamp: null }, + { role: 'assistant', text: 'Added a regression test', timestamp: null } + ] + } + + expect(sessionDetailConversationTurns(session, 3).map((turn) => turn.text)).toEqual([ + 'I updated the fixture ordering', + 'Added a regression test' + ]) + }) }) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-display.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-display.ts index 4a4940543f1..a4078616810 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-display.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-display.ts @@ -28,6 +28,58 @@ export function recentSessionConversationTurns( return displayableSessionPreviewMessages(session).slice(-limit).map(toDisplayTurn) } +export function sessionDetailConversationTurns( + session: AiVaultSession, + limit: number +): AiVaultSessionDisplayTurn[] { + if (limit <= 0) { + return [] + } + + const turns = displayableSessionPreviewMessages(session) + .map(toDisplayTurn) + .filter((turn) => !turnTextMatchesSessionTitle(session.title, turn.text)) + + return dedupeAdjacentConversationTurns(turns).slice(-limit) +} + +function turnTextMatchesSessionTitle(title: string, turnText: string): boolean { + const sessionText = normalizeSessionDisplayText(title) + const candidateText = normalizeSessionDisplayText(turnText) + if (!sessionText || !candidateText) { + return false + } + if (sessionText === candidateText) { + return true + } + const longEnough = sessionText.length >= 24 && candidateText.length >= 24 + return ( + longEnough && (sessionText.startsWith(candidateText) || candidateText.startsWith(sessionText)) + ) +} + +function dedupeAdjacentConversationTurns( + turns: AiVaultSessionDisplayTurn[] +): AiVaultSessionDisplayTurn[] { + const deduped: AiVaultSessionDisplayTurn[] = [] + for (const turn of turns) { + const previous = deduped.at(-1) + if ( + previous && + previous.role === turn.role && + normalizeSessionDisplayText(previous.text) === normalizeSessionDisplayText(turn.text) + ) { + continue + } + deduped.push(turn) + } + return deduped +} + +function normalizeSessionDisplayText(value: string): string { + return value.trim().replace(/\s+/g, ' ').toLowerCase() +} + export function sessionPreviewSearchText(session: AiVaultSession): string { return displayableSessionPreviewMessages(session) .map((message) => message.text) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts index 5acfd125b09..3190221f389 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts @@ -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( diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-filters.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-filters.ts index f0422547761..21e9d61de7f 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-filters.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-filters.ts @@ -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 | null, - liveWorktrees: readonly Pick[] = [] -): 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, - liveWorktrees: readonly Pick[] -): 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 diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts new file mode 100644 index 00000000000..a2d074b6b34 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts @@ -0,0 +1,113 @@ +import { useCallback } from 'react' +import { toast } from 'sonner' +import { + buildAiVaultResumeCommandForWorktree, + buildAiVaultResumeStartupForWorktree, + type AiVaultResumeStartup +} from '@/lib/ai-vault-resume-command' +import { launchAiVaultSessionInNewTab } from '@/lib/launch-ai-vault-session' +import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { useAppStore } from '@/store' +import type { AiVaultAgent, AiVaultSession } from '../../../../shared/ai-vault-types' +import type { Repo, Worktree } from '../../../../shared/types' +import { translate } from '@/i18n/i18n' +import { agentLabel } from './ai-vault-session-filters' + +export function useAiVaultSessionLaunchActions({ + activeWorktree, + allWorktrees, + repos, + agentCmdOverrides +}: { + activeWorktree: Worktree | null + allWorktrees: readonly Worktree[] + repos: readonly Repo[] + agentCmdOverrides?: Partial> +}): { + buildResumeStartup: (session: AiVaultSession, worktreeId?: string | null) => AiVaultResumeStartup + copyResumeCommand: (session: AiVaultSession, worktreeId?: string | null) => Promise + handleResume: (session: AiVaultSession, targetWorktreeId?: string) => void +} { + const buildResumeCommand = useCallback( + (session: AiVaultSession, worktreeId?: string | null): string => + buildAiVaultResumeCommandForWorktree({ + state: useAppStore.getState(), + worktreeId: worktreeId ?? activeWorktree?.id ?? null, + session, + commandOverride: agentCmdOverrides?.[session.agent] + }), + [activeWorktree?.id, agentCmdOverrides] + ) + + const buildResumeStartup = useCallback( + (session: AiVaultSession, worktreeId?: string | null) => + buildAiVaultResumeStartupForWorktree({ + state: useAppStore.getState(), + worktreeId: worktreeId ?? activeWorktree?.id ?? null, + session, + commandOverride: agentCmdOverrides?.[session.agent] + }), + [activeWorktree?.id, agentCmdOverrides] + ) + + const copyResumeCommand = useCallback( + async (session: AiVaultSession, worktreeId?: string | null): Promise => { + await window.api.ui.writeClipboardText(buildResumeCommand(session, worktreeId)) + toast.success( + translate( + 'auto.components.right.sidebar.AiVaultPanel.resumeCommandCopied', + 'Resume command copied' + ) + ) + }, + [buildResumeCommand] + ) + + const handleResume = useCallback( + (session: AiVaultSession, targetWorktreeId?: string): void => { + const worktree = + (targetWorktreeId + ? allWorktrees.find((candidate) => candidate.id === targetWorktreeId) + : null) ?? activeWorktree + if (!worktree) { + toast.error( + translate( + 'auto.components.right.sidebar.AiVaultPanel.openWorkspaceBeforeResuming', + 'Open a workspace before resuming a session.' + ) + ) + return + } + + const worktreeRepo = repos.find((repo) => repo.id === worktree.repoId) + if (worktreeRepo?.connectionId) { + toast.error( + translate( + 'auto.components.right.sidebar.AiVaultPanel.localWorkspacesOnly', + 'Resume from history is only available in local workspaces.' + ) + ) + return + } + + launchAiVaultSessionInNewTab({ + agent: session.agent, + worktreeId: worktree.id, + ...buildResumeStartup(session, worktree.id) + }) + if (useAppStore.getState().activeWorktreeId !== worktree.id) { + activateAndRevealWorktree(worktree.id) + } + toast.success( + translate( + 'auto.components.right.sidebar.AiVaultPanel.agentSessionQueued', + '{{value0}} session queued', + { value0: agentLabel(session.agent) } + ) + ) + }, + [activeWorktree, allWorktrees, buildResumeStartup, repos] + ) + + return { buildResumeStartup, copyResumeCommand, handleResume } +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts new file mode 100644 index 00000000000..8d9a7fdfd9d --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts @@ -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 + scanResult: AiVaultListResult | null + sessions: AiVaultSession[] +} { + const [sessions, setSessions] = useState([]) + const [scanResult, setScanResult] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(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(scopePaths) + scopePathsRef.current = scopePaths + + const refresh = useCallback(async (args: { force?: boolean } = {}): Promise => { + // 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 } +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts new file mode 100644 index 00000000000..9c9e86b2990 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it } from 'vitest' +import type { Repo, Worktree } from '../../../../shared/types' +import type { AiVaultSessionWorktreeInfo } from './ai-vault-session-worktree' +import { + aiVaultSessionResumeLabel, + resolveAiVaultSessionResumeActions, + resolveAiVaultSessionResumeState +} from './ai-vault-session-resume' + +function makeWorktree(overrides: Partial = {}): Worktree { + return { + id: 'repo-1::/repo/orca', + repoId: 'repo-1', + displayName: 'orca', + path: '/repo/orca', + head: 'abc123', + branch: 'main', + isBare: false, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1, + isMainWorktree: false, + ...overrides + } +} + +function makeWorktreeInfo( + status: AiVaultSessionWorktreeInfo['status'] +): AiVaultSessionWorktreeInfo { + return { + status, + label: 'orca', + path: '/repo/orca', + ...(status === 'unavailable' ? {} : { worktreeId: 'repo-1::/repo/orca' }) + } +} + +describe('resolveAiVaultSessionResumeState', () => { + it('prefers the session worktree over the active workspace', () => { + expect( + resolveAiVaultSessionResumeState({ + worktreeInfo: makeWorktreeInfo('active'), + activeWorktreeId: 'repo-1::/repo/other', + worktrees: [ + makeWorktree(), + makeWorktree({ id: 'repo-1::/repo/other', path: '/repo/other' }) + ], + repos: [{ id: 'repo-1' } as Repo] + }) + ).toEqual({ + blocked: false, + worktreeId: 'repo-1::/repo/orca', + usesSessionWorktree: true + }) + }) + + it('falls back to the active workspace when the session worktree is unavailable', () => { + expect( + resolveAiVaultSessionResumeState({ + worktreeInfo: makeWorktreeInfo('archived'), + activeWorktreeId: 'repo-1::/repo/orca', + worktrees: [makeWorktree()], + repos: [{ id: 'repo-1' } as Repo] + }) + ).toEqual({ + blocked: false, + worktreeId: 'repo-1::/repo/orca', + usesSessionWorktree: false + }) + }) + + it('blocks remote targets and missing worktrees', () => { + expect( + resolveAiVaultSessionResumeState({ + worktreeInfo: makeWorktreeInfo('active'), + activeWorktreeId: null, + worktrees: [], + repos: [{ id: 'repo-1', connectionId: 'ssh-1' } as Repo] + }) + ).toEqual({ + blocked: true, + worktreeId: null, + usesSessionWorktree: false + }) + }) + + it('uses a local session worktree when the active workspace is remote', () => { + expect( + resolveAiVaultSessionResumeState({ + worktreeInfo: makeWorktreeInfo('active'), + activeWorktreeId: 'repo-2::/remote/orca', + worktrees: [ + makeWorktree(), + makeWorktree({ + id: 'repo-2::/remote/orca', + repoId: 'repo-2', + path: '/remote/orca' + }) + ], + repos: [{ id: 'repo-1' } as Repo, { id: 'repo-2', connectionId: 'ssh-1' } as Repo] + }) + ).toEqual({ + blocked: false, + worktreeId: 'repo-1::/repo/orca', + usesSessionWorktree: true + }) + }) +}) + +describe('resolveAiVaultSessionResumeActions', () => { + it('exposes separate session-worktree and active-workspace targets', () => { + expect( + resolveAiVaultSessionResumeActions({ + worktreeInfo: makeWorktreeInfo('active'), + activeWorktreeId: 'repo-1::/repo/other', + worktrees: [ + makeWorktree(), + makeWorktree({ id: 'repo-1::/repo/other', path: '/repo/other' }) + ], + repos: [{ id: 'repo-1' } as Repo] + }) + ).toEqual({ + worktree: { worktreeId: 'repo-1::/repo/orca', disabled: false }, + newTab: { worktreeId: 'repo-1::/repo/other', disabled: false } + }) + }) + + it('disables only the remote active-workspace action when the session worktree is local', () => { + expect( + resolveAiVaultSessionResumeActions({ + worktreeInfo: makeWorktreeInfo('active'), + activeWorktreeId: 'repo-2::/remote/orca', + worktrees: [ + makeWorktree(), + makeWorktree({ + id: 'repo-2::/remote/orca', + repoId: 'repo-2', + path: '/remote/orca' + }) + ], + repos: [{ id: 'repo-1' } as Repo, { id: 'repo-2', connectionId: 'ssh-1' } as Repo] + }) + ).toEqual({ + worktree: { worktreeId: 'repo-1::/repo/orca', disabled: false }, + newTab: { worktreeId: 'repo-2::/remote/orca', disabled: true } + }) + }) + + it('does not expose the active workspace as a duplicate new-tab target', () => { + expect( + resolveAiVaultSessionResumeActions({ + worktreeInfo: makeWorktreeInfo('current'), + activeWorktreeId: 'repo-1::/repo/orca', + worktrees: [makeWorktree()], + repos: [{ id: 'repo-1' } as Repo] + }) + ).toEqual({ + worktree: { worktreeId: 'repo-1::/repo/orca', disabled: false }, + newTab: { worktreeId: null, disabled: true } + }) + }) +}) + +describe('aiVaultSessionResumeLabel', () => { + it('names the session worktree action distinctly from the active-workspace fallback', () => { + expect(aiVaultSessionResumeLabel({ usesSessionWorktree: true })).toBe('Resume in Worktree') + expect(aiVaultSessionResumeLabel({ usesSessionWorktree: false })).toBe('Resume in New Tab') + }) +}) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts new file mode 100644 index 00000000000..32481998af0 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts @@ -0,0 +1,140 @@ +import type { Repo, Worktree } from '../../../../shared/types' +import { translate } from '@/i18n/i18n' +import { + canJumpToAiVaultSessionWorktree, + type AiVaultSessionWorktreeInfo +} from './ai-vault-session-worktree' + +export type AiVaultSessionResumeState = { + blocked: boolean + worktreeId: string | null + usesSessionWorktree: boolean +} + +export type AiVaultSessionResumeAction = { + worktreeId: string | null + disabled: boolean +} + +export type AiVaultSessionResumeActions = { + worktree: AiVaultSessionResumeAction + newTab: AiVaultSessionResumeAction +} + +export function resolveAiVaultSessionResumeState(args: { + worktreeInfo: AiVaultSessionWorktreeInfo | null + activeWorktreeId: string | null + worktrees: readonly Worktree[] + repos: readonly Repo[] +}): AiVaultSessionResumeState { + const sessionWorktreeId = + canJumpToAiVaultSessionWorktree(args.worktreeInfo) && args.worktreeInfo?.worktreeId + ? args.worktreeInfo.worktreeId + : null + + const candidateWorktreeIds = [ + sessionWorktreeId, + args.activeWorktreeId && args.activeWorktreeId !== sessionWorktreeId + ? args.activeWorktreeId + : null + ].filter((value): value is string => Boolean(value)) + + for (const worktreeId of candidateWorktreeIds) { + const worktree = args.worktrees.find((candidate) => candidate.id === worktreeId) + if (!worktree) { + continue + } + const repo = args.repos.find((candidate) => candidate.id === worktree.repoId) + if (repo?.connectionId) { + continue + } + return { + blocked: false, + worktreeId, + usesSessionWorktree: worktreeId === sessionWorktreeId + } + } + + return { + blocked: true, + worktreeId: null, + usesSessionWorktree: false + } +} + +export function resolveAiVaultSessionResumeActions(args: { + worktreeInfo: AiVaultSessionWorktreeInfo | null + activeWorktreeId: string | null + worktrees: readonly Worktree[] + repos: readonly Repo[] +}): AiVaultSessionResumeActions { + const sessionWorktreeId = + canJumpToAiVaultSessionWorktree(args.worktreeInfo) && args.worktreeInfo?.worktreeId + ? args.worktreeInfo.worktreeId + : null + + const sessionTargetId = resolveLocalResumeWorktreeId({ + worktreeId: sessionWorktreeId, + worktrees: args.worktrees, + repos: args.repos + }) + const activeTargetId = resolveLocalResumeWorktreeId({ + worktreeId: + args.activeWorktreeId && args.activeWorktreeId !== sessionWorktreeId + ? args.activeWorktreeId + : null, + worktrees: args.worktrees, + repos: args.repos + }) + + return { + worktree: { + worktreeId: sessionWorktreeId, + disabled: !sessionTargetId + }, + newTab: { + worktreeId: + args.activeWorktreeId && args.activeWorktreeId !== sessionWorktreeId + ? args.activeWorktreeId + : null, + disabled: !activeTargetId + } + } +} + +function resolveLocalResumeWorktreeId(args: { + worktreeId: string | null + worktrees: readonly Worktree[] + repos: readonly Repo[] +}): string | null { + if (!args.worktreeId) { + return null + } + + const worktree = args.worktrees.find((candidate) => candidate.id === args.worktreeId) + if (!worktree) { + return null + } + + const repo = args.repos.find((candidate) => candidate.id === worktree.repoId) + if (repo?.connectionId) { + return null + } + + return args.worktreeId +} + +export function aiVaultSessionResumeLabel( + state: Pick +): string { + if (state.usesSessionWorktree) { + return translate( + 'auto.components.right.sidebar.AiVaultSessionDetails.resumeInWorktree', + 'Resume in Worktree' + ) + } + return translate( + 'auto.components.right.sidebar.AiVaultSessionRow.resumeInNewTab', + 'Resume in New Tab' + ) +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts new file mode 100644 index 00000000000..d1adbbb9a94 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, it } from 'vitest' +import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import type { Worktree } from '../../../../shared/types' +import { + aiVaultWorktreeCompactPath, + aiVaultWorktreeJumpTooltip, + canJumpToAiVaultSessionWorktree, + extractWorktreePathFromSessionTitle, + resolveAiVaultSessionWorktreeDisplay, + resolveAiVaultSessionWorktreeInfo, + shouldShowAiVaultWorktreeStatusBadge, + type AiVaultSessionWorktreeInfo +} from './ai-vault-session-worktree' + +const baseSession: AiVaultSession = { + id: 'codex:session-1', + agent: 'codex', + sessionId: 'session-1', + title: 'Find the pane', + cwd: '/repo/orca/src', + branch: null, + model: null, + filePath: '/home/ada/.codex/session-1.jsonl', + codexHome: null, + createdAt: null, + updatedAt: '2026-06-24T10:00:00.000Z', + modifiedAt: '2026-06-24T10:00:00.000Z', + messageCount: 2, + totalTokens: 42, + previewMessages: [], + resumeCommand: "codex resume 'session-1'" +} + +function makeWorktree(overrides: Partial = {}): Worktree { + const worktree: Worktree = { + id: 'repo-1::/repo/orca', + repoId: 'repo-1', + displayName: 'orca', + path: '/repo/orca', + head: 'abc123', + branch: 'main', + isBare: false, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1, + isMainWorktree: false + } + return { ...worktree, ...overrides } +} + +describe('resolveAiVaultSessionWorktreeInfo', () => { + it('marks the selected owning worktree as current', () => { + const worktree = makeWorktree() + + expect( + resolveAiVaultSessionWorktreeInfo({ + session: baseSession, + worktrees: [worktree], + activeWorktreeId: worktree.id + }) + ).toMatchObject({ + status: 'current', + label: 'orca', + path: '/repo/orca' + }) + }) + + it('marks a known non-selected worktree as active', () => { + const worktree = makeWorktree() + + expect( + resolveAiVaultSessionWorktreeInfo({ + session: baseSession, + worktrees: [worktree], + activeWorktreeId: 'other' + })?.status + ).toBe('active') + }) + + it('uses prior worktree paths to identify renamed active worktrees', () => { + const worktree = makeWorktree({ + id: 'repo-1::/repo/orca-renamed', + path: '/repo/orca-renamed', + priorWorktreeIds: ['repo-1::/repo/orca'] + }) + + expect( + resolveAiVaultSessionWorktreeInfo({ + session: baseSession, + worktrees: [worktree], + activeWorktreeId: null + }) + ).toMatchObject({ + status: 'active', + label: 'orca', + path: '/repo/orca' + }) + }) + + it('falls back to unavailable when no known worktree owns the transcript cwd', () => { + expect( + resolveAiVaultSessionWorktreeInfo({ + session: baseSession, + worktrees: [], + activeWorktreeId: null + }) + ).toMatchObject({ + status: 'unavailable', + label: 'orca/src', + path: '/repo/orca/src' + }) + }) + + it('matches WSL UNC worktree paths to Linux transcript cwd values', () => { + const worktree = makeWorktree({ + path: '\\\\wsl.localhost\\Ubuntu\\home\\ada\\orca' + }) + + expect( + resolveAiVaultSessionWorktreeInfo({ + session: { ...baseSession, cwd: '/home/ada/orca/src' }, + worktrees: [worktree], + activeWorktreeId: null + }) + ).toMatchObject({ + status: 'active', + label: 'orca', + path: '\\\\wsl.localhost\\Ubuntu\\home\\ada\\orca' + }) + }) +}) + +describe('canJumpToAiVaultSessionWorktree', () => { + it('allows current and active worktree targets', () => { + expect(canJumpToAiVaultSessionWorktree(makeWorktreeInfo('current'))).toBe(true) + expect(canJumpToAiVaultSessionWorktree(makeWorktreeInfo('active'))).toBe(true) + }) + + it('disables jump targets that are not active worktrees', () => { + expect(canJumpToAiVaultSessionWorktree(makeWorktreeInfo('archived'))).toBe(false) + expect(canJumpToAiVaultSessionWorktree(makeWorktreeInfo('unavailable'))).toBe(false) + expect(canJumpToAiVaultSessionWorktree(null)).toBe(false) + }) +}) + +describe('extractWorktreePathFromSessionTitle', () => { + it('reads worktree paths embedded in session titles', () => { + expect( + extractWorktreePathFromSessionTitle( + 'Inspect PR #6229 - Worktree: /Users/ada/projects/orca/fix-tabs' + ) + ).toBe('/Users/ada/projects/orca/fix-tabs') + expect(extractWorktreePathFromSessionTitle('Worktree: /tmp/orca-worker')).toBe( + '/tmp/orca-worker' + ) + }) +}) + +describe('resolveAiVaultSessionWorktreeDisplay', () => { + it('falls back to title and branch when cwd is missing', () => { + expect( + resolveAiVaultSessionWorktreeDisplay({ + session: { + ...baseSession, + cwd: null, + branch: null, + title: 'Fix tabs - Worktree: /Users/ada/projects/orca/fix-tabs' + }, + worktrees: [makeWorktree()], + activeWorktreeId: null + })?.path + ).toBe('/Users/ada/projects/orca/fix-tabs') + + expect( + resolveAiVaultSessionWorktreeDisplay({ + session: { ...baseSession, cwd: null, branch: 'chinese-translation-improvement' }, + worktrees: [makeWorktree()], + activeWorktreeId: null + })?.label + ).toBe('chinese-translation-improvement') + }) +}) + +describe('aiVaultWorktreeCompactPath', () => { + it('keeps the last two path segments for dense detail rows', () => { + expect(aiVaultWorktreeCompactPath('/Users/ada/projects/orca/improve-agent-session')).toBe( + 'orca/improve-agent-session' + ) + }) +}) + +describe('shouldShowAiVaultWorktreeStatusBadge', () => { + it('hides the generic active badge but keeps meaningful states', () => { + expect(shouldShowAiVaultWorktreeStatusBadge('active')).toBe(false) + expect(shouldShowAiVaultWorktreeStatusBadge('current')).toBe(true) + expect(shouldShowAiVaultWorktreeStatusBadge('archived')).toBe(true) + expect(shouldShowAiVaultWorktreeStatusBadge('unavailable')).toBe(true) + }) +}) + +describe('aiVaultWorktreeJumpTooltip', () => { + it('explains active jump targets and disabled states', () => { + expect(aiVaultWorktreeJumpTooltip(makeWorktreeInfo('active'))).toBe('Jump to Worktree') + expect(aiVaultWorktreeJumpTooltip(makeWorktreeInfo('archived'))).toBe( + 'This session is in an archived worktree.' + ) + expect(aiVaultWorktreeJumpTooltip(makeWorktreeInfo('unavailable'))).toBe( + 'No active worktree matches this session.' + ) + expect(aiVaultWorktreeJumpTooltip(null)).toBe('No worktree was recorded for this session.') + }) +}) + +function makeWorktreeInfo( + status: AiVaultSessionWorktreeInfo['status'] +): AiVaultSessionWorktreeInfo { + return { + status, + label: 'orca', + path: '/repo/orca', + ...(status === 'unavailable' ? {} : { worktreeId: 'repo-1::/repo/orca' }) + } +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.ts new file mode 100644 index 00000000000..142cfe164d6 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.ts @@ -0,0 +1,289 @@ +import { useMemo } from 'react' +import { parseWslUncPath } from '../../../../shared/wsl-paths' +import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree-id' +import { + isPathInsideOrEqual, + isRuntimePathAbsolute, + normalizeRuntimePathForComparison, + normalizeRuntimePathSeparators +} from '../../../../shared/cross-platform-path' +import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import type { Worktree } from '../../../../shared/types' +import { translate } from '@/i18n/i18n' + +export type AiVaultSessionWorktreeStatus = 'current' | 'active' | 'archived' | 'unavailable' + +export type AiVaultSessionWorktreeInfo = { + status: AiVaultSessionWorktreeStatus + label: string + path: string + worktreeId?: string +} + +type WorktreeCandidate = { + worktree: Worktree + path: string + status: Exclude + source: 'current-path' | 'prior-path' +} + +export function resolveAiVaultSessionWorktreeInfo({ + session, + worktrees, + activeWorktreeId +}: { + session: AiVaultSession + worktrees: readonly Worktree[] + activeWorktreeId: string | null +}): AiVaultSessionWorktreeInfo | null { + if (!session.cwd) { + return null + } + + const candidates = buildWorktreeCandidates(worktrees) + .filter((candidate) => isSessionInWorktreePath(candidate.path, session.cwd!)) + .sort(compareWorktreeCandidates) + + const best = candidates[0] + if (!best) { + return { + status: 'unavailable', + label: compactPathLabel(session.cwd), + path: session.cwd + } + } + + const status = + best.worktree.id === activeWorktreeId + ? 'current' + : best.worktree.isArchived + ? 'archived' + : best.status + + return { + status, + label: best.worktree.displayName || compactPathLabel(best.path), + path: best.path, + worktreeId: best.worktree.id + } +} + +export function extractWorktreePathFromSessionTitle(title: string): string | null { + const trimmed = title.trim() + if (!trimmed) { + return null + } + + const suffixMatch = trimmed.match(/\s-\s*Worktree:\s*(.+)$/i) + if (suffixMatch?.[1]) { + return suffixMatch[1].trim() + } + + const inlineMatch = trimmed.match(/\bWorktree:\s*(.+)$/i) + return inlineMatch?.[1]?.trim() ?? null +} + +export function resolveAiVaultSessionWorktreeDisplay(args: { + session: AiVaultSession + worktrees: readonly Worktree[] + activeWorktreeId: string | null +}): AiVaultSessionWorktreeInfo | null { + const resolved = resolveAiVaultSessionWorktreeInfo(args) + if (resolved) { + return resolved + } + + const cwd = args.session.cwd?.trim() + if (cwd) { + return unavailableWorktreeInfo(cwd) + } + + const titlePath = extractWorktreePathFromSessionTitle(args.session.title) + if (titlePath) { + return unavailableWorktreeInfo(titlePath) + } + + const branch = args.session.branch?.trim() + if (branch) { + return { + status: 'unavailable', + label: branch, + path: branch + } + } + + return null +} + +export function useAiVaultSessionWorktreeMap({ + sessions, + worktrees, + activeWorktreeId +}: { + sessions: readonly AiVaultSession[] + worktrees: readonly Worktree[] + activeWorktreeId: string | null +}): ReadonlyMap { + return useMemo( + () => + new Map( + sessions.flatMap((session) => { + const worktreeInfo = resolveAiVaultSessionWorktreeDisplay({ + session, + worktrees, + activeWorktreeId + }) + return worktreeInfo ? [[session.id, worktreeInfo] as const] : [] + }) + ), + [activeWorktreeId, sessions, worktrees] + ) +} + +export function canJumpToAiVaultSessionWorktree( + worktreeInfo: AiVaultSessionWorktreeInfo | null +): boolean { + return Boolean( + worktreeInfo?.worktreeId && + worktreeInfo.status !== 'archived' && + worktreeInfo.status !== 'unavailable' + ) +} + +export function aiVaultWorktreeJumpTooltip( + worktreeInfo: AiVaultSessionWorktreeInfo | null +): string { + if (canJumpToAiVaultSessionWorktree(worktreeInfo)) { + return translate( + 'auto.components.right.sidebar.AiVaultSessionWorktree.jumpToWorktree', + 'Jump to Worktree' + ) + } + if (!worktreeInfo) { + return translate( + 'auto.components.right.sidebar.AiVaultSessionWorktree.noRecordedWorktree', + 'No worktree was recorded for this session.' + ) + } + if (worktreeInfo.status === 'archived') { + return translate( + 'auto.components.right.sidebar.AiVaultSessionWorktree.archivedJumpUnavailable', + 'This session is in an archived worktree.' + ) + } + if (worktreeInfo.status === 'unavailable') { + return translate( + 'auto.components.right.sidebar.AiVaultSessionWorktree.noActiveWorktreeMatch', + 'No active worktree matches this session.' + ) + } + return translate( + 'auto.components.right.sidebar.AiVaultSessionWorktree.noActiveWorktreeTarget', + 'No active worktree is available.' + ) +} + +function buildWorktreeCandidates(worktrees: readonly Worktree[]): WorktreeCandidate[] { + const candidates: WorktreeCandidate[] = [] + for (const worktree of worktrees) { + if (hasUsablePath(worktree.path)) { + candidates.push({ + worktree, + path: worktree.path, + status: worktree.isArchived ? 'archived' : 'active', + source: 'current-path' + }) + } + for (const priorWorktreeId of worktree.priorWorktreeIds ?? []) { + const parsed = splitWorktreeIdForFilesystem(priorWorktreeId) + if (!parsed || parsed.repoId !== worktree.repoId || !hasUsablePath(parsed.worktreePath)) { + continue + } + candidates.push({ + worktree, + path: parsed.worktreePath, + status: worktree.isArchived ? 'archived' : 'active', + source: 'prior-path' + }) + } + } + return candidates +} + +function hasUsablePath(pathValue: string): boolean { + const trimmed = pathValue.trim() + return Boolean(trimmed && isRuntimePathAbsolute(trimmed)) +} + +function isSessionInWorktreePath(worktreePath: string, sessionCwd: string): boolean { + if (isPathInsideOrEqual(worktreePath, sessionCwd)) { + return true + } + const wslPath = parseWslUncPath(worktreePath) + return wslPath ? isPathInsideOrEqual(wslPath.linuxPath, sessionCwd) : false +} + +function compareWorktreeCandidates(left: WorktreeCandidate, right: WorktreeCandidate): number { + const lengthDifference = + normalizeRuntimePathForComparison(right.path).length - + normalizeRuntimePathForComparison(left.path).length + if (lengthDifference !== 0) { + return lengthDifference + } + if (left.source === right.source) { + return 0 + } + return left.source === 'current-path' ? -1 : 1 +} + +export function aiVaultWorktreeCompactPath(pathValue: string): string { + const parts = normalizeRuntimePathSeparators(pathValue).split('/').filter(Boolean) + if (parts.length >= 2) { + return parts.slice(-2).join('/') + } + return parts[0] ?? pathValue +} + +export function shouldShowAiVaultWorktreeStatusBadge( + status: AiVaultSessionWorktreeStatus +): boolean { + // Why: "active" repeats the branch label without adding scan value in dense rows. + return status !== 'active' +} + +function unavailableWorktreeInfo(pathValue: string): AiVaultSessionWorktreeInfo { + return { + status: 'unavailable', + label: compactPathLabel(pathValue), + path: pathValue + } +} + +function compactPathLabel(pathValue: string): string { + return aiVaultWorktreeCompactPath(pathValue) +} + +export function aiVaultWorktreeStatusLabel(status: AiVaultSessionWorktreeStatus): string { + if (status === 'current') { + return translate( + 'auto.components.right.sidebar.AiVaultSessionWorktree.currentWorktree', + 'Current worktree' + ) + } + if (status === 'active') { + return translate( + 'auto.components.right.sidebar.AiVaultSessionWorktree.activeWorktree', + 'Active worktree' + ) + } + if (status === 'archived') { + return translate( + 'auto.components.right.sidebar.AiVaultSessionWorktree.archivedWorktree', + 'Archived worktree' + ) + } + return translate( + 'auto.components.right.sidebar.AiVaultSessionWorktree.unavailableWorktree', + 'Unavailable worktree' + ) +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-virtual-rows.ts b/src/renderer/src/components/right-sidebar/ai-vault-virtual-rows.ts index 32b9d9bb47e..c92e3f1e64e 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-virtual-rows.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-virtual-rows.ts @@ -6,7 +6,7 @@ import { } from '../sidebar/worktree-list-virtual-rows' export const VAULT_GROUP_HEADER_ROW_HEIGHT = 32 -export const VAULT_SESSION_ROW_HEIGHT = 82 +export const VAULT_SESSION_ROW_HEIGHT = 98 export type VaultVirtualRow = { type: 'group' | 'session' } diff --git a/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx b/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx index 6fa3af42929..2d8ca10c2f7 100644 --- a/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx +++ b/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx @@ -247,6 +247,58 @@ export function GeneralEditorSettingsSection({ /> + +
+ +

+ {translate( + 'auto.components.settings.GeneralEditorSettingsSection.4aa4d9fb73', + 'Wrap long lines in diff editors instead of requiring horizontal scrolling.' + )} +

+
+ 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' + ) + } + ]} + /> +
+ ({ + 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('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 }) + }) +}) diff --git a/src/renderer/src/components/settings/RepositoryWorktreeDefaultsSection.tsx b/src/renderer/src/components/settings/RepositoryWorktreeDefaultsSection.tsx index ab13d3cfb7a..db262bd0645 100644 --- a/src/renderer/src/components/settings/RepositoryWorktreeDefaultsSection.tsx +++ b/src/renderer/src/components/settings/RepositoryWorktreeDefaultsSection.tsx @@ -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" />

diff --git a/src/renderer/src/components/settings/TerminalAppearanceSection.ghostty.test.ts b/src/renderer/src/components/settings/TerminalAppearanceSection.ghostty.test.ts index 36e8eb5bae9..deb30721179 100644 --- a/src/renderer/src/components/settings/TerminalAppearanceSection.ghostty.test.ts +++ b/src/renderer/src/components/settings/TerminalAppearanceSection.ghostty.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mockStateValues: unknown[] = [] let mockStateIndex = 0 +let mockSettingsSearchQuery = '' function resetMockState() { mockStateIndex = 0 @@ -30,7 +31,7 @@ vi.mock('react', async () => { vi.mock('../../store', () => ({ useAppStore: (selector: (state: { settingsSearchQuery: string }) => unknown) => - selector({ settingsSearchQuery: '' }) + selector({ settingsSearchQuery: mockSettingsSearchQuery }) })) vi.mock('@/lib/keyboard-layout/use-effective-mac-option-as-alt', () => ({ @@ -105,13 +106,7 @@ vi.mock('./SettingsFormControls', () => ({ })) vi.mock('./TerminalThemeSections', () => ({ - DarkTerminalThemeSection: function DarkTerminalThemeSection() { - return null - }, - LightTerminalThemeSection: function LightTerminalThemeSection() { - return null - }, - TerminalThemeImportSection: function TerminalThemeImportSection() { + TerminalThemeCatalogSection: function TerminalThemeCatalogSection() { return null } })) @@ -255,13 +250,13 @@ function findButtons(node: unknown): { text: string; onClick: (() => void) | und return buttons } -function findDarkTerminalThemeSection(node: unknown): ReactElementLike | null { +function findTerminalThemeCatalogSection(node: unknown): ReactElementLike | null { if (node == null) { return null } if (Array.isArray(node)) { for (const child of node) { - const found = findDarkTerminalThemeSection(child) + const found = findTerminalThemeCatalogSection(child) if (found) { return found } @@ -270,11 +265,11 @@ function findDarkTerminalThemeSection(node: unknown): ReactElementLike | null { } const el = node as ReactElementLike const typeName = typeof el.type === 'function' ? el.type.name : String(el.type) - if (typeName === 'DarkTerminalThemeSection') { + if (typeName === 'TerminalThemeCatalogSection') { return el } if (el.props?.children) { - return findDarkTerminalThemeSection(el.props.children) + return findTerminalThemeCatalogSection(el.props.children) } return null } @@ -303,30 +298,6 @@ function findGhosttyImportModal(node: unknown): ReactElementLike | null { return null } -function findTerminalThemeImportSection(node: unknown): ReactElementLike | null { - if (node == null) { - return null - } - if (Array.isArray(node)) { - for (const child of node) { - const found = findTerminalThemeImportSection(child) - if (found) { - return found - } - } - return null - } - const el = node as ReactElementLike - const typeName = typeof el.type === 'function' ? el.type.name : String(el.type) - if (typeName === 'TerminalThemeImportSection') { - return el - } - if (el.props?.children) { - return findTerminalThemeImportSection(el.props.children) - } - return null -} - function findWarpThemeImportModal(node: unknown): ReactElementLike | null { if (node == null) { return null @@ -354,6 +325,7 @@ function findWarpThemeImportModal(node: unknown): ReactElementLike | null { describe('TerminalAppearanceSection ghostty import wiring', () => { beforeEach(() => { mockStateValues.length = 0 + mockSettingsSearchQuery = '' resetMockState() vi.unstubAllGlobals() vi.stubGlobal('window', { location: { pathname: '/index.html' } }) @@ -378,7 +350,7 @@ describe('TerminalAppearanceSection ghostty import wiring', () => { expect(ghosttyMock.handleClick).toHaveBeenCalled() }) - it('renders the shared theme import section above the theme pickers on desktop', () => { + it('passes shared theme import controls into the theme catalog on desktop', () => { const element = TerminalAppearanceSection({ settings: {} as never, updateSettings: () => {}, @@ -388,14 +360,65 @@ describe('TerminalAppearanceSection ghostty import wiring', () => { warpThemes: warpThemesMock }) - // Why: imports land in one pool shared by both pickers, so the buttons - // live in their own section rather than inside the Dark Theme section. const buttons = findButtons(element) - expect(buttons.some((button) => button.text === 'Import themes from Warp')).toBe(false) + expect(buttons.some((button) => button.text === 'Import from Warp')).toBe(false) - const importSection = findTerminalThemeImportSection(element) - expect(importSection?.props.warpThemes).toBe(warpThemesMock) - expect(findDarkTerminalThemeSection(element)).not.toBeNull() + const catalog = findTerminalThemeCatalogSection(element) + expect(catalog?.props.warpThemes).toBe(warpThemesMock) + expect(catalog?.props.showThemeImport).toBe(true) + }) + + it('routes dark and light theme searches to the matching catalog target', () => { + mockSettingsSearchQuery = 'Light Divider Color' + const lightElement = TerminalAppearanceSection({ + settings: {} as never, + updateSettings: () => {}, + systemPrefersDark: true, + terminalFontSuggestions: [], + ghostty: ghosttyMock, + warpThemes: warpThemesMock + }) + + expect(findTerminalThemeCatalogSection(lightElement)?.props.preferredTarget).toBe('light') + + mockSettingsSearchQuery = 'Dark Theme' + resetMockState() + const darkElement = TerminalAppearanceSection({ + settings: {} as never, + updateSettings: () => {}, + systemPrefersDark: true, + terminalFontSuggestions: [], + ghostty: ghosttyMock, + warpThemes: warpThemesMock + }) + + expect(findTerminalThemeCatalogSection(darkElement)?.props.preferredTarget).toBe('dark') + + mockSettingsSearchQuery = 'dark' + resetMockState() + const darkAliasElement = TerminalAppearanceSection({ + settings: {} as never, + updateSettings: () => {}, + systemPrefersDark: true, + terminalFontSuggestions: [], + ghostty: ghosttyMock, + warpThemes: warpThemesMock + }) + + expect(findTerminalThemeCatalogSection(darkAliasElement)?.props.preferredTarget).toBe('dark') + + mockSettingsSearchQuery = 'dark terminal theme' + resetMockState() + const darkPhraseElement = TerminalAppearanceSection({ + settings: {} as never, + updateSettings: () => {}, + systemPrefersDark: true, + terminalFontSuggestions: [], + ghostty: ghosttyMock, + warpThemes: warpThemesMock + }) + + expect(findTerminalThemeCatalogSection(darkPhraseElement)?.props.preferredTarget).toBe('dark') }) it('hides the theme import affordance on paired web clients', () => { @@ -413,7 +436,7 @@ describe('TerminalAppearanceSection ghostty import wiring', () => { warpThemes: warpThemesMock }) - expect(findTerminalThemeImportSection(element)).toBeNull() + expect(findTerminalThemeCatalogSection(element)?.props.showThemeImport).toBe(false) expect(findWarpThemeImportModal(element)).toBeNull() }) diff --git a/src/renderer/src/components/settings/TerminalAppearanceSection.tsx b/src/renderer/src/components/settings/TerminalAppearanceSection.tsx index 0ffac9efa37..e790673835a 100644 --- a/src/renderer/src/components/settings/TerminalAppearanceSection.tsx +++ b/src/renderer/src/components/settings/TerminalAppearanceSection.tsx @@ -1,6 +1,10 @@ import { useState } from 'react' import type { GlobalSettings } from '../../../../shared/types' -import { matchesSettingsSearch } from './settings-search' +import { + matchesSettingsSearch, + scoreSettingsSearch, + type SettingsSearchEntry +} from './settings-search' import { useAppStore } from '../../store' import { getTerminalCursorSearchEntries, @@ -8,16 +12,13 @@ import { getTerminalGhosttyImportSearchEntries, getTerminalLightThemeSearchEntries, getTerminalPaneAppearanceSearchEntries, + getTerminalThemeTargetSearchEntries, getTerminalTypographySearchEntries, getTerminalWarpImportSearchEntries, getTerminalWindowSearchEntries, getTerminalYamlImportSearchEntries } from './terminal-search' -import { - DarkTerminalThemeSection, - LightTerminalThemeSection, - TerminalThemeImportSection -} from './TerminalThemeSections' +import { TerminalThemeCatalogSection } from './TerminalThemeSections' import { TerminalWindowSection } from './TerminalWindowSection' import { TerminalTypographyAppearanceSection } from './TerminalTypographyAppearanceSection' import { TerminalCursorAppearanceSection } from './TerminalCursorAppearanceSection' @@ -37,6 +38,26 @@ type TerminalAppearanceSectionProps = { warpThemes: UseWarpThemeImportReturn } +type TerminalThemeTarget = 'dark' | 'light' + +function scoreThemeTargetIntent(searchQuery: string, entries: SettingsSearchEntry[]): number { + // Why: descriptions mention dark/light incidentally; target intent should come from labels and aliases. + return scoreSettingsSearch( + searchQuery, + entries.map(({ title, keywords }) => ({ title, keywords })) + ) +} + +function getPreferredThemeTarget( + darkThemeSearchScore: number, + lightThemeSearchScore: number +): TerminalThemeTarget | undefined { + if (darkThemeSearchScore === lightThemeSearchScore) { + return undefined + } + return darkThemeSearchScore > lightThemeSearchScore ? 'dark' : 'light' +} + export function TerminalAppearanceSection({ settings, updateSettings, @@ -46,10 +67,28 @@ export function TerminalAppearanceSection({ warpThemes }: TerminalAppearanceSectionProps): React.JSX.Element { const searchQuery = useAppStore((state) => state.settingsSearchQuery) - const [themeSearchDark, setThemeSearchDark] = useState('') - const [themeSearchLight, setThemeSearchLight] = useState('') + const [themeSearch, setThemeSearch] = useState('') const [previewFontFamily, setPreviewFontFamily] = useState(null) const showWarpThemeImport = !isWebClientLocation() + const darkThemeSearchEntries = getTerminalDarkThemeSearchEntries() + const lightThemeSearchEntries = getTerminalLightThemeSearchEntries() + const darkThemeSearchScore = scoreSettingsSearch(searchQuery, darkThemeSearchEntries) + const lightThemeSearchScore = scoreSettingsSearch(searchQuery, lightThemeSearchEntries) + const darkThemeTargetScore = scoreThemeTargetIntent(searchQuery, darkThemeSearchEntries) + const lightThemeTargetScore = scoreThemeTargetIntent(searchQuery, lightThemeSearchEntries) + const darkThemeMatches = darkThemeSearchScore > 0 + const lightThemeMatches = lightThemeSearchScore > 0 + const themeTargetMatches = matchesSettingsSearch( + searchQuery, + getTerminalThemeTargetSearchEntries() + ) + const themeImportMatches = + showWarpThemeImport && + (matchesSettingsSearch(searchQuery, getTerminalWarpImportSearchEntries()) || + matchesSettingsSearch(searchQuery, getTerminalYamlImportSearchEntries())) + const showTerminalThemeCatalog = + darkThemeMatches || lightThemeMatches || themeTargetMatches || themeImportMatches + const preferredThemeTarget = getPreferredThemeTarget(darkThemeTargetScore, lightThemeTargetScore) const visibleSections = [ matchesSettingsSearch(searchQuery, getTerminalGhosttyImportSearchEntries()) || @@ -82,31 +121,19 @@ export function TerminalAppearanceSection({ matchesSettingsSearch(searchQuery, getTerminalWindowSearchEntries()) ? ( ) : null, - showWarpThemeImport && - (matchesSettingsSearch(searchQuery, getTerminalWarpImportSearchEntries()) || - matchesSettingsSearch(searchQuery, getTerminalYamlImportSearchEntries())) ? ( - - ) : null, - matchesSettingsSearch(searchQuery, getTerminalDarkThemeSearchEntries()) ? ( - - ) : null, - matchesSettingsSearch(searchQuery, getTerminalLightThemeSearchEntries()) ? ( - ) : null ].filter(Boolean) diff --git a/src/renderer/src/components/settings/TerminalPane.pwsh.test.ts b/src/renderer/src/components/settings/TerminalPane.pwsh.test.ts index ebf575749c9..b796c0c9d32 100644 --- a/src/renderer/src/components/settings/TerminalPane.pwsh.test.ts +++ b/src/renderer/src/components/settings/TerminalPane.pwsh.test.ts @@ -122,10 +122,7 @@ vi.mock('./SettingsFormControls', () => ({ })) vi.mock('./TerminalThemeSections', () => ({ - DarkTerminalThemeSection: function DarkTerminalThemeSection() { - return null - }, - LightTerminalThemeSection: function LightTerminalThemeSection() { + TerminalThemeCatalogSection: function TerminalThemeCatalogSection() { return null } })) diff --git a/src/renderer/src/components/settings/TerminalThemeSections.lifecycle.test.ts b/src/renderer/src/components/settings/TerminalThemeSections.lifecycle.test.ts index bde83da6774..5e8b643e111 100644 --- a/src/renderer/src/components/settings/TerminalThemeSections.lifecycle.test.ts +++ b/src/renderer/src/components/settings/TerminalThemeSections.lifecycle.test.ts @@ -1,18 +1,29 @@ -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import type { GlobalSettings } from '../../../../shared/types' import type { UseWarpThemeImportReturn } from './useWarpThemeImport' +let themeTarget: 'dark' | 'light' | undefined = 'dark' + +vi.mock('react', async () => { + const actual = await vi.importActual('react') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() + return { + ...actual, + useState: (initial: T) => [ + themeTarget ?? initial, + (value: T) => { + themeTarget = value as 'dark' | 'light' + } + ] + } +}) + vi.mock('./TerminalSettingsPreview', () => ({ TerminalSettingsPreview: function TerminalSettingsPreview() { return null } })) -import { - DarkTerminalThemeSection, - LightTerminalThemeSection, - TerminalThemeImportSection -} from './TerminalThemeSections' +import { TerminalThemeCatalogSection } from './TerminalThemeSections' type ReactElementLike = { type: unknown @@ -37,48 +48,43 @@ const warpThemesMock: UseWarpThemeImportReturn = { handleOpenChange: vi.fn() } -function findButtonTexts(node: unknown): string[] { - if (node == null || typeof node === 'string' || typeof node === 'number') { - return [] - } - if (Array.isArray(node)) { - return node.flatMap(findButtonTexts) - } - const element = node as ReactElementLike - const typeName = typeof element.type === 'function' ? element.type.name : String(element.type) - if (typeName === 'WarpThemeImportButton') { - return ['Import themes from Warp'] - } - if (typeName === 'YamlThemeImportButton') { - return ['Import from YAML'] - } - return [...findButtonTexts(element.props?.children), ...findButtonTexts(element.props?.action)] -} - -function renderDarkSection(): React.JSX.Element { - return DarkTerminalThemeSection({ - settings: { - terminalThemeDark: 'Ghostty Default Style Dark', - terminalDividerColorDark: '#3f3f46' - } as GlobalSettings, - systemPrefersDark: true, - themeSearchDark: '', - setThemeSearchDark: () => {}, - updateSettings: () => {}, - previewFontFamily: null, - importedHighlightSignal: 0 - }) -} - function makeSettings(overrides: Partial = {}): GlobalSettings { return { terminalUseSeparateLightTheme: false, + terminalThemeDark: 'Ghostty Default Style Dark', terminalThemeLight: 'Builtin Tango Light', + terminalDividerColorDark: '#3f3f46', terminalDividerColorLight: '#d4d4d8', + terminalCustomThemes: [], ...overrides } as GlobalSettings } +function renderCatalog( + settings = makeSettings(), + updateSettings = vi.fn(), + target?: 'dark' | 'light', + preferredTarget?: 'dark' | 'light' +): React.JSX.Element { + themeTarget = target + return TerminalThemeCatalogSection({ + settings, + systemPrefersDark: true, + themeSearch: '', + setThemeSearch: () => {}, + updateSettings, + previewFontFamily: null, + importedHighlightSignal: 7, + warpThemes: warpThemesMock, + showThemeImport: true, + preferredTarget + }) +} + +function getTypeName(node: ReactElementLike): string { + return typeof node.type === 'function' ? node.type.name : String(node.type) +} + function countElementsByTypeName(node: unknown, typeName: string): number { if (node == null || typeof node === 'string' || typeof node === 'number') { return 0 @@ -88,45 +94,239 @@ function countElementsByTypeName(node: unknown, typeName: string): number { } const element = node as ReactElementLike - const currentTypeName = - typeof element.type === 'function' ? element.type.name : String(element.type) const childCount = countElementsByTypeName(element.props?.children, typeName) - return currentTypeName === typeName ? childCount + 1 : childCount + return getTypeName(element) === typeName ? childCount + 1 : childCount } -function renderLightSection(settings: GlobalSettings): React.JSX.Element { - return LightTerminalThemeSection({ - settings, - themeSearchLight: '', - setThemeSearchLight: () => {}, - updateSettings: () => {}, - previewFontFamily: null - }) +function findElementByTypeName(node: unknown, typeName: string): ReactElementLike | null { + if (node == null || typeof node === 'string' || typeof node === 'number') { + return null + } + if (Array.isArray(node)) { + for (const child of node) { + const found = findElementByTypeName(child, typeName) + if (found) { + return found + } + } + return null + } + + const element = node as ReactElementLike + if (getTypeName(element) === typeName) { + return element + } + return findElementByTypeName(element.props?.children, typeName) } -describe('LightTerminalThemeSection preview lifecycle', () => { - it('does not mount the terminal preview while separate light theme is disabled', () => { - const element = renderLightSection(makeSettings({ terminalUseSeparateLightTheme: false })) +function findElementByClassSubstring( + node: unknown, + classNameSubstring: string +): ReactElementLike | null { + if (node == null || typeof node === 'string' || typeof node === 'number') { + return null + } + if (Array.isArray(node)) { + for (const child of node) { + const found = findElementByClassSubstring(child, classNameSubstring) + if (found) { + return found + } + } + return null + } - expect(countElementsByTypeName(element, 'TerminalSettingsPreview')).toBe(0) + const element = node as ReactElementLike + if ( + typeof element.props?.className === 'string' && + element.props.className.includes(classNameSubstring) + ) { + return element + } + return findElementByClassSubstring(element.props?.children, classNameSubstring) +} + +function findButtonTexts(node: unknown): string[] { + if (node == null || typeof node === 'string' || typeof node === 'number') { + return [] + } + if (Array.isArray(node)) { + return node.flatMap(findButtonTexts) + } + const element = node as ReactElementLike + const typeName = getTypeName(element) + if (typeName === 'WarpThemeImportButton') { + return ['Import from Warp'] + } + if (typeName === 'YamlThemeImportButton') { + return ['Import from YAML'] + } + return [...findButtonTexts(element.props?.children), ...findButtonTexts(element.props?.action)] +} + +describe('TerminalThemeCatalogSection', () => { + beforeEach(() => { + themeTarget = 'dark' + vi.clearAllMocks() }) - it('mounts the terminal preview when separate light theme is enabled', () => { - const element = renderLightSection(makeSettings({ terminalUseSeparateLightTheme: true })) + it('renders one theme picker and one preview for the active target', () => { + const element = renderCatalog(makeSettings({ terminalUseSeparateLightTheme: false }), vi.fn()) + expect(countElementsByTypeName(element, 'ThemePicker')).toBe(1) expect(countElementsByTypeName(element, 'TerminalSettingsPreview')).toBe(1) + expect(findElementByTypeName(element, 'TerminalSettingsPreview')?.props?.modeOverride).toBe( + 'dark' + ) + }) + + it('keeps the light target enabled while separate light theme is disabled', () => { + const element = renderCatalog(makeSettings({ terminalUseSeparateLightTheme: false }), vi.fn()) + const targetControl = findElementByTypeName(element, 'SettingsSegmentedControl') + const options = targetControl?.props?.options as readonly { + value: string + disabled?: boolean + }[] + + expect(options.find((option) => option.value === 'light')?.disabled).toBeUndefined() + }) + + it('uses the preferred target when opened from a light-specific search', () => { + const element = renderCatalog( + makeSettings({ terminalUseSeparateLightTheme: true }), + vi.fn(), + undefined, + 'light' + ) + const picker = findElementByTypeName(element, 'ThemePicker') + const preview = findElementByTypeName(element, 'TerminalSettingsPreview') + + expect(picker?.props?.selectedTheme).toBe('Builtin Tango Light') + expect(preview?.props?.modeOverride).toBe('light') + }) + + it('updates the dark theme from the catalog when the dark target is active', () => { + const updateSettings = vi.fn() + const element = renderCatalog(makeSettings(), updateSettings, 'dark') + const picker = findElementByTypeName(element, 'ThemePicker') + const selectTheme = picker?.props?.onSelectTheme as (theme: string) => void + + selectTheme('Builtin Solarized Dark') + + expect(updateSettings).toHaveBeenCalledWith({ terminalThemeDark: 'Builtin Solarized Dark' }) + }) + + it('updates the light theme from the catalog when the light target is active', () => { + const updateSettings = vi.fn() + const element = renderCatalog( + makeSettings({ terminalUseSeparateLightTheme: true }), + updateSettings, + 'light' + ) + const picker = findElementByTypeName(element, 'ThemePicker') + const selectTheme = picker?.props?.onSelectTheme as (theme: string) => void + + selectTheme('Builtin Tango Light') + + expect(updateSettings).toHaveBeenCalledWith({ terminalThemeLight: 'Builtin Tango Light' }) + }) + + it('turns match dark mode off to customize light mode', () => { + const updateSettings = vi.fn() + const element = renderCatalog( + makeSettings({ terminalUseSeparateLightTheme: false }), + updateSettings, + 'light' + ) + const matchDarkModeSwitch = findElementByTypeName(element, 'SettingsSwitchRow') + const toggleMatchDarkMode = matchDarkModeSwitch?.props?.onChange as () => void + + expect(matchDarkModeSwitch?.props?.label).toBe('Match dark mode') + expect(matchDarkModeSwitch?.props?.description).toBe( + 'Share the dark terminal theme and divider color in light mode.' + ) + expect(matchDarkModeSwitch?.props?.checked).toBe(true) + + toggleMatchDarkMode() + + expect(updateSettings).toHaveBeenCalledWith({ terminalUseSeparateLightTheme: true }) + }) + + it('turns match dark mode on from the customized light state', () => { + const updateSettings = vi.fn() + const element = renderCatalog( + makeSettings({ terminalUseSeparateLightTheme: true }), + updateSettings, + 'light' + ) + const matchDarkModeSwitch = findElementByTypeName(element, 'SettingsSwitchRow') + const toggleMatchDarkMode = matchDarkModeSwitch?.props?.onChange as () => void + + expect(matchDarkModeSwitch?.props?.checked).toBe(false) + + toggleMatchDarkMode() + + expect(updateSettings).toHaveBeenCalledWith({ terminalUseSeparateLightTheme: false }) + }) + + it('collapses light customization with a transition while matching dark mode', () => { + const element = renderCatalog( + makeSettings({ terminalUseSeparateLightTheme: false }), + vi.fn(), + 'light' + ) + const preview = findElementByTypeName(element, 'TerminalSettingsPreview') + const matchDarkModeSwitch = findElementByTypeName(element, 'SettingsSwitchRow') + const transitionRegion = findElementByClassSubstring( + element, + 'transition-[grid-template-rows,padding-top]' + ) + + expect(matchDarkModeSwitch?.props?.label).toBe('Match dark mode') + expect(transitionRegion?.props?.className).toContain('grid-rows-[0fr]') + expect(transitionRegion?.props?.['aria-hidden']).toBe(true) + expect(transitionRegion?.props?.inert).toBe(true) + expect(countElementsByTypeName(element, 'ThemePicker')).toBe(1) + expect(countElementsByTypeName(element, 'ColorField')).toBe(1) + expect(preview?.props?.modeOverride).toBe('light') + }) + + it('uses the active target for divider color updates', () => { + const updateSettings = vi.fn() + const lightElement = renderCatalog( + makeSettings({ terminalUseSeparateLightTheme: true }), + updateSettings, + 'light' + ) + const lightColorField = findElementByTypeName(lightElement, 'ColorField') + const updateLightDividerColor = lightColorField?.props?.onChange as (value: string) => void + + updateLightDividerColor('#ffffff') + + expect(updateSettings).toHaveBeenCalledWith({ terminalDividerColorLight: '#ffffff' }) + + updateSettings.mockClear() + const darkElement = renderCatalog(makeSettings(), updateSettings, 'dark') + const darkColorField = findElementByTypeName(darkElement, 'ColorField') + const updateDarkDividerColor = darkColorField?.props?.onChange as (value: string) => void + + updateDarkDividerColor('#000000') + + expect(updateSettings).toHaveBeenCalledWith({ terminalDividerColorDark: '#000000' }) + }) + + it('passes imported theme highlight signals into the shared picker', () => { + const element = renderCatalog() + const picker = findElementByTypeName(element, 'ThemePicker') + + expect(picker?.props?.importedHighlightSignal).toBe(7) }) }) -describe('TerminalThemeImportSection', () => { - it('renders the Warp and YAML import buttons in the shared import section', () => { - const buttonTexts = findButtonTexts(TerminalThemeImportSection({ warpThemes: warpThemesMock })) +describe('Terminal theme imports', () => { + it('renders the Warp and YAML import buttons inside the combined theme catalog', () => { + const buttonTexts = findButtonTexts(renderCatalog()) - expect(buttonTexts).toContain('Import themes from Warp') - expect(buttonTexts).toContain('Import from YAML') - }) - - it('keeps the import buttons out of the mode-specific theme sections', () => { - expect(findButtonTexts(renderDarkSection())).toEqual([]) + expect(buttonTexts).toEqual(['Import from Warp', 'Import from YAML']) }) }) diff --git a/src/renderer/src/components/settings/TerminalThemeSections.tsx b/src/renderer/src/components/settings/TerminalThemeSections.tsx index 01105472cf9..081d47d7a1b 100644 --- a/src/renderer/src/components/settings/TerminalThemeSections.tsx +++ b/src/renderer/src/components/settings/TerminalThemeSections.tsx @@ -1,6 +1,12 @@ -import type { Dispatch, SetStateAction } from 'react' +import { useState, type Dispatch, type SetStateAction } from 'react' import type { GlobalSettings } from '../../../../shared/types' -import { ColorField, SettingsSubsectionHeader, ThemePicker } from './SettingsFormControls' +import { + ColorField, + SettingsSegmentedControl, + SettingsSwitchRow, + SettingsSubsectionHeader, + ThemePicker +} from './SettingsFormControls' import { SearchableSetting } from './SearchableSetting' import { TerminalSettingsPreview } from './TerminalSettingsPreview' import { WarpThemeImportButton } from './WarpThemeImportButton' @@ -8,292 +14,258 @@ import { YamlThemeImportButton } from './YamlThemeImportButton' import type { UseWarpThemeImportReturn } from './useWarpThemeImport' import { getAvailableTerminalThemeOptions } from '@/lib/terminal-theme' import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' -type DarkTerminalThemeSectionProps = { +type TerminalThemeTarget = 'dark' | 'light' + +type TerminalThemeCatalogSectionProps = { settings: GlobalSettings systemPrefersDark: boolean - themeSearchDark: string - setThemeSearchDark: Dispatch> + themeSearch: string + setThemeSearch: Dispatch> updateSettings: (updates: Partial) => void previewFontFamily: string | null importedHighlightSignal: number -} - -type LightTerminalThemeSectionProps = { - settings: GlobalSettings - themeSearchLight: string - setThemeSearchLight: Dispatch> - updateSettings: (updates: Partial) => void - previewFontFamily: string | null -} - -/** Shared import affordance for terminal themes. Why: imported themes land in - * one pool used by both the dark and light pickers, so the buttons live above - * both sections rather than implying a mode-specific import. */ -export function TerminalThemeImportSection({ - warpThemes -}: { warpThemes: UseWarpThemeImportReturn -}): React.JSX.Element { - return ( -

- -
- - -
-
- ) + showThemeImport: boolean + preferredTarget?: TerminalThemeTarget } -export function DarkTerminalThemeSection({ +export function TerminalThemeCatalogSection({ settings, systemPrefersDark, - themeSearchDark, - setThemeSearchDark, + themeSearch, + setThemeSearch, updateSettings, previewFontFamily, - importedHighlightSignal -}: DarkTerminalThemeSectionProps): React.JSX.Element { + importedHighlightSignal, + warpThemes, + showThemeImport, + preferredTarget +}: TerminalThemeCatalogSectionProps): React.JSX.Element { + const [target, setTarget] = useState(preferredTarget ?? 'dark') const themeOptions = getAvailableTerminalThemeOptions(settings) + const isLightTarget = target === 'light' + const matchDarkMode = !settings.terminalUseSeparateLightTheme + const lightModeMatchesDark = isLightTarget && matchDarkMode + const showCustomControls = !lightModeMatchesDark + const selectedTheme = isLightTarget ? settings.terminalThemeLight : settings.terminalThemeDark + const pickerTitle = isLightTarget + ? translate('auto.components.settings.TerminalThemeSections.8273bc75d7', 'Light Theme') + : translate('auto.components.settings.TerminalThemeSections.9499ad1dc4', 'Dark Theme') + const pickerDescription = isLightTarget + ? translate( + 'auto.components.settings.TerminalThemeSections.d56af60e6f', + 'Choose the theme used when Orca is in light mode.' + ) + : translate( + 'auto.components.settings.TerminalThemeSections.7add204bd5', + 'Choose the terminal theme used in dark mode.' + ) + const dividerTitle = isLightTarget + ? translate('auto.components.settings.TerminalThemeSections.ec2e33ad80', 'Light Divider Color') + : translate('auto.components.settings.TerminalThemeSections.b739d2abfe', 'Dark Divider Color') + const dividerDescription = isLightTarget + ? translate( + 'auto.components.settings.TerminalThemeSections.5e0c24b5c8', + 'Controls the split divider line between panes in light mode.' + ) + : translate( + 'auto.components.settings.TerminalThemeSections.cbe56a0f79', + 'Controls the split divider line between panes in dark mode.' + ) return ( -
-
- - - - updateSettings({ terminalThemeDark: theme })} - importedHighlightSignal={importedHighlightSignal} - /> - - - - updateSettings({ terminalDividerColorDark: value })} - /> - -
- - + -
- ) -} - -export function LightTerminalThemeSection({ - settings, - themeSearchLight, - setThemeSearchLight, - updateSettings, - previewFontFamily -}: LightTerminalThemeSectionProps): React.JSX.Element { - const themeOptions = getAvailableTerminalThemeOptions(settings) - - return ( -
- -
-

- {translate( - 'auto.components.settings.TerminalThemeSections.d76f60c9cc', - 'Use Separate Theme In Light Mode' - )} -

-

- {translate( - 'auto.components.settings.TerminalThemeSections.b584287e84', - 'When disabled, light mode reuses the dark terminal theme.' - )} -

-
- -
+ /> - {settings.terminalUseSeparateLightTheme ? ( -
-
-
-
-

+ {showThemeImport ? ( +
+ + +
+ ) : null} + +
+
+
+ +
+

{translate( - 'auto.components.settings.TerminalThemeSections.8273bc75d7', - 'Light Theme' - )} -

-

- {translate( - 'auto.components.settings.TerminalThemeSections.74b15574c8', - 'Configure the optional light-mode terminal appearance.' + 'auto.components.settings.TerminalThemeSections.target_title', + 'Theme Mode' )}

+
+ + {isLightTarget ? ( - + // The legacy setting stores the inverse; the UI exposes the matching concept. + updateSettings({ + terminalUseSeparateLightTheme: !settings.terminalUseSeparateLightTheme + }) + } + /> + + ) : null} +
+ +
+
+ + updateSettings({ terminalThemeLight: theme })} + query={themeSearch} + onQueryChange={setThemeSearch} + onSelectTheme={(theme) => + updateSettings( + isLightTarget ? { terminalThemeLight: theme } : { terminalThemeDark: theme } + ) + } + importedHighlightSignal={importedHighlightSignal} /> updateSettings({ terminalDividerColorLight: value })} + label={dividerTitle} + description={dividerDescription} + value={ + isLightTarget + ? settings.terminalDividerColorLight + : settings.terminalDividerColorDark + } + fallback={isLightTarget ? '#d4d4d8' : '#3f3f46'} + onChange={(value) => + updateSettings( + isLightTarget + ? { terminalDividerColorLight: value } + : { terminalDividerColorDark: value } + ) + } />
- -
- ) : null} + + +
) } diff --git a/src/renderer/src/components/settings/WarpThemeImportButton.tsx b/src/renderer/src/components/settings/WarpThemeImportButton.tsx index 5af59c9f1de..92d7653a2e2 100644 --- a/src/renderer/src/components/settings/WarpThemeImportButton.tsx +++ b/src/renderer/src/components/settings/WarpThemeImportButton.tsx @@ -18,7 +18,7 @@ export function WarpThemeImportButton({ onClick={() => void warpThemes.handleClick()} > - {translate('auto.components.settings.WarpThemeImportModal.title', 'Import themes from Warp')} + {translate('auto.components.settings.WarpThemeImportModal.title', 'Import from Warp')} ) } diff --git a/src/renderer/src/components/settings/WarpThemeImportModal.tsx b/src/renderer/src/components/settings/WarpThemeImportModal.tsx index 9290d1cec84..8026e70f13a 100644 --- a/src/renderer/src/components/settings/WarpThemeImportModal.tsx +++ b/src/renderer/src/components/settings/WarpThemeImportModal.tsx @@ -86,7 +86,7 @@ export function WarpThemeImportModal({ ) : translate( 'auto.components.settings.WarpThemeImportModal.title', - 'Import themes from Warp' + 'Import from Warp' )} diff --git a/src/renderer/src/components/settings/terminal-search.test.ts b/src/renderer/src/components/settings/terminal-search.test.ts index 56ca5e3c283..88041fdf80b 100644 --- a/src/renderer/src/components/settings/terminal-search.test.ts +++ b/src/renderer/src/components/settings/terminal-search.test.ts @@ -122,12 +122,32 @@ describe('getTerminalPaneSearchEntries', () => { ) }) + it.each([ + 'dark', + 'light', + 'divider', + 'preview', + 'theme', + 'dark terminal theme', + 'target', + 'editing', + 'Match dark mode', + 'Customize Light Mode', + 'Match dark mode terminal theme', + 'Use Separate Theme In Light Mode', + 'import', + 'Warp', + 'YAML' + ])('matches terminal appearance search for %s', (query) => { + expect(matchesSettingsSearch(query, getAppearancePaneSearchEntries())).toBe(true) + }) + it('omits the Warp import appearance entry when desktop-only controls are hidden', () => { const desktopEntries = getAppearancePaneSearchEntries({ showWarpImport: true }) const webEntries = getAppearancePaneSearchEntries({ showWarpImport: false }) - expect(desktopEntries.some((entry) => entry.title === 'Import themes from Warp')).toBe(true) - expect(webEntries.some((entry) => entry.title === 'Import themes from Warp')).toBe(false) + expect(desktopEntries.some((entry) => entry.title === 'Import from Warp')).toBe(true) + expect(webEntries.some((entry) => entry.title === 'Import from Warp')).toBe(false) expect(webEntries.some((entry) => entry.title === 'Import from Ghostty')).toBe(true) }) diff --git a/src/renderer/src/components/settings/terminal-search.ts b/src/renderer/src/components/settings/terminal-search.ts index c12ef4113a4..3b06d4a3b1c 100644 --- a/src/renderer/src/components/settings/terminal-search.ts +++ b/src/renderer/src/components/settings/terminal-search.ts @@ -12,6 +12,7 @@ import { import { getTerminalDarkThemeSearchEntries, getTerminalLightThemeSearchEntries, + getTerminalThemeTargetSearchEntries, getTerminalWarpImportSearchEntries, getTerminalYamlImportSearchEntries } from './terminal-theme-search' @@ -44,6 +45,7 @@ export { export { getTerminalDarkThemeSearchEntries, getTerminalLightThemeSearchEntries, + getTerminalThemeTargetSearchEntries, getTerminalWarpImportSearchEntries, getTerminalYamlImportSearchEntries } from './terminal-theme-search' @@ -68,6 +70,7 @@ const getTerminalAppearanceSearchEntriesWithoutWarp = createLocalizedCatalog( ...getTerminalTypographySearchEntries(), ...getTerminalCursorSearchEntries(), ...getTerminalPaneAppearanceSearchEntries(), + ...getTerminalThemeTargetSearchEntries(), ...getTerminalDarkThemeSearchEntries(), ...getTerminalLightThemeSearchEntries(), ...getTerminalWindowSearchEntries(), diff --git a/src/renderer/src/components/settings/terminal-theme-search.ts b/src/renderer/src/components/settings/terminal-theme-search.ts index 3433b9b0a16..16afab956c2 100644 --- a/src/renderer/src/components/settings/terminal-theme-search.ts +++ b/src/renderer/src/components/settings/terminal-theme-search.ts @@ -12,7 +12,9 @@ export const getTerminalDarkThemeSearchEntries = createLocalizedCatalog(() => [ keywords: [ ...translateSearchKeyword('auto.components.settings.terminal.search.f66a7cf715', 'terminal'), ...translateSearchKeyword('auto.components.settings.terminal.search.0ce176909a', 'theme'), - ...translateSearchKeyword('auto.components.settings.terminal.search.f785374072', 'dark'), + ...translateSearchKeyword('auto.components.settings.terminal.search.f785374072', 'dark', { + aliases: ['dark terminal theme'] + }), ...translateSearchKeyword('auto.components.settings.terminal.search.7718d70356', 'preview') ] }, @@ -31,15 +33,33 @@ export const getTerminalDarkThemeSearchEntries = createLocalizedCatalog(() => [ } ]) +export const getTerminalThemeTargetSearchEntries = createLocalizedCatalog(() => [ + { + title: translate('auto.components.settings.terminal.search.theme_target.title', 'Theme Mode'), + keywords: [ + ...translateSearchKeyword('auto.components.settings.terminal.search.f66a7cf715', 'terminal'), + ...translateSearchKeyword('auto.components.settings.terminal.search.0ce176909a', 'theme'), + ...translateSearchKeyword( + 'auto.components.settings.terminal.search.theme_target.keyword_target', + 'target' + ), + ...translateSearchKeyword( + 'auto.components.settings.terminal.search.theme_target.keyword_editing', + 'editing' + ) + ] + } +]) + export const getTerminalLightThemeSearchEntries = createLocalizedCatalog(() => [ { title: translate( - 'auto.components.settings.terminal.search.232e532169', - 'Use Separate Theme In Light Mode' + 'auto.components.settings.terminal.search.match_dark_mode_title', + 'Match dark mode' ), description: translate( 'auto.components.settings.terminal.search.f268092ee3', - 'When disabled, light mode reuses the dark terminal theme.' + 'Light mode can use its own terminal theme.' ), keywords: [ ...translateSearchKeyword('auto.components.settings.terminal.search.f66a7cf715', 'terminal'), @@ -47,6 +67,18 @@ export const getTerminalLightThemeSearchEntries = createLocalizedCatalog(() => [ 'auto.components.settings.terminal.search.da864e6cec', 'light mode' ), + ...translateSearchKeyword( + 'auto.components.settings.terminal.search.match_dark_mode_title', + 'Match dark mode', + { + aliases: [ + 'Customize Light Mode', + 'Use separate light theme', + 'Use Separate Theme In Light Mode', + 'Match dark mode terminal theme' + ] + } + ), ...translateSearchKeyword('auto.components.settings.terminal.search.0ce176909a', 'theme') ] }, @@ -58,7 +90,9 @@ export const getTerminalLightThemeSearchEntries = createLocalizedCatalog(() => [ ), keywords: [ ...translateSearchKeyword('auto.components.settings.terminal.search.f66a7cf715', 'terminal'), - ...translateSearchKeyword('auto.components.settings.terminal.search.0ce176909a', 'theme'), + ...translateSearchKeyword('auto.components.settings.terminal.search.0ce176909a', 'theme', { + aliases: ['light terminal theme'] + }), ...translateSearchKeyword('auto.components.settings.terminal.search.411229c636', 'light'), ...translateSearchKeyword('auto.components.settings.terminal.search.7718d70356', 'preview') ] @@ -103,7 +137,7 @@ export const getTerminalWarpImportSearchEntries = createLocalizedCatalog(() => [ { title: translate( 'auto.components.settings.terminal.search.warp_import.title', - 'Import themes from Warp' + 'Import from Warp' ), description: translate( 'auto.components.settings.terminal.search.warp_import.description', @@ -115,7 +149,11 @@ export const getTerminalWarpImportSearchEntries = createLocalizedCatalog(() => [ translate('auto.components.settings.terminal.search.f66a7cf715', 'terminal'), translate('auto.components.settings.terminal.search.0ce176909a', 'theme'), translate('auto.components.settings.terminal.search.warp_import.keyword_themes', 'themes'), - translate('auto.components.settings.terminal.search.warp_import.keyword_yaml', 'yaml') + translate('auto.components.settings.terminal.search.warp_import.keyword_yaml', 'yaml'), + translate( + 'auto.components.settings.terminal.search.warp_import.keyword_legacy_title', + 'Import themes from Warp' + ) ] } ]) diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index c303866f52e..34db3fe66a7 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -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) diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts index 8bf89d66a73..e389023e540 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts @@ -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', () => { diff --git a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts index 7ea67c0d99b..ebedf8bed89 100644 --- a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts +++ b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts @@ -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 } diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index da2705e5288..c0d45d84249 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -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 } diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 4133374fa2d..7277541f562 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -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 { diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts index 87db96c3a22..5feee00f707 100644 --- a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts @@ -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 -): 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) } } diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts index df9f4ba45fc..75245c1dddd 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts @@ -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', () => { diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index cfd5b6f398a..f26d9adc198 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -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() diff --git a/src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.test.ts index c30d96a5387..3cd22968f4b 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.test.ts @@ -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', () => { diff --git a/src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.ts b/src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.ts index 182ef2741e9..05afb66d5d4 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.ts @@ -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>(new Map()) - const scrollDisposablesRef = useRef>(new Map()) const suppressScrollTrackingRef = useRef(false) const pendingFollowOutputPaneIdsRef = useRef>(new Set()) const followOutputFrameIdsRef = useRef([]) @@ -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, diff --git a/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts index b6b7ec6643b..e4546a8ed47 100644 --- a/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts +++ b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts @@ -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, diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 8ad69b7da71..486cbd1e45c 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -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 diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 8118136b07c..892855ebda1 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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", @@ -5039,7 +5061,7 @@ "1f744a72f4": "Config" }, "WarpThemeImportModal": { - "title": "Import themes from Warp", + "title": "Import from Warp", "description": "Import Warp themes as Orca terminal themes.", "yaml_title": "Import theme YAML", "yaml_description": "Import theme YAML files (Warp format) as Orca terminal themes.", @@ -6321,22 +6343,28 @@ "d06664e889": "dark" }, "TerminalThemeSections": { - "import_themes_title": "Import Themes", - "import_themes_description": "Imported themes are available in both the dark and light theme pickers.", "db210115c5": "Light Mode Preview", "5e0c24b5c8": "Controls the split divider line between panes in light mode.", "ec2e33ad80": "Light Divider Color", "d56af60e6f": "Choose the theme used when Orca is in light mode.", "8273bc75d7": "Light Theme", - "74b15574c8": "Configure the optional light-mode terminal appearance.", - "b584287e84": "When disabled, light mode reuses the dark terminal theme.", - "d76f60c9cc": "Use Separate Theme In Light Mode", "bc8e8a251a": "Dark Mode Preview", "cbe56a0f79": "Controls the split divider line between panes in dark mode.", "b739d2abfe": "Dark Divider Color", "7add204bd5": "Choose the terminal theme used in dark mode.", "9499ad1dc4": "Dark Theme", - "f012172e21": "Choose the theme used for terminal panes in dark mode." + "f012172e21": "Choose the theme used for terminal panes in dark mode.", + "catalog_title": "Terminal Themes", + "catalog_description": "Choose terminal themes and divider colors for dark and light mode.", + "target_title": "Theme Mode", + "target_label": "Theme Mode", + "target_aria": "Terminal theme mode", + "target_dark": "Dark", + "target_light": "Light", + "match_dark_mode": "Match dark mode", + "match_dark_mode_description": "Share the dark terminal theme and divider color in light mode.", + "light_preview_description": "Shows the effective light terminal appearance.", + "dark_preview_description": "Shows the effective dark terminal appearance." }, "TerminalWindowSection": { "1705318506": "ANSI magenta color", @@ -7833,11 +7861,12 @@ "73e9422f19": "One-time import of supported Ghostty terminal settings.", "a979df0083": "Import from Ghostty", "warp_import": { - "title": "Import themes from Warp", + "title": "Import from Warp", "description": "Import Warp themes as Orca terminal themes.", "keyword_warp": "warp", "keyword_themes": "themes", - "keyword_yaml": "yaml" + "keyword_yaml": "yaml", + "keyword_legacy_title": "Import themes from Warp" }, "yaml_import": { "title": "Import from YAML", @@ -7884,8 +7913,8 @@ "1dee533bd9": "Choose the theme used when Orca is in light mode.", "1d89457764": "Light Theme", "da864e6cec": "light mode", - "f268092ee3": "When disabled, light mode reuses the dark terminal theme.", - "232e532169": "Use Separate Theme In Light Mode", + "f268092ee3": "Light mode can use its own terminal theme.", + "match_dark_mode_title": "Match dark mode", "f785374072": "dark", "9c32726f47": "Controls the split divider line between panes in dark mode.", "8987db7ff2": "Dark Divider Color", @@ -7951,6 +7980,11 @@ "scrollSpeed": { "title": "Scroll Speed", "description": "Tune normal terminal scrollback, fast modifier scrolling, and full-screen TUI wheel speed." + }, + "theme_target": { + "title": "Theme Mode", + "keyword_target": "target", + "keyword_editing": "editing" } }, "windows": { @@ -9150,7 +9184,9 @@ "noAgentSessionsFound": "No agent sessions found", "noSessionsMatchFilters": "No sessions match the current filters", "sessionId": "Session ID", - "logPath": "Log path" + "logPath": "Log path", + "originalPaneUnavailable": "Original pane is no longer available.", + "worktreeUnavailable": "Worktree is no longer available." }, "AiVaultPanelControls": { "scanningSessions": "Scanning sessions", @@ -9203,6 +9239,8 @@ "resumeCommand": "Resume command", "sessionActions": "{{value0}} session actions", "resumeInNewTab": "Resume in New Tab", + "resumeInWorktree": "Resume in Worktree", + "viewLog": "View Log", "copyResumeCommand": "Copy Resume Command", "openLog": "Open Log", "revealLog": "Reveal Log", @@ -9226,7 +9264,10 @@ "agentRole": "Agent", "toolRole": "Tool", "systemRole": "System", - "sessionRole": "Session" + "sessionRole": "Session", + "jumpToOriginalPane": "Jump to Original Pane", + "worktree": "Worktree", + "jumpToWorktree": "Jump to Worktree" }, "AiVaultSessionRow": { "noPreviewAvailable": "No conversation preview available", @@ -9250,7 +9291,9 @@ "agentRole": "Agent", "toolRole": "Tool", "systemRole": "System", - "sessionRole": "Session" + "sessionRole": "Session", + "jumpToOriginalPane": "Jump to Original Pane", + "jumpToWorktree": "Jump to Worktree" }, "FileExplorerNameFilter": { "26fb73c6e3": "Find files", @@ -9294,6 +9337,17 @@ "copyCommand": "Copy command" } } + }, + "AiVaultSessionWorktree": { + "currentWorktree": "Current worktree", + "activeWorktree": "Active worktree", + "archivedWorktree": "Archived worktree", + "unavailableWorktree": "Unavailable worktree", + "jumpToWorktree": "Jump to Worktree", + "noRecordedWorktree": "No worktree was recorded for this session.", + "archivedJumpUnavailable": "This session is in an archived worktree.", + "noActiveWorktreeMatch": "No active worktree matches this session.", + "noActiveWorktreeTarget": "No active worktree is available." } } }, @@ -10467,7 +10521,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...", @@ -10555,7 +10611,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..." diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index f4ac115dc62..f753da0c6f7 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -25,7 +25,7 @@ "geminiToggleDescription": "Muestra el token de Gemini y el uso de costos para el espacio de trabajo activo.", "opencodeGoToggleDescription": "Muestra el token de OpenCode Go y el uso de costos para el espacio de trabajo activo.", "kimiToggleDescription": "Muestra el uso de la suscripción de Kimi para el espacio de trabajo activo.", - "sshToggleDescription": "Show configured SSH and remote Orca hosts when any are available.", + "sshToggleDescription": "Muestra hosts SSH configurados y hosts remotos de Orca cuando haya alguno disponible.", "resourceUsageToggleDescription": "Muestra el Administrador de recursos. Haga clic en él para ver la CPU, la memoria, las sesiones, los controles del demonio y los análisis del disco del espacio de trabajo.", "portsToggleDescription": "Mostrar puertos del espacio de trabajo en vivo. Haga clic en él para puertos con ámbito de espacio de trabajo y oyentes externos." } @@ -172,8 +172,8 @@ "dcb521ed29": "Este archivo se encuentra en estado de conflicto, pero no hay ningún archivo de árbol de trabajo disponible para editar.", "51f15c37d3": "No se puede abrir el directorio: {{value0}}", "f2e00db373": "Archivo no encontrado: {{value0}}", - "checkRunDetailsUnavailable": "No details are available for this check.", - "checkRunDetailsLoadFailed": "Failed to load check details." + "checkRunDetailsUnavailable": "No hay detalles disponibles para esta comprobación.", + "checkRunDetailsLoadFailed": "No se pudieron cargar los detalles de la comprobación." }, "github": { "f129c42773": "GitHub no devolvió el nuevo comentario.", @@ -247,10 +247,10 @@ "53883b7bc3": "No se pudo enviar a {{value0}}" }, "jira": { - "856083302c": "Jira connection was superseded by a newer request." + "856083302c": "La conexión de Jira fue reemplazada por una solicitud más reciente." }, "linear": { - "37d36984d0": "Linear connection was superseded by a newer request." + "37d36984d0": "La conexión de Linear fue reemplazada por una solicitud más reciente." } } }, @@ -510,10 +510,10 @@ } }, "projectSkillRuntime": { - "wslUnavailable": "Project runtime needs WSL before this skill can be installed.", - "distroRequired": "Select a WSL distro for this project before installing this skill.", - "distroMissing": "The selected WSL distro is unavailable. Choose an available distro or switch this project to Windows.", - "wslDefault": "WSL default" + "wslUnavailable": "El runtime del proyecto necesita WSL antes de instalar esta habilidad.", + "distroRequired": "Selecciona una distribución WSL para este proyecto antes de instalar esta habilidad.", + "distroMissing": "La distribución WSL seleccionada no está disponible. Elige una disponible o cambia este proyecto a Windows.", + "wslDefault": "Predeterminado de WSL" } }, "hooks": { @@ -521,7 +521,7 @@ "59718b120b": "El espacio de trabajo de destino ya no está disponible.", "16a21d6413": "La reconexión SSH requiere credenciales interactivas.", "386db94f3e": "El proyecto de destino ya no está disponible.", - "3ad7d77f57": "The target workspace is on a different host than this automation run target." + "3ad7d77f57": "El espacio de trabajo de destino está en un host distinto al destino de ejecución de esta automatización." }, "useComposerState": { "7eb3f44ff7": "El agente seleccionado está deshabilitado. Elija un agente habilitado antes de crear.", @@ -537,10 +537,10 @@ "38c9f034ff": "No se pudieron cargar los archivos eliminados.", "d720e2f855": "Algunos archivos caídos no se pudieron cargar.", "245faa95b9": "No hay ninguna ruta de espacio de trabajo remoto disponible para los archivos descartados.", - "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": "Suelta {{value0}} archivos o menos a la vez.", + "nativeDropTooManyPaths": "La suelta contiene demasiados archivos.", + "nativeDropPathsTooLargeDescription": "Suelta menos archivos o usa una lista de rutas más corta.", + "nativeDropPathsTooLarge": "La lista de rutas soltadas es demasiado grande." }, "useIpcEvents": { "0e3cf53060": "Pestaña del navegador {{value0}} no encontrada", @@ -570,7 +570,7 @@ "d91ae31fbd": "Permisos de macOS", "95a1886d94": "Controla terminales y agents desde tu teléfono.", "1cd25673df": "Móvil", - "31e57d1c70": "Use existing machines over SSH for files, terminals, Git, and workspaces.", + "31e57d1c70": "Usa máquinas existentes por SSH para archivos, terminales, Git y espacios de trabajo.", "94a5afe910": "Anfitriones SSH", "40d80bad8a": "Beta", "de0c2907a1": "Servidores remotos de Orca", @@ -618,13 +618,13 @@ "b1c2f8b0ac": "Cambio de cuenta opcional para Claude, Codex, Gemini y OpenCode Go.", "f70ac54d38": "Cuentas de proveedores de IA", "4121f7a0a2": "Administre agents de IA, establezca un valor predeterminado y personalice comandos.", - "b49abbd2f7": "Agents" + "b49abbd2f7": "Agentes" }, "useAppMenuPaste": { - "pasteTooLarge": "Paste is too large." + "pasteTooLarge": "El contenido pegado es demasiado grande." }, "useLargeTextControlPaste": { - "pasteTooLarge": "Paste is too large." + "pasteTooLarge": "El contenido pegado es demasiado grande." } }, "components": { @@ -632,10 +632,10 @@ "9132779820": "Despedir", "c72a5fb234": "Reanudar", "9263e75f49": "Codex está usando la cuenta anterior.", - "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 aún inició sesión como {{value0}}", + "d3e8a1f4b2": "Cuenta cambiada", + "e9b2c7d1a5": "Reinicia esta sesión para usar {{value0}}. Contrae para seguir trabajando con la cuenta actual.", + "f1a6c8e3b4": "Contraer" }, "FirstLaunchBanner": { "b9e1b966c7": "Descartar aviso", @@ -822,7 +822,23 @@ "ba8e329d92": "Desmarcar visto", "3f79ffc8b7": "Abra los detalles de relaciones públicas para ver los revisores actuales.", "5c1c973855": "Eliminar revisor", - "commentTooLarge": "Comment is too large to submit safely." + "commentTooLarge": "El comentario es demasiado grande para enviarlo de forma segura.", + "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": "Reabrir", @@ -887,7 +903,7 @@ "3a051b8ade": "Elemento de trabajo", "32f8bef818": "Sin salida de registro.", "4168eb2c51": "Resolver", - "commentTooLarge": "Comment is too large to submit safely." + "commentTooLarge": "El comentario es demasiado grande para enviarlo de forma segura." }, "JiraIssueWorkspace": { "b0b92666c9": "Comentario", @@ -922,7 +938,7 @@ "2a829a2f00": "prioridad", "693be070d0": "transición", "ef21405c6d": "Problema con Jira", - "commentTooLarge": "Comment is too large to submit safely." + "commentTooLarge": "El comentario es demasiado grande para enviarlo de forma segura." }, "Landing": { "76a95f7f47": "Crear", @@ -944,7 +960,7 @@ "0d0ace8861": "Dar estrella en GitHub", "ec43b38ba7": "Con estrella en GitHub", "157bb5ecbb": "Abrir GitHub", - "preflightDismiss": "Dismiss" + "preflightDismiss": "Descartar" }, "LinearIssueMarkdownDescriptionEditor": { "d9c47069ef": "Markdown", @@ -1045,7 +1061,7 @@ "39883467f4": "Cuestión Linear", "fda549766e": "{{value0}} para comentar", "d71cd3003e": "+ Cesionario", - "commentTooLarge": "Comment is too large to submit safely." + "commentTooLarge": "El comentario es demasiado grande para enviarlo de forma segura." }, "NewWorkspaceComposerCard": { "cbb47ee0dc": "Solo disponible para proyectos Git locales.", @@ -1057,7 +1073,7 @@ "f0470c7383": "Avanzado", "ba64270bdb": "Configurar agents", "ab63f25397": "Abrir configuración del agente", - "01d1e8f601": "Agent", + "01d1e8f601": "Agente", "0c5d6a479c": "[Opcional]", "b5a0796911": "Conectar", "dccd26d4e4": "Elige proyecto", @@ -1079,34 +1095,36 @@ "connectProjectFirst": "Conecta este proyecto primero", "connectSourceLookup": "Conectar", "reconnectSourceLookup": "Volver a conectar", - "sshNotConnected": "SSH not connected", - "connectingSsh": "Connecting SSH...", - "sshAuthenticationFailed": "SSH authentication failed", - "preparingSshConnection": "Preparing SSH connection...", - "connected": "Connected", - "reconnectingSsh": "Reconnecting SSH...", - "sshReconnectionFailed": "SSH reconnection failed", - "notConnected": "Not connected", - "runOn": "Run on", - "setupHostExistingFolderTitle": "Set up {{value0}}", - "cloneProjectOnHost": "Clone project", + "sshNotConnected": "SSH no conectado", + "connectingSsh": "Conectando SSH...", + "sshAuthenticationFailed": "Falló la autenticación SSH", + "preparingSshConnection": "Preparando conexión SSH...", + "connected": "Conectado", + "reconnectingSsh": "Reconectando SSH...", + "sshReconnectionFailed": "Falló la reconexión SSH", + "notConnected": "No conectado", + "runOn": "Ejecutar en", + "setupHostExistingFolderTitle": "Configurar {{value0}}", + "cloneProjectOnHost": "Clonar proyecto", "cloneUrlPlaceholder": "https://github.com/owner/repo.git", "cloneDestinationPlaceholder": "/parent/directory/on/host", - "cloningHostSetup": "Cloning...", - "cloneHostSetup": "Clone", - "importExistingFolderOnHost": "Import existing folder", + "cloningHostSetup": "Clonando...", + "cloneHostSetup": "Clonar", + "importExistingFolderOnHost": "Importar carpeta existente", "setupHostExistingFolderPlaceholder": "/path/to/project/on/host", - "setupKindGit": "Git repo", - "setupKindFolder": "Folder", - "setupHostExistingFolderHelp": "Link a checkout that already exists there, then create this workspace on that host.", - "importingHostSetup": "Importing...", - "importHostSetup": "Import", - "notePasteTooLarge": "Paste is too large for the note field.", - "reuseExistingBranch": "Reuse branch", - "reuseExistingBranchHint": "Check out the existing branch instead of creating a new one from it.", - "createMultiple": "Create more" + "setupKindGit": "Repositorio Git", + "setupKindFolder": "Carpeta", + "setupHostExistingFolderHelp": "Vincula un checkout que ya exista allí y luego crea este espacio de trabajo en ese host.", + "importingHostSetup": "Importando...", + "importHostSetup": "Importar", + "notePasteTooLarge": "El contenido pegado es demasiado grande para el campo de nota.", + "reuseExistingBranch": "Reutilizar rama", + "reuseExistingBranchHint": "Hacer checkout de la rama existente en lugar de crear una nueva a partir de ella.", + "createMultiple": "Crear más" }, "NewWorkspaceComposerModal": { + "createWorktree": "Crear árbol de trabajo", + "createWorkspace": "Crear espacio de trabajo", "fa90f739a5": "Elija el proyecto, el nombre del espacio de trabajo y el agente antes de crear el espacio de trabajo." }, "PullRequestPage": { @@ -1274,10 +1292,10 @@ "2b4fdb880c": "Desmarcar visto", "56ec6eafb7": "Abra los detalles de relaciones públicas para ver los revisores actuales.", "7f964a365a": "Eliminar revisor", - "commentTooLarge": "Comment is too large to submit safely.", - "8ff5ae8866": "Assignees", - "82c87eceb9": "Edit assignees", - "1ff5d979df": "No one assigned" + "commentTooLarge": "El comentario es demasiado grande para enviarlo de forma segura.", + "8ff5ae8866": "Responsables", + "82c87eceb9": "Editar responsables", + "1ff5d979df": "Nadie asignado" }, "QuickOpen": { "1dbd3f59ff": "Mover", @@ -1579,7 +1597,7 @@ "456b8512da": "Solicitud de fusión", "03da966159": "Seleccione un proyecto para que podamos autenticarnos en GitLab.", "d591aac6ae": "No hay todos pendientes. ¡Estáis todos atrapados!", - "33a4bd7f5c": "todos", + "33a4bd7f5c": "pendientes", "66ae7330f6": "Más acciones", "93d5f21fc1": "pr", "e104fa3d3d": "Iniciar espacio de trabajo desde el problema", @@ -1593,7 +1611,7 @@ "0bfbf62f75": "Rever", "38139edb52": "github", "31f81cc334": "Actualizando el trabajo de GitHub...", - "3d93316bb0": "prs", + "3d93316bb0": "PRs", "937b29fa35": "elementos", "bc46d8204e": "Seleccione un proyecto para abrir en GitHub", "d1132848f8": "Seleccione un proyecto de GitHub para abrir en GitHub", @@ -1607,25 +1625,25 @@ "fe28c9821f": "vista", "8d1e17a3ef": "Abra {{value0}} en GitHub", "4ac8ff2275": "Abrir {{value0}} en Jira", - "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": "Detalles", + "closeAsCompleted": "Cerrar como completado", + "closeAsCompletedDescription": "Hecho, cerrado, corregido, resuelto", + "closeAsNotPlanned": "Cerrar como no planificado", + "closeAsNotPlannedDescription": "No se corregirá, no se puede reproducir, obsoleto", + "closeAsDuplicate": "Cerrar como duplicado", + "closeAsDuplicateDescription": "Duplicado de otro issue en este repositorio", + "duplicateIssueNumberPlaceholder": "Número de issue", + "closeAsDuplicateSubmit": "Cerrar duplicado", + "duplicateIssueMissing": "Introduce un número de issue de este repositorio.", + "duplicateIssueNotInteger": "Usa un número de issue entero.", + "duplicateIssueNotPositive": "Usa un número de issue positivo.", + "duplicateIssueSameIssue": "Elige otro issue.", + "repository": "Repositorio", + "backToCloseReasons": "Volver", + "searchIssues": "Buscar issues", + "useIssueNumber": "Usar issue #{{value0}}", + "noMatchingIssuesLoaded": "No se cargaron issues coincidentes.", + "editReviewersWithCurrent": "Editar revisores: {{value0}}" }, "Terminal": { "73768427cf": "Cerca", @@ -1642,8 +1660,8 @@ "46e08bc5c8": "Este archivo tiene cambios no guardados.", "61ed600d29": "\"{{value0}}\" tiene cambios no guardados. ¿Quieres guardar antes de cerrar?", "cdc9ac4b2d": "editor", - "e57db40c11": "Could not build launch command for {{value0}}.", - "5b2c1a9e44": "No agent CLI detected — install one or pick a default agent in Settings." + "e57db40c11": "No se pudo construir el comando de inicio para {{value0}}.", + "5b2c1a9e44": "No se detectó ninguna CLI de agente; instala una o elige un agente predeterminado en Configuración." }, "TerminalSearch": { "db234b7519": "Cerca", @@ -1727,9 +1745,9 @@ "actionBadge": "Acción", "paletteHostBadge": "Host: {{value0}}", "workspaceTabMissing": "La pestaña ya no existe", - "projectsGroupsHeader": "Projects & Groups", - "projectBadge": "Project", - "repoGroupBadge": "Repo group" + "projectsGroupsHeader": "Proyectos y grupos", + "projectBadge": "Proyecto", + "repoGroupBadge": "Grupo de repositorios" }, "github": { "pr": { @@ -1872,10 +1890,10 @@ "22df63c393": "Los datos de la subemisión no están disponibles para su token.", "067119985c": "Búsqueda de GitHub, p. cesionario:@yo es:abierto", "1850fceac8": "{{value0}}/{{value1}} no se agrega a Orca. Agréguelo para comenzar a trabajar o ábralo en GitHub.", - "1aa7c952b9": "Project view", - "f352abf7c3": "Repository list is updating.", - "1ce21b8cff": "This item is outside the selected repositories.", - "030de75bc5": "This item matches multiple selected repositories." + "1aa7c952b9": "Vista del proyecto", + "f352abf7c3": "La lista de repositorios se está actualizando.", + "1ce21b8cff": "Este elemento está fuera de los repositorios seleccionados.", + "030de75bc5": "Este elemento coincide con varios repositorios seleccionados." }, "slug": { "dialog": { @@ -1892,7 +1910,7 @@ "463d030ae4": "Borrar", "8564f58542": "Editar", "5f104bf855": "Aún no hay comentarios.", - "commentTooLarge": "Comment is too large to submit safely." + "commentTooLarge": "El comentario es demasiado grande para enviarlo de forma segura." }, "LabelsEditor": { "34dd57d6c8": "Cargando…", @@ -1917,11 +1935,11 @@ "e3bd59143c": "Insertar", "f24783f470": "https://...", "ec6310b731": "Utilice una URL de imagen http:// o https://.", - "b7e4a1c902": "Paste, drop, or click to add files", - "8f1c2d4e6a": "Nothing to preview", - "c91f0a2b14": "Write", - "d82b1e3f05": "Preview", - "imageUrlTooLarge": "Image URL is too large." + "b7e4a1c902": "Pega, suelta o haz clic para agregar archivos", + "8f1c2d4e6a": "No hay nada que previsualizar", + "c91f0a2b14": "Escribir", + "d82b1e3f05": "Previsualizar", + "imageUrlTooLarge": "La URL de la imagen es demasiado grande." }, "IssueSourceSelector": { "d6aeb2012b": "Mostrando problemas de", @@ -1973,7 +1991,7 @@ "4e50c7bc03": "cesionario", "7bf3a6e5ac": "autor", "66256a73b3": "estado", - "3ce4d5e96e": "prs" + "3ce4d5e96e": "PRs" }, "github": { "rate": { @@ -1992,51 +2010,51 @@ "c377a4f06a": "Buscar", "c392c749a6": "API DESCANSO", "bb227706a6": "DESCANSAR", - "budget_scope_prefix": "Budget scope" + "budget_scope_prefix": "Ámbito del presupuesto" } } } }, "CloseReasonDropdown": { - "e1f2a3b4c5": "Choose close reason" + "e1f2a3b4c5": "Elige el motivo de cierre" }, "GitHubIssueCommentComposer": { - "082515176a": "Failed to add comment", - "9f88657c4e": "Issue closed", - "e9b7cb7d17": "Failed to close issue", - "bd3b4492a0": "Issue reopened", - "f2a8c1d903": "Failed to reopen issue", - "a1b2c3d4e5": "Add a comment", - "c5c117270e": "Add your comment here, be kind", - "f6a7b8c9d0": "Close issue", - "b1c2d3e4f5": "Reopen issue", - "0a73f59e85": "Send comment", - "bf43425540": "Comment", - "commentTooLarge": "Comment is too large to submit safely." + "082515176a": "No se pudo agregar el comentario", + "9f88657c4e": "Issue cerrado", + "e9b7cb7d17": "No se pudo cerrar el issue", + "bd3b4492a0": "Issue reabierto", + "f2a8c1d903": "No se pudo reabrir el issue", + "a1b2c3d4e5": "Agregar un comentario", + "c5c117270e": "Agrega tu comentario aquí, con amabilidad", + "f6a7b8c9d0": "Cerrar issue", + "b1c2d3e4f5": "Reabrir issue", + "0a73f59e85": "Enviar comentario", + "bf43425540": "Comentario", + "commentTooLarge": "El comentario es demasiado grande para enviarlo de forma segura." }, "GitHubWorkItemAssigneePopoverContent": { - "cddd9b04a7": "Loading assignees", - "a00830d3f7": "No users", - "4f8b6f2c1d": "Filter assignees..." + "cddd9b04a7": "Cargando responsables", + "a00830d3f7": "No hay usuarios", + "4f8b6f2c1d": "Filtrar responsables..." }, "GitHubWorkItemLabelPopoverContent": { - "2aa9acdf34": "Edit labels on GitHub", - "cddd9b04a7": "Loading labels", - "de26e2eb06": "No labels", - "8b0d52ee3a": "Filter labels..." + "2aa9acdf34": "Editar etiquetas en GitHub", + "cddd9b04a7": "Cargando etiquetas", + "de26e2eb06": "No hay etiquetas", + "8b0d52ee3a": "Filtrar etiquetas..." }, "githubIssueCloseReasons": { "completed": { - "label": "Close as completed", - "description": "Done, closed, fixed, resolved" + "label": "Cerrar como completado", + "description": "Hecho, cerrado, corregido, resuelto" }, "notPlanned": { - "label": "Close as not planned", - "description": "Won't fix, can't repro, stale" + "label": "Cerrar como no planificado", + "description": "No se corregirá, no se puede reproducir, obsoleto" }, "duplicate": { - "label": "Close as duplicate", - "description": "Duplicate of another issue" + "label": "Cerrar como duplicado", + "description": "Duplicado de otro issue" } } }, @@ -2272,7 +2290,7 @@ "577a342c7d": "Pídale al agente que investigue este espacio de trabajo.", "026cfb232a": "No admite comandos rápidos", "346d409ab2": "Elige agente", - "0adba8fa0c": "Agent", + "0adba8fa0c": "Agente", "ec8f081919": "Acción", "ed04233b3e": "Guarde los comandos del terminal o las indicaciones del agente para un acceso rápido.", "ca414324ee": "Texto de comando", @@ -2418,11 +2436,11 @@ "29c031b49a": "Subiendo el archivo {{value0}}{{value1}} al tiempo de ejecución...", "0c77693641": "Worktree no está listo; inténtalo de nuevo en un momento.", "ce8248b835": "Ruta del árbol de trabajo no disponible.", - "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": "La operación contiene demasiadas rutas para pegarlas de forma segura en la terminal.", + "internalPathsTooLarge": "La lista de rutas es demasiado grande para pegarla de forma segura en la terminal.", + "b4cf68e889": "Se omitió {{value0}} {{value1}}.", + "writeTimeout": "Se canceló la suelta de archivos: la terminal no aceptó la ruta antes del tiempo de seguridad.", + "writeRejected": "Se canceló la suelta de archivos: la terminal no pudo aceptar la ruta." } } }, @@ -2471,11 +2489,11 @@ "closePaneColumn": "Cerrar panel dividido" }, "AiVaultSessionDropLayer": { - "dropOntoTerminalPane": "Drop onto a terminal pane to resume this session.", - "couldNotReadPayload": "Could not read the session drag payload.", - "localWorkspacesOnly": "Resume from history is only available in local workspaces.", - "openLocalWorkspace": "Open a local workspace before resuming a session.", - "sessionQueued": "Session queued" + "dropOntoTerminalPane": "Suelta sobre un panel de terminal para reanudar esta sesión.", + "couldNotReadPayload": "No se pudo leer la carga arrastrada de la sesión.", + "localWorkspacesOnly": "Reanudar desde el historial solo está disponible en espacios de trabajo locales.", + "openLocalWorkspace": "Abre un espacio de trabajo local antes de reanudar una sesión.", + "sessionQueued": "Sesión en cola" }, "TabGroupDropOverlay": { "paneColumnLabel": "Nueva división" @@ -2596,8 +2614,8 @@ "7b1c9d6ae1": "Correr", "c781f992e4": "destructivo", "be8f0ff166": "Borrar", - "f3a8c2d1e7": "Search quick commands...", - "b4e7f9a2c1": "No commands match" + "f3a8c2d1e7": "Buscar comandos rápidos...", + "b4e7f9a2c1": "No hay comandos coincidentes" }, "shell": { "icons": { @@ -2614,7 +2632,7 @@ "5a9c83c04b": "Abra cualquier archivo, URL, agente, ...", "90eb94dc48": "Introduzca una URL http:// o https://.", "5553b283ce": "Ingrese una URL o ruta de archivo.", - "queryTooLarge": "Search text is too large." + "queryTooLarge": "El texto de búsqueda es demasiado grande." } }, "menu": { @@ -2748,10 +2766,10 @@ "e9a5d3c2b1f0": "¿Matar a {{value0}}?" }, "SshStatusSegment": { - "3ad70e0365": "Manage Remote Hosts…", - "6e8a9a4242": "Remote Hosts", - "d09ec41831": "Remote Hosts", - "fdc57e9970": "Remote host connection status", + "3ad70e0365": "Administrar hosts remotos…", + "6e8a9a4242": "Hosts remotos", + "d09ec41831": "Hosts remotos", + "fdc57e9970": "Estado de conexión del host remoto", "59b553e2aa": "Desconectar", "63f36455cc": "Conectar", "bf07aee59e": "Falló la desconexión", @@ -2762,20 +2780,20 @@ "fbb3f9f05e": "conflicto", "95e4ff5b4b": "emprendedor", "63a2b965f6": "tracción", - "remote_server": "Remote Server", - "runtime_checking": "Checking", - "runtime_online": "Connected", - "runtime_unavailable": "Disconnected", - "runtime_connect_unavailable": "Remote host is not reachable", - "runtime_disconnect_failed": "Disconnect failed", - "runtime_reconnecting": "Reconnecting", - "runtime_last_close_reason": "Closed: {{value0}}", - "runtime_reconnect_attempt": "Attempt {{value0}}" + "remote_server": "Servidor remoto", + "runtime_checking": "Comprobando", + "runtime_online": "Conectado", + "runtime_unavailable": "Desconectado", + "runtime_connect_unavailable": "No se puede acceder al host remoto", + "runtime_disconnect_failed": "No se pudo desconectar", + "runtime_reconnecting": "Reconectando", + "runtime_last_close_reason": "Cerrado: {{value0}}", + "runtime_reconnect_attempt": "Intento {{value0}}" }, "StatusBar": { "9659e38343": "Puertos", "d1e1a7a6bf": "Administrador de recursos", - "24ac89df1a": "Remote Hosts", + "24ac89df1a": "Hosts remotos", "5e59007df4": "Uso de Kimi", "8c86cd77b0": "Uso de OpenCode Go", "c1df0d67ec": "Uso de Gemini", @@ -2814,14 +2832,14 @@ "6cd6650b4c": "Reiniciar sesión", "1446d0d8a0": "{{value0}} Las sesiones del Codex todavía están en la cuenta anterior.", "605901a495": "1 sesión del Codex todavía está en la cuenta anterior", - "5e5f9f5160": "1 rate-limit reset available", - "5ecae9197c": "{{value0}} rate-limit resets available", - "25d8bbde69": "Using reset…", - "e159fc1fd7": "Reset now", - "972a1ff497": "Reset Codex limits?", - "6d1042aa6f": "This uses one Codex rate-limit reset credit for the active account and resets any eligible usage windows immediately.", - "f077f586db": "Don't ask again", - "c0e972d726": "Cancel" + "5e5f9f5160": "1 restablecimiento de límite disponible", + "5ecae9197c": "{{value0}} restablecimientos de límite disponibles", + "25d8bbde69": "Usando restablecimiento…", + "e159fc1fd7": "Restablecer ahora", + "972a1ff497": "¿Restablecer límites de Codex?", + "6d1042aa6f": "Esto usa un crédito de restablecimiento de límite de Codex para la cuenta activa y restablece de inmediato las ventanas de uso aptas.", + "f077f586db": "No volver a preguntar", + "c0e972d726": "Cancelar" }, "StatusBarUsageEmptyCta": { "828c764a79": "Conectar una cuenta", @@ -2898,8 +2916,8 @@ "b9b4a3a25d": "Rama", "c432278ec7": "Búfers del editor", "0bc756efaf": "cambios de git", - "e9528a89b3": "Terminals", - "a8d9e0de79": "Agents", + "e9528a89b3": "Terminales", + "a8d9e0de79": "Agentes", "d384a4ce9f": "Eliminar decisión", "7d7745bb8f": "puede eliminar", "720870a18e": "Mantener: vinculado", @@ -2964,25 +2982,25 @@ "7ad719c4bf": "Limitado", "e740f92596": "Error al actualizar", "8418ec448d": "No se pudo actualizar el uso de {{value0}}. Es posible que las sesiones de agente sigan iniciadas.", - "45198c7d95": "1 rate-limit reset available", - "bce421cba3": "{{value0}} rate-limit resets available", - "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." + "45198c7d95": "1 restablecimiento de límite disponible", + "bce421cba3": "{{value0}} restablecimientos de límite disponibles", + "7ec6e030a0": "El siguiente vence ahora", + "d1e442a9e5": "Vence ahora", + "6cf9eaed10": "El siguiente vence en {{value0}}", + "20ad66aed1": "Vence en {{value0}}", + "0d8d7cfe15": "Esperando sesión de Claude", + "1804cd8c3f": "Actualizando inicio de sesión", + "f8f0f9d8cc": "Problema de red", + "bf2e739f18": "Inicio de sesión no disponible", + "f8b8dbed85": "Uso no disponible", + "3d3c9c0c1f": "El uso de Claude se actualizará cuando la terminal Claude activa rote sus credenciales.", + "42fdd4da1d": "Se está actualizando el inicio de sesión de Claude. Las sesiones de agentes pueden seguir conectadas.", + "c06c1d215d": "No se pudo actualizar el uso de Claude porque falló la solicitud de red.", + "cabdc2a9e0": "No se pudieron leer las credenciales de inicio de sesión de Claude.", + "a7517cccb6": "El uso de Claude no está disponible ahora." }, "SshTargetStatusRow": { - "sshHost": "SSH Host" + "sshHost": "Host SSH" } } }, @@ -2997,7 +3015,7 @@ "c9f7cd30e9": "Uso diario" }, "ClaudeUsagePane": { - "21ea00bfa8": "Cache", + "21ea00bfa8": "Caché", "a8b7487ff7": "Producción", "faf3444859": "Aporte", "0f03975d59": "vueltas", @@ -3036,10 +3054,10 @@ "cfe2282ffa": "Desconocido", "7765a4c3e1": "n / A", "2d41fd45c6": "• Error del último análisis: {{value0}}", - "rangeLast7Days": "Last 7 days", - "rangeLast30Days": "Last 30 days", - "rangeLast90Days": "Last 90 days", - "rangeAllTime": "All time" + "rangeLast7Days": "Últimos 7 días", + "rangeLast30Days": "Últimos 30 días", + "rangeLast90Days": "Últimos 90 días", + "rangeAllTime": "Todo el tiempo" }, "CodexUsageDailyChart": { "1e6f62d7e3": "Razonamiento", @@ -3089,10 +3107,10 @@ "ae255c3dba": "n / A", "247c93ca92": "• precios inferidos", "8a6655f7a2": "• Error del último análisis: {{value0}}", - "rangeLast7Days": "Last 7 days", - "rangeLast30Days": "Last 30 days", - "rangeLast90Days": "Last 90 days", - "rangeAllTime": "All time" + "rangeLast7Days": "Últimos 7 días", + "rangeLast30Days": "Últimos 30 días", + "rangeLast90Days": "Últimos 90 días", + "rangeAllTime": "Todo el tiempo" }, "OpenCodeUsagePane": { "349f7c3f5c": "Total", @@ -3132,10 +3150,10 @@ "362231082f": "Desconocido", "8095a63426": "n / A", "6cc7782458": "• Error del último análisis: {{value0}}", - "rangeLast7Days": "Last 7 days", - "rangeLast30Days": "Last 30 days", - "rangeLast90Days": "Last 90 days", - "rangeAllTime": "All time" + "rangeLast7Days": "Últimos 7 días", + "rangeLast30Days": "Últimos 30 días", + "rangeLast90Days": "Últimos 90 días", + "rangeAllTime": "Todo el tiempo" }, "ShareUsageButton": { "7d6b25323d": "Compartir en X", @@ -3207,15 +3225,15 @@ }, "stats": { "search": { - "cb6a9f0334": "cache", + "cb6a9f0334": "caché", "eaf251e183": "fichas", "6953af58e6": "código abierto", "b77826fca3": "codex", "e9dc37d889": "claude", "8efeae0b22": "seguimiento", "5acbe1fdf2": "tiempo", - "ef8bbf7739": "prs", - "ce8533f02e": "agents", + "ef8bbf7739": "PRs", + "ce8533f02e": "agentes", "0bba8ca244": "estadística", "0e2a0b6431": "uso", "372debfac0": "estadísticas", @@ -3244,7 +3262,7 @@ "e65084cb4b": "razonamiento", "3bc4a01b24": "Tokens combinados de entrada, salida y caché entre proveedores habilitados.", "4ff104da47": "Mezcla de tokens", - "0015facc1f": "Cache", + "0015facc1f": "Caché", "7f270458af": "Producción", "9365b14a4e": "Nueva entrada", "3de9bf87fc": "Aún no hay modelo", @@ -3257,19 +3275,19 @@ }, "UsageBreakdownSection": { "7765a4c3e1": "n/a", - "247c93ca92": "• inferred pricing" + "247c93ca92": "• precio inferido" }, "UsageSessionsTable": { - "1afc25eb06": "Turns", - "0f03975d59": "Events", - "21ea00bfa8": "Cache", + "1afc25eb06": "Turnos", + "0f03975d59": "Eventos", + "21ea00bfa8": "Caché", "e0b988599d": "Total", - "01476891c7": "Last active", - "c17bed0416": "Project", - "f6a2c8d019": "Model", - "faf3444859": "Input", - "a8b7487ff7": "Output", - "cfe2282ffa": "Unknown" + "01476891c7": "Última actividad", + "c17bed0416": "Proyecto", + "f6a2c8d019": "Modelo", + "faf3444859": "Entrada", + "a8b7487ff7": "Salida", + "cfe2282ffa": "Desconocido" } }, "sparse": { @@ -3373,7 +3391,7 @@ "c234df77f7": "Elige o introduce una carpeta principal del servidor antes de crear.", "3a13f6e88b": "ubicación no seleccionada", "6ed14c0281": "carpeta del servidor no seleccionada", - "ssh_parent_manual": "Enter an SSH parent path." + "ssh_parent_manual": "Introduce una ruta padre SSH." }, "AddRepoNestedImportStep": { "496f68cf8c": "Escaneo de repositorios. Haga clic para detener.", @@ -3408,9 +3426,9 @@ "dd3ff65486": "Explorar el sistema de archivos remoto", "36d427bb66": "Agregar proyecto remoto", "35831a7312": "Añadiendo...", - "lockedDescription": "Enter the path to a Git repository on {{value0}}.", - "lockedDisconnected": "{{value0}} is disconnected.", - "93e0221434": "Connect" + "lockedDescription": "Introduce la ruta a un repositorio Git en {{value0}}.", + "lockedDisconnected": "{{value0}} está desconectado.", + "93e0221434": "Conectar" }, "AddRepoServerStartStep": { "ae990c86a0": "Volver para agregar opciones", @@ -3458,8 +3476,8 @@ "3e64e8a70d": "La conexión falló", "32a7256d85": "Clon", "69f5b5380d": "Clonación...", - "cloneOnHostDescription": "Enter the Git URL and choose where to clone it on {{value0}}.", - "cloneParentFolder": "Parent folder" + "cloneOnHostDescription": "Introduce la URL de Git y elige dónde clonarlo en {{value0}}.", + "cloneParentFolder": "Carpeta padre" }, "AutoRenameFailedDialog": { "aed1623b1e": "Cerca", @@ -3570,7 +3588,7 @@ "51001182e3": "directorio vacio", "971d85cc84": "Se abre como proyecto remoto · {{value0}}", "00c4235c10": "No hay coincidencias para '{{value0}}'", - "largeInputNoMatches": "No matches for this long input" + "largeInputNoMatches": "No hay coincidencias para esta entrada larga" }, "RemoveFolderDialog": { "4dc5b5065b": "Eliminar", @@ -3664,7 +3682,7 @@ "0c3395fd32": "Buscar árboles de trabajo y pestañas del navegador", "c86d83b5c3": "Nuevo", "1b5c41caee": "Orca Móvil", - "9c95e1ce91": "Agents", + "9c95e1ce91": "Agentes", "f323383e9a": "Automatizaciones", "e7ad3c540d": "Abrir tareas de Jira", "c39ab10000": "Abrir tareas Lineares", @@ -3753,21 +3771,21 @@ "680043342f": "compacto", "c7591b6014": "repo", "hosts": "Hosts", - "allHostsDetail": "Show every host", - "configuredSshHost": "Configured SSH", - "projectSshHost": "Project SSH", - "activeRuntimeHost": "Active server", - "projectRuntimeHost": "Project server", - "631b97eea9": "Host scope", + "allHostsDetail": "Mostrar todos los hosts", + "configuredSshHost": "SSH configurado", + "projectSshHost": "SSH del proyecto", + "activeRuntimeHost": "Servidor activo", + "projectRuntimeHost": "Servidor del proyecto", + "631b97eea9": "Ámbito del host", "2d4f0eb933": "Predeterminado", "1a0eec0d35": "Estado", "b5536d5a88": "Tareas", "8d62c68b35": "Notas", - "automation": "Automation", + "automation": "Automatización", "2d74665a56": "Puertos", "65a9820bd1": "Estados del agente", "219ebf1961": "Nombre de rama", - "folderPathIdentity": "Branch / folder path" + "folderPathIdentity": "Rama / ruta de carpeta" }, "SshDisconnectedDialog": { "ca4a7892af": "Conectando...", @@ -3778,9 +3796,9 @@ "11552bf786": "SSH desconectado", "cb5938ae79": "Reconectando...", "disconnected": "This remote repository is not connected.", - "reconnecting": "Reconnecting to the remote host...", - "reconnectionFailed": "Reconnection to the remote host failed.", - "authFailed": "Authentication to the remote host failed." + "reconnecting": "Reconectando con el host remoto...", + "reconnectionFailed": "Falló la reconexión con el host remoto.", + "authFailed": "Falló la autenticación con el host remoto." }, "SshTargetRow": { "4677394048": "Conectando…", @@ -3841,18 +3859,18 @@ "0d224eff10": "árbol de trabajo primario", "ca74db7550": "Proyecto remoto vía SSH", "021538e1d1": "SSH desconectado", - "runtimeHostDisconnected": "Server disconnected", - "runtimeHostProject": "Project on Orca server", - "automationCreated": "Created by automation", - "branchIdentity": "Branch", - "branchFolderPathIdentity": "Branch or folder path" + "runtimeHostDisconnected": "Servidor desconectado", + "runtimeHostProject": "Proyecto en servidor Orca", + "automationCreated": "Creado por automatización", + "branchIdentity": "Rama", + "branchFolderPathIdentity": "Rama o ruta de carpeta" }, "WorktreeCardAgents": { - "1b0a156717": "Agents" + "1b0a156717": "Agentes" }, "WorktreeCardReviewDetailSection": { "reviewHeader": "{{value0}} #{{value1}}", - "copyLink": "Copy link" + "copyLink": "Copiar enlace" }, "WorktreeCardMeta": { "3e65e11cc6": "Metadatos del espacio de trabajo", @@ -3880,12 +3898,12 @@ "automationMissing": "La automatización ya no está disponible.", "automationRunMissing": "El historial de ejecuciones ya no está disponible.", "automationAvailabilityUnavailable": "No se pudo comprobar la disponibilidad de la automatización.", - "moreIssueActions": "More issue actions", - "copyLink": "Copy link", - "copyLinkSuccess": "{{value0}} copied", - "copyLinkFailure": "Failed to copy link", - "issueLinkLabel": "Issue link", - "reviewLinkLabel": "{{value0}} link" + "moreIssueActions": "Más acciones de issue", + "copyLink": "Copiar enlace", + "copyLinkSuccess": "{{value0}} copiado", + "copyLinkFailure": "No se pudo copiar el enlace", + "issueLinkLabel": "Enlace del issue", + "reviewLinkLabel": "Enlace de {{value0}}" }, "WorktreeCardMetadataStatusBadges": { "fe188062a1": "Estado: Abierto", @@ -3930,7 +3948,7 @@ "8dacff1fe0": "Marcar como leído", "3baa7d6507": "Alfiler", "697d0f6e1b": "Desprender", - "250de158fd": "Remove Workspace", + "250de158fd": "Eliminar espacio de trabajo", "changeParentWorkspace": "Cambiar árbol de trabajo padre...", "setParentWorkspace": "Establecer árbol de trabajo padre...", "workspaceSection": "Espacio de trabajo" @@ -3967,16 +3985,16 @@ "c1f4a31623": "Mostrar {{value0}} espacios de trabajo secundarios", "e97297cb75": "Ocultar {{value0}} espacio de trabajo secundario", "0cd15956d4": "Ocultar {{value0}} espacios de trabajo secundarios", - "bd37a57ac8": "Create workspace for {{value0}}", - "b667b59632": "Some projects could not be removed from Orca", - "f94466bc39": "{{value0}} of {{value1}} contained project{{value2}} remained after deleting the group.", - "groupDeleteFailed": "Failed to delete group", - "groupDeleteFailedDesc": "Something went wrong while deleting the group. No projects were removed.", - "7a8b9c0d1e": "Update required", - "hostAuthNeeded": "Authentication needed", - "hostDisconnected": "Disconnected", + "bd37a57ac8": "Crear espacio de trabajo para {{value0}}", + "b667b59632": "Algunos proyectos no se pudieron eliminar de Orca", + "f94466bc39": "{{value0}} de {{value1}} proyecto{{value2}} contenido(s) permanecieron tras eliminar el grupo.", + "groupDeleteFailed": "No se pudo eliminar el grupo", + "groupDeleteFailedDesc": "Algo salió mal al eliminar el grupo. No se eliminó ningún proyecto.", + "7a8b9c0d1e": "Actualización requerida", + "hostAuthNeeded": "Autenticación requerida", + "hostDisconnected": "Desconectado", "failedNestWorkspace": "No se pudo anidar el espacio de trabajo", - "sidebarRowMissing": "Target no longer exists" + "sidebarRowMissing": "El destino ya no existe" }, "WorktreeMetaDialog": { "3db0a2a593": "Cancelar", @@ -4260,7 +4278,7 @@ "3c4d5e6f7a": "Remove {{value0}}?", "4d5e6f7a8b": "This opens the Orca servers settings where you can remove this server.", "5e6f7a8b9c": "This removes the saved SSH host and its credentials from this computer. Remote files are not deleted.", - "6f7a8b9c0d": "Cancel", + "6f7a8b9c0d": "Cancelar", "7a8b9c0d1e": "Open settings", "8b9c0d1e2f": "Remove host" }, @@ -4269,14 +4287,14 @@ "2b3c4d5e6f": "This label is shown only on this computer. Leave it blank to use the default name.", "3c4d5e6f7a": "Display name", "4d5e6f7a8b": "Reset to default", - "5e6f7a8b9c": "Cancel", + "5e6f7a8b9c": "Cancelar", "6f7a8b9c0d": "Save" }, "HostSectionHeaderMenu": { "5b8b4b6a01": "Update server required", "9b3c1d2e44": "Update client required", "2c29e2de68": "Connection failed", - "bf07aee59e": "Disconnect failed", + "bf07aee59e": "No se pudo desconectar", "7f1a2b3c4d": "{{value0}} is reachable", "4f2c8a9b10": "Host actions for {{value0}}", "6b7c8d9e10": "Host actions", @@ -4307,7 +4325,7 @@ "empty": "No hay árboles de trabajo elegibles que coincidan." }, "WorktreeCardStatusSlot": { - "branchIdentity": "Branch" + "branchIdentity": "Rama" } }, "shared": { @@ -4459,23 +4477,23 @@ "f97b986b7f": "wsl" }, "AgentSkillSetupPanel": { - "0b810ec59f": "Press Enter to run the command.", + "0b810ec59f": "Presiona Enter para ejecutar el comando.", "ed197f59a2": "comando copiar", - "817d3f9f18": "Copy command", + "817d3f9f18": "Copiar comando", "5289300939": "No instalado", "9fcebceb2a": "Instalado", "68a468752e": "De cheques...", "c689392435": "Vuelva a comprobar", - "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": "No se pudo copiar el comando.", + "378ad26865": "Comando copiado.", + "copiedCommand": "Comando copiado.", + "failedToCopyCommand": "No se pudo copiar el comando.", + "copyCommandAria": "Copiar comando", + "runCommandDescription": "Presiona Enter para ejecutar el comando." }, "AgentsPane": { "d83834f5e6": "Detectando agents instalados…", - "024bd95089": "agents", + "024bd95089": "agentes", "e8da2af684": "Disponible para instalar", "ed3e110e61": "detectado", "02e0143be5": "Instalado", @@ -4503,9 +4521,9 @@ "24e032fa34": "Por defecto", "5f986a9b92": "Establecer como predeterminado", "d7625cf8b2": "Agente predeterminado", - "cfb3f35775": "Arguments", + "cfb3f35775": "Argumentos", "6f99bf5dd0": "No default arguments", - "8fbe1f37c1": "Environment", + "8fbe1f37c1": "Entorno", "2d133152fa": "No default environment", "agentPermissions": "Agent Permissions", "agentPermissionsInfo": "Agent permissions info", @@ -4513,7 +4531,7 @@ "agentPermissionsDescription": "Choose whether Orca launches agents with fewer permission prompts or with manual checks.", "agentPermissionsYolo": "Yolo", "agentPermissionsManual": "Manual", - "3f1bdf3cb4": "Environment text is too large to parse safely." + "3f1bdf3cb4": "El texto de entorno es demasiado grande para analizarlo de forma segura." }, "AppIconSelector": { "d5a112dc9b": "Icono siguiente", @@ -4941,7 +4959,11 @@ "70bb30feb1": "Guarde el editor y los cambios de diferencias editables automáticamente después de una breve pausa.", "0df2e4fd12": "Guardar archivos automáticamente", "d21136d9ef": "Configure cómo Orca persiste en las ediciones de archivos.", - "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": "host local, 127.0.0.1, *.interno", @@ -5471,7 +5493,7 @@ "3edf3deaf8": "¿Eliminar \"{{value0}}\"?", "9fcfc29519": "Insertar", "9b3e338d62": "Ingresar", - "4ccc63da87": "Agent", + "4ccc63da87": "Agente", "0252ddd578": "Sin texto de comando", "7784912ed6": "repo", "2bb9e38e93": "Intitulado", @@ -5623,23 +5645,23 @@ "hostSetupStateError": "Error", "hostSetupStateUnsupported": "Unsupported", "setupPathPending": "Path pending", - "openSetup": "Open", + "openSetup": "Abrir", "removeSetup": "Remove", "hostSetupBlockedVersion": "Orca server version is incompatible", "hostSetupMissingCapability": "Update Orca on this host to set up projects", "setupProjectOnHost": "Set up on another host", "setupProjectOnHostHelp": "Choose a host, then import an existing checkout, clone the repository there, or track a setup that will be provisioned later.", - "setupExistingFolder": "Import existing folder", + "setupExistingFolder": "Importar carpeta existente", "setupExistingFolderHelp": "Make this project available on another host by linking a checkout that already exists there.", "setupExistingFolderPathPlaceholder": "/path/to/project/on/host", "cloneUrlPlaceholder": "Repository URL", "cloneDestinationPlaceholder": "/destination/on/host", - "setupKindGit": "Git repo", - "setupKindFolder": "Folder", - "settingUpHost": "Importing...", - "setupHost": "Import", - "cloningHost": "Cloning...", - "cloneHost": "Clone", + "setupKindGit": "Repositorio Git", + "setupKindFolder": "Carpeta", + "settingUpHost": "Importando...", + "setupHost": "Importar", + "cloningHost": "Clonando...", + "cloneHost": "Clonar", "creatingPendingSetup": "Creating...", "createPendingSetup": "Track setup", "hostSetupCheckingCapability": "Checking host capabilities", @@ -5648,7 +5670,7 @@ "addToAnotherHost": "Add to another host", "addProjectHost": "Add project to host", "addProjectHostHelp": "Choose where this project should also be available.", - "closeHostSetup": "Close", + "closeHostSetup": "Cerrar", "setupHostLabel": "Host", "browseFolder": "Browse folder", "browseFolderHelp": "Use an existing checkout or folder on this host.", @@ -5668,7 +5690,7 @@ "7a3a8e431d": "Argumentos CLI", "2b2f38652b": "Comando personalizado", "0ffb081b3a": "Usar agente predeterminado", - "f4310cf63f": "Agent", + "f4310cf63f": "Agente", "1cd88d470a": "Personalizar", "403876bb48": "Usar global", "f0aa2cfaea": "Recetas de acción" @@ -5839,7 +5861,7 @@ "65660d4548": "Permisos de macOS", "c6c01ac209": "Controla terminales y agents desde tu teléfono.", "c40dadaac8": "Móvil", - "c2ee313198": "Use existing machines over SSH for files, terminals, Git, and workspaces.", + "c2ee313198": "Usa máquinas existentes por SSH para archivos, terminales, Git y espacios de trabajo.", "9b02492d1f": "Anfitriones SSH", "b5ee17826b": "Pair remote Orca runtimes for persistent sessions, richer remote state, and web or mobile handoff.", "7686cb5c36": "Conecte este navegador a un servidor Orca guardado.", @@ -5883,7 +5905,7 @@ "21f09426ea": "Opcional. Orca trabaja con los inicios de sesión de su proveedor existente; agregue cuentas solo si desea que Orca le ayude a cambiar entre ellas.", "ad6c529693": "Cuentas de proveedores de IA", "ec1ba547f7": "Administre agents de IA, establezca un valor predeterminado y personalice comandos.", - "8afa676615": "Agents", + "8afa676615": "Agentes", "add3b97ee6": "\"", "3c88ec55d6": "No se encontraron configuraciones para \"", "c7ad095d96": "Cargando configuración...", @@ -5891,7 +5913,7 @@ "43b68e10f0": "No has guardado los cambios de autor de Git AI. Irse los descartará.", "17bdee4ff1": "¿Descartar los cambios de autor de Git AI no guardados?", "084d8fac5b": "Privacidad y seguridad", - "23931df7e8": "Remote Hosts", + "23931df7e8": "Hosts remotos", "mobile_group": "Mobile", "8bd117d669": "Interfaz", "e1578cd4bc": "Flujos de trabajo", @@ -5915,9 +5937,9 @@ "cb330ef7f8": "de {{value0}}", "c822571b2e": "coincidente con \"{{value0}}\"", "3119c012a5": "cadena", - "builtin_themes": "Built-in", + "builtin_themes": "Integrados", "imported_from": "Imported from {{value0}}", - "imported_themes": "Imported", + "imported_themes": "Importados", "search_terminal_themes": "Search terminal themes" }, "SettingsSidebar": { @@ -6289,17 +6311,23 @@ "ec2e33ad80": "Color del divisor de luz", "d56af60e6f": "Elige el tema utilizado cuando Orca está en modo claro.", "8273bc75d7": "Tema ligero", - "74b15574c8": "Configure la apariencia del terminal en modo de luz opcional.", - "b584287e84": "Cuando está deshabilitado, el modo claro reutiliza el tema oscuro del terminal.", - "d76f60c9cc": "Utilice un tema independiente en el modo claro", "bc8e8a251a": "Vista previa del modo oscuro", "cbe56a0f79": "Controla la línea divisoria entre paneles en modo oscuro.", "b739d2abfe": "Color divisor oscuro", "7add204bd5": "Elija el tema del terminal utilizado en modo oscuro.", "9499ad1dc4": "Tema oscuro", "f012172e21": "Elija el tema utilizado para los paneles de terminal en modo oscuro.", - "import_themes_title": "Import Themes", - "import_themes_description": "Imported themes are available in both the dark and light theme pickers." + "catalog_title": "Temas de terminal", + "catalog_description": "Elige temas de terminal y colores de divisor para los modos oscuro y claro.", + "target_title": "Modo del tema", + "target_label": "Modo del tema", + "target_aria": "Modo del tema de terminal", + "target_dark": "Oscuro", + "target_light": "Claro", + "match_dark_mode": "Coincidir con modo oscuro", + "match_dark_mode_description": "Comparte el tema de terminal oscuro y el color del divisor en modo claro.", + "light_preview_description": "Muestra la apariencia efectiva del terminal claro.", + "dark_preview_description": "Muestra la apariencia efectiva del terminal oscuro." }, "TerminalWindowSection": { "1705318506": "color magenta ANSI", @@ -6405,7 +6433,7 @@ "f9a9cf6928": "Se requiere permiso del micrófono antes de habilitar el dictado de voz.", "1eac933202": "Se abrió Privacidad y seguridad de macOS. Habilite el dictado nuevamente después de otorgar acceso.", "cd9fe37556": "Permiso de micrófono concedido", - "6fa734ed95": "Delete {{value0}}" + "6fa734ed95": "Eliminar {{value0}}" }, "WorktreeSymlinksSection": { "1c1e35b219": "Quitar {{value0}}", @@ -6531,7 +6559,7 @@ "cbdd7f3b9e": "Elija si los agents instalados se detectan en este dispositivo o en WSL.", "ef804b7337": "Ubicación del Agent", "01926b9d8c": "Configure agents de codificación de IA, agente predeterminado y anulaciones de comandos.", - "bb9ad95777": "Agents", + "bb9ad95777": "Agentes", "d8f3a8b8a0": "por defecto", "167daeb5e9": "dominio", "be59907510": "anular", @@ -6655,7 +6683,7 @@ "a278406ed5": "remoto", "6ecad74eb3": "ssh", "f17d66d0d2": "Show remote host connection status in the status bar.", - "57fb424c56": "Remote Hosts", + "57fb424c56": "Hosts remotos", "35565867cb": "disparo a la luna", "de586def95": "suscripción", "00a028f25f": "uso", @@ -6950,7 +6978,7 @@ "d01b3882ba": "notificaciones", "244a0ecd3d": "actividad", "92a9357d1f": "vista de agents", - "fa72e71f05": "agents", + "fa72e71f05": "agentes", "4d63251595": "Feed integrado en la barra lateral izquierda para completar agentes y estados de bloqueo.", "ccc5548ac5": "Vista de Agents", "9af7a518db": "personaje", @@ -7034,7 +7062,7 @@ "585beac3f8": "ttl", "0efc9d96ad": "inmediato", "939b80f5fd": "minutero", - "b2601a778c": "cache", + "b2601a778c": "caché", "40c9585e43": "Temporizador de cuenta regresiva que muestra el tiempo hasta que caduque el caché de mensajes (agents Claude).", "1e0f28c6f1": "Temporizador de caché rápido", "e49e739a59": "descargar", @@ -7044,7 +7072,7 @@ "79ff46776e": "Busque actualizaciones de la aplicación e instale una versión más reciente de Orca.", "e15af4eb64": "Buscar actualizaciones", "6382fe9724": "npx", - "baa263d6d8": "agents", + "baa263d6d8": "agentes", "bda108e66c": "habilidad", "244e3fb4c8": "Instale la habilidad Orca para que los agents sepan cómo usar la CLI de Orca.", "2d9f7b42df": "Habilidad del Agent", @@ -7444,7 +7472,7 @@ "eee028ae14": "despacho", "9a5ebdca31": "mensajería", "91fc8ab7e5": "coordinación", - "13ba5c6cbd": "agents", + "13ba5c6cbd": "agentes", "d86705ba77": "multiagente", "a7f76b4ca7": "orquestación", "e05ff36753": "Coordine múltiples agents de codificación a través de mensajes, DAG de tareas, despacho y puertas de decisión.", @@ -7834,8 +7862,8 @@ "1dee533bd9": "Elige el tema utilizado cuando Orca está en modo claro.", "1d89457764": "Tema ligero", "da864e6cec": "modo de luz", - "f268092ee3": "Cuando está deshabilitado, el modo claro reutiliza el tema oscuro del terminal.", - "232e532169": "Utilice un tema independiente en el modo claro", + "f268092ee3": "El modo claro puede usar su propio tema de terminal.", + "match_dark_mode_title": "Coincidir con modo oscuro", "f785374072": "oscuro", "9c32726f47": "Controla la línea divisoria entre paneles en modo oscuro.", "8987db7ff2": "Color divisor oscuro", @@ -7897,11 +7925,12 @@ "0fe0073f0c": "Tamaño de fuente de terminal predeterminado para nuevos paneles y actualizaciones en vivo.", "5930244899": "Tamaño de fuente", "warp_import": { - "title": "Import themes from Warp", + "title": "Importar desde Warp", "description": "Import Warp themes as Orca terminal themes.", "keyword_warp": "warp", "keyword_themes": "themes", - "keyword_yaml": "yaml" + "keyword_yaml": "yaml", + "keyword_legacy_title": "Import themes from Warp" }, "yaml_import": { "title": "Import from YAML", @@ -7914,6 +7943,11 @@ "scrollSpeed": { "title": "Velocidad de desplazamiento", "description": "Ajusta el desplazamiento normal del terminal, el desplazamiento rápido con modificador y la velocidad de rueda en TUI de pantalla completa." + }, + "theme_target": { + "title": "Modo del tema", + "keyword_target": "destino", + "keyword_editing": "edición" } }, "windows": { @@ -8016,7 +8050,7 @@ "b036c7a409": "Derive nombres breves de pestañas estables a partir del primer mensaje de agente conocido. Los cambios de nombre manuales siempre ganan." }, "WarpThemeImportModal": { - "title": "Import themes from Warp", + "title": "Importar desde Warp", "description": "Import Warp themes as Orca terminal themes.", "yaml_title": "Import theme YAML", "yaml_description": "Import theme YAML files (Warp format) as Orca terminal themes.", @@ -8036,7 +8070,7 @@ "choose_manually": "Choose a theme YAML file or folder to import manually.", "skipped_files": "Skipped files", "more_skipped_files": "{{value0}} more skipped files.", - "cancel": "Cancel", + "cancel": "Cancelar", "import_theme_one": "Import 1 Theme", "import_theme_other": "Import {{value0}} Themes", "import_themes": "Import Themes" @@ -8255,7 +8289,7 @@ "runtimeSessionJoin": "{{value0}} y {{value1}}", "runtimeSessionWarning": "{{value0}} seguirá ejecutándose en el runtime actual. Deja que las tareas terminen o reinicia las terminales antes de continuar.", "pendingRuntimeChange": "Runtime change pending. New project work will use the selected runtime after you apply.", - "cancel": "Cancel", + "cancel": "Cancelar", "applyRuntimeChange": "Apply runtime change" }, "PrivacyDiagnosticsRows": { @@ -8783,7 +8817,7 @@ "fe119187bb": "--soneto modelo", "bc8dc39f4b": "Argumentos CLI", "b99c33cec5": "Ajustes", - "15c5d85706": "Agent", + "15c5d85706": "Agente", "3e8f21954f": "error", "74168d7ada": "idle", "1d47db9bf0": "No hay agents habilitados", @@ -8806,7 +8840,7 @@ "4eab815004": "Argumentos CLI", "914c8f6ac2": "Comando personalizado", "cce2cbd01d": "Elige agente", - "9c14186dd2": "Agent" + "9c14186dd2": "Agente" }, "activity": { "bar": { @@ -8900,7 +8934,7 @@ "b8c4e2a1f7": "View full logs", "f8a2c91d04": "Queue for agent", "b4e8a1c902": "Queued", - "7c1f0a2b11": "Open", + "7c1f0a2b11": "Abrir", "e8b4c1a903": "Resolved · {{value0}}", "c3a8e5d710": "Needs review · {{value0}}", "a7f0c7e8d1": "Queue", @@ -8971,7 +9005,7 @@ "6306b48afd": "control de fuente", "ef182dcb12": "buscar", "fc3095d2ed": "explorador", - "aiVaultSessionHistory": "Agents", + "aiVaultSessionHistory": "Agentes", "folderWorkspaces": "Árboles de trabajo adjuntos", "parentPrChecks": "Comprobaciones de PR" }, @@ -8986,7 +9020,7 @@ "542bf6a7e2": "Itálico", "256300f8ea": "Atrevido", "87aff03d63": "Envío...", - "commentTooLarge": "Comment is too large to submit safely." + "commentTooLarge": "El comentario es demasiado grande para enviarlo de forma segura." } } } @@ -9132,10 +9166,10 @@ }, "AiVaultPanel": { "resumeCommandCopied": "Resume command copied", - "valueCopied": "{{value0}} copied", + "valueCopied": "{{value0}} copiado", "valueCopyFailed": "No se pudo copiar {{value0}}", "openWorkspaceBeforeResuming": "Open a workspace before resuming a session.", - "localWorkspacesOnly": "Resume from history is only available in local workspaces.", + "localWorkspacesOnly": "Reanudar desde el historial solo está disponible en espacios de trabajo locales.", "agentSessionQueued": "{{value0}} session queued", "sessionHistory": "Agent Session History", "shownRecent": "{{value0}} shown · {{value1}} recent", @@ -9150,7 +9184,9 @@ "sessionId": "ID de sesión", "logPath": "Ruta del registro", "agents": "Agentes", - "sessionsShownCompact": "{{value0}} mostradas" + "sessionsShownCompact": "{{value0}} mostradas", + "originalPaneUnavailable": "Original pane is no longer available.", + "worktreeUnavailable": "Worktree is no longer available." }, "AiVaultPanelControls": { "scanningSessions": "Scanning sessions", @@ -9165,13 +9201,13 @@ "allSessions": "All sessions", "viewOptionsAriaLabel": "Session History view options", "viewOptions": "View options", - "agents": "Agents", + "agents": "Agentes", "sort": "Sort", "lastUpdated": "Last updated", "created": "Created", "group": "Group", - "folder": "Folder", - "agent": "Agent", + "folder": "Carpeta", + "agent": "Agente", "resetView": "Reset view", "hideEmptySessions": "Hide empty sessions", "workspaceScope": "Workspace", @@ -9182,41 +9218,43 @@ "project": "Proyecto" }, "AiVaultSessionDetails": { - "updated": "Updated", - "created": "Created", - "workingDir": "Working dir", - "unknownLocation": "Unknown location", - "branch": "Branch", - "model": "Model", - "usage": "Usage", - "usageValue": "{{value0}} msgs{{value1}}", + "updated": "Actualizado", + "created": "Creado", + "workingDir": "Dir. de trabajo", + "unknownLocation": "Ubicación desconocida", + "branch": "Rama", + "model": "Modelo", + "usage": "Uso", + "usageValue": "{{value0}} msjs{{value1}}", "tokenSuffix": " · {{value0}} tok", - "session": "Session", - "copyDetailValue": "Copy {{value0}}", - "latestLog": "Latest log", - "noReadablePreview": "No readable message preview in this transcript.", - "resumeCommand": "Resume command", - "sessionActions": "{{value0}} session actions", - "resumeInNewTab": "Resume in New Tab", - "copyResumeCommand": "Copy Resume Command", - "openLog": "Open Log", - "revealLog": "Reveal Log", - "openWorkingDirectory": "Open Working Directory", - "copySessionId": "Copy Session ID", - "copyLogPath": "Copy Log Path", - "unknownTime": "Unknown time", - "unknown": "Unknown", - "user": "User", - "assistant": "Assistant", - "tool": "Tool", - "system": "System", - "log": "Log", - "justNow": "Just now", - "minutesAgo": "{{value0}}m ago", - "hoursAgo": "{{value0}}h ago", - "daysAgo": "{{value0}}d ago", - "monthsAgo": "{{value0}}mo ago", - "yearsAgo": "{{value0}}y ago", + "session": "Sesión", + "copyDetailValue": "Copiar {{value0}}", + "latestLog": "Último registro", + "noReadablePreview": "No hay vista previa de mensajes legible en esta transcripción.", + "resumeCommand": "Comando para reanudar", + "sessionActions": "Acciones de sesión {{value0}}", + "resumeInNewTab": "Reanudar en nueva pestaña", + "resumeInWorktree": "Reanudar en árbol de trabajo", + "viewLog": "Ver registro", + "copyResumeCommand": "Copiar comando para reanudar", + "openLog": "Abrir registro", + "revealLog": "Mostrar registro", + "openWorkingDirectory": "Abrir directorio de trabajo", + "copySessionId": "Copiar ID de sesión", + "copyLogPath": "Copiar ruta de registro", + "unknownTime": "Hora desconocida", + "unknown": "Desconocido", + "user": "Usuario", + "assistant": "Asistente", + "tool": "Herramienta", + "system": "Sistema", + "log": "Registro", + "justNow": "Ahora mismo", + "minutesAgo": "hace {{value0}}m", + "hoursAgo": "hace {{value0}}h", + "daysAgo": "hace {{value0}}d", + "monthsAgo": "hace {{value0}}mo", + "yearsAgo": "hace {{value0}}a", "sessionId": "ID de sesión", "originalAsk": "Solicitud original", "latestTurns": "Últimos turnos", @@ -9226,18 +9264,21 @@ "agentRole": "Agente", "toolRole": "Herramienta", "systemRole": "Sistema", - "sessionRole": "Sesión" + "sessionRole": "Sesión", + "jumpToOriginalPane": "Saltar al panel original", + "worktree": "Árbol de trabajo", + "jumpToWorktree": "Saltar al árbol de trabajo" }, "AiVaultSessionRow": { - "resumeAgentSession": "Resume {{value0}} session", - "resumeInNewTab": "Resume in New Tab", - "copyResumeCommand": "Copy Resume Command", - "openLog": "Open Log", - "revealLog": "Reveal Log", - "openWorkingDirectory": "Open Working Directory", - "copySessionId": "Copy Session ID", - "copyLogPath": "Copy Log Path", - "messageCount": "{{value0}} msgs", + "resumeAgentSession": "Reanudar sesión {{value0}}", + "resumeInNewTab": "Reanudar en nueva pestaña", + "copyResumeCommand": "Copiar comando para reanudar", + "openLog": "Abrir registro", + "revealLog": "Mostrar registro", + "openWorkingDirectory": "Abrir directorio de trabajo", + "copySessionId": "Copiar ID de sesión", + "copyLogPath": "Copiar ruta de registro", + "messageCount": "{{value0}} msjs", "tokenCount": "{{value0}} tok", "toggleSessionDetails": "Detalles de sesión de {{value0}}", "hideDetails": "Ocultar detalles", @@ -9250,7 +9291,9 @@ "agentRole": "Agente", "toolRole": "Herramienta", "systemRole": "Sistema", - "sessionRole": "Sesión" + "sessionRole": "Sesión", + "jumpToOriginalPane": "Saltar al panel original", + "jumpToWorktree": "Saltar al árbol de trabajo" }, "FileExplorerNameFilter": { "26fb73c6e3": "Buscar archivos", @@ -9280,20 +9323,31 @@ "pull": { "policy": { "notice": { - "merge": "Merge", - "mergeDescription": "Create a merge commit when local and remote both changed.", - "rebase": "Rebase", - "rebaseDescription": "Replay local commits on top of the remote branch.", - "fastForwardOnly": "Fast-forward only", - "fastForwardOnlyDescription": "Only pull when no merge or rebase is needed.", - "title": "Pull needs a policy", - "diverged": "Diverged", - "body": "This branch has local and remote commits. Run one command in this worktree or on the SSH host, then try Pull or Sync again.", - "copyAria": "Copy {{value0}} pull policy command", - "copied": "Copied", - "copyCommand": "Copy command" + "merge": "Fusionar (Merge)", + "mergeDescription": "Crear un commit de fusión cuando tanto el repositorio local como el remoto hayan cambiado.", + "rebase": "Reorganizar (Rebase)", + "rebaseDescription": "Volver a aplicar los commits locales sobre la rama remota.", + "fastForwardOnly": "Solo avance rápido (Fast-forward)", + "fastForwardOnlyDescription": "Solo realizar pull cuando no se requiera fusión ni rebase.", + "title": "El pull requiere una política", + "diverged": "Divergido", + "body": "Esta rama tiene commits locales y remotos. Ejecute un comando en este árbol de trabajo o en el host SSH, luego intente Pull o Sincronizar de nuevo.", + "copyAria": "Copiar el comando de política de pull {{value0}}", + "copied": "Copiado", + "copyCommand": "Copiar comando" } } + }, + "AiVaultSessionWorktree": { + "currentWorktree": "Árbol de trabajo actual", + "activeWorktree": "Árbol de trabajo activo", + "archivedWorktree": "Árbol de trabajo archivado", + "unavailableWorktree": "Árbol de trabajo no disponible", + "jumpToWorktree": "Saltar al árbol de trabajo", + "noRecordedWorktree": "No se registró ningún árbol de trabajo para esta sesión.", + "archivedJumpUnavailable": "Esta sesión se encuentra en un árbol de trabajo archivado.", + "noActiveWorktreeMatch": "Ningún árbol de trabajo activo coincide con esta sesión.", + "noActiveWorktreeTarget": "No hay ningún árbol de trabajo activo disponible." } } }, @@ -9335,7 +9389,7 @@ "3c5a593bc8": "Web", "477b28c948": "Base de datos", "787490e9bd": "Paquete", - "07012dc113": "Agent", + "07012dc113": "Agente", "3eba7387ab": "Terminal", "65b437c381": "Código", "bed2674f9d": "Carpeta" @@ -9770,7 +9824,7 @@ "ea8ad0bae8": "de", "0a891e8935": "API DESCANSO", "953f7c6062": "Este host de GitLab no devolvió encabezados de límite de velocidad.", - "budget_scope_prefix": "Budget scope" + "budget_scope_prefix": "Ámbito del presupuesto" } } } @@ -9853,7 +9907,7 @@ "be8917699e": "Modelo", "4d9b6d84df": "sin soporte. Elija Claude, Codex o Personalizado.", "560d4feb00": "Costumbre", - "29d119fe95": "Agent", + "29d119fe95": "Agente", "f9382b48a1": "Habilitar autor de IA", "1c0cb4fabb": "autor de IA", "bd14e9c42a": "No configurado", @@ -10223,10 +10277,10 @@ "connect": { "integration": { "step": { - "0f47ff17c6": "Change", - "5538eb6743": "Done", - "open_step": "Open", - "close_step": "Close" + "0f47ff17c6": "Cambiar", + "5538eb6743": "Listo", + "open_step": "Abrir", + "close_step": "Cerrar" } } }, @@ -10467,7 +10521,9 @@ "8368d256ec": "rama combinada", "8f68ad9ca9": "Mostrar {{value0}} AI {{value1}}", "724a13568d": "en {{value0}}", - "6094135eec": "frente a {{value0}}" + "6094135eec": "frente a {{value0}}", + "a4420ca1f7": "Wrap On", + "dde325ddfe": "Wrap Off" }, "ConflictComponents": { "f338288514": "Cargando contenidos conflictivos...", @@ -10533,7 +10589,7 @@ "3c6e71df22": "La diferencia de texto no está disponible para este archivo en la comparación de ramas.", "d07e4b8553": "rama", "d16e037f40": "rico", - "6c4f1a8d2e": "Check details are unavailable." + "6c4f1a8d2e": "Los detalles de la comprobación no están disponibles." }, "EditorPanelHeader": { "fb8331694e": "Abrir vista previa al lado", @@ -10555,7 +10611,8 @@ "3e0ce48c24": "Exportar como PDF", "561251019a": "Más acciones", "8c8b7f5ff5": "Mostrar portada", - "10c39d58c1": "Ocultar materia preliminar" + "10c39d58c1": "Ocultar materia preliminar", + "1eef809708": "Word Wrap" }, "EditorPanelShell": { "e2c4dec350": "Cargando editor..." @@ -10664,7 +10721,7 @@ "MonacoEditor": { "68cb83f4a7": "Agregar nota sobre el texto seleccionado", "fd68ae03b3": "Buscar en archivos", - "largePasteTooLarge": "Paste is too large." + "largePasteTooLarge": "El contenido pegado es demasiado grande." }, "MonacoGutterContextMenu": { "7b57b1b468": "Copiar URL remota", @@ -10902,32 +10959,32 @@ "CheckRunDetailsPanel": { "8f2d0f5a91": "Passed", "4c8e1b2d73": "Failed", - "91a4c7e2b0": "Cancelled", - "2f6d8a1c45": "Timed out", - "7b3e9d4f12": "Skipped", + "91a4c7e2b0": "Cancelado", + "2f6d8a1c45": "Tiempo agotado", + "7b3e9d4f12": "Omitido", "5a1c8e3d67": "Neutral", "3d9f2b8e14": "Pending", "b7f5e2c91a": "Refresh", - "a54ae21c6f": "Status:", - "fd46a70f1a": "Started", + "a54ae21c6f": "Estado:", + "fd46a70f1a": "Iniciado", "00e1c1658a": "Completed", "aa8494ae3c": "check #", "2dd5ddabc4": "workflow #", - "1f2b980522": "Loading check details…", - "d098e5529a": "Output", - "f2fe8a4e8f": "Annotations", + "1f2b980522": "Cargando detalles de la comprobación…", + "d098e5529a": "Salida", + "f2fe8a4e8f": "Anotaciones", "cdbfda4dec": "Annotation", - "066fedd446": "Failed jobs", - "49731703ea": "Jobs", + "066fedd446": "Tareas fallidas", + "49731703ea": "Tareas", "ee07b33924": "unknown", - "07eccfa397": "No details are available for this check.", - "a916648574": "Open details", - "834cb3f23d": "Fix with AI", - "c8f1a2d4e7": "Choose the agent and edit the full command input before launch.", - "b3e7f9a1c2": "Check fix context unavailable", - "d5a8c2f1b9": "Start the default AI agent to fix this check", - "e2b4d7c8a1": "Choose an agent for this check", - "f1c9e3a6d4": "Choose agent to fix check" + "07eccfa397": "No hay detalles disponibles para esta comprobación.", + "a916648574": "Abrir detalles", + "834cb3f23d": "Corregir con IA", + "c8f1a2d4e7": "Elige el agente y edita el comando completo antes de iniciarlo.", + "b3e7f9a1c2": "El contexto de corrección de la comprobación no está disponible", + "d5a8c2f1b9": "Iniciar el agente de IA predeterminado para corregir esta comprobación", + "e2b4d7c8a1": "Elige un agente para esta comprobación", + "f1c9e3a6d4": "Elige un agente para corregir la comprobación" }, "check": { "run": { @@ -10947,7 +11004,7 @@ } }, "richMarkdownLargeTextPaste": { - "tooLarge": "Paste is too large." + "tooLarge": "El contenido pegado es demasiado grande." } }, "diff": { @@ -10965,7 +11022,7 @@ "2b3ce6d394": "Cancelar", "e05063cfc1": "Línea {{value0}}", "c845170b3b": "Líneas {{value0}}-{{value1}}", - "commentTooLarge": "Comment is too large to submit safely." + "commentTooLarge": "El comentario es demasiado grande para enviarlo de forma segura." }, "useDiffCommentDecorator": { "995fa28b50": "esta nota" @@ -11094,8 +11151,8 @@ "palette": { "project": { "results": { - "repoGroup": "Repo group", - "project": "Project" + "repoGroup": "Grupo de repositorios", + "project": "Proyecto" } } } @@ -11211,7 +11268,7 @@ "5c3d530a68": "Downloaded", "4bb7424d6b": "Canceled", "6e776f9ef9": "Download failed", - "756bfc25c9": "Open", + "756bfc25c9": "Abrir", "09a9489aa5": "Show" }, "BrowserToolbarMenu": { @@ -11279,7 +11336,7 @@ "449fc83bf7": "Fichas", "401f40ae79": "Est. gastar", "a7c312430d": "última ejecución", - "2df8970cd5": "Agent", + "2df8970cd5": "Agente", "e353ab9516": "verificación previa", "620b22145e": "Gracia", "15ea446b93": "Sesión", @@ -11302,7 +11359,7 @@ }, "AutomationEditorDialog": { "fb1896a5e7": "Cancelar", - "57b722cbba": "Agent", + "57b722cbba": "Agente", "6ff66f9012": "Nueva carrera", "a2e688226d": "árbol de trabajo", "6f9610e667": "Worktree se ejecuta en el espacio de trabajo seleccionado. La nueva ejecución crea un espacio de trabajo nuevo a partir de la rama seleccionada cada vez.", @@ -11578,7 +11635,7 @@ }, "AgentSettingsDialog": { "50cdb57c03": "Administre agents de IA, establezca un valor predeterminado y personalice comandos.", - "fc0268e4ed": "Agents" + "fc0268e4ed": "Agentes" } }, "activity": { @@ -11596,7 +11653,7 @@ "a472a14700": "Más opciones", "db8a1878b5": "Opciones de lista de hilos", "d1a88df9a8": "Mostrar solo hilos no leídos", - "f6396e1f85": "Agent", + "f6396e1f85": "Agente", "b29191b3e0": "árbol de trabajo", "8c3b621ddf": "Proyecto", "4a3986b200": "Estado", @@ -11611,7 +11668,7 @@ }, "ActivityTitlebarControls": { "f915168c8e": "no leído", - "d6a8de3934": "agents", + "d6a8de3934": "agentes", "dc708f3eff": "Agentes cercanos" } }, @@ -11624,9 +11681,9 @@ "jira": { "connect": { "dialog": { - "63ce735809": "Connect", + "63ce735809": "Conectar", "4a2ab52781": "Verifying…", - "79e7aaed39": "Cancel", + "79e7aaed39": "Cancelar", "fdd26d81cc": "Atlassian account settings", "8090504a3e": "Create a token in", "7b3967c12f": "Atlassian API token", @@ -11783,7 +11840,7 @@ "starOnGithub": "Star on GitHub", "onboardingCompleted": "Onboarding completed!", "body": "If you’re enjoying Orca so far, a GitHub star helps other developers discover it.", - "dismiss": "Dismiss", + "dismiss": "Descartar", "later": "Later" } } diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 531f739720c..c9084e43301 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -25,7 +25,7 @@ "geminiToggleDescription": "アクティブなワークスペースの Gemini トークンとコストの使用状況を表示します。", "opencodeGoToggleDescription": "アクティブなワークスペースの OpenCode Go トークンと使用コストを表示します。", "kimiToggleDescription": "Kimi サブスクリプション", - "sshToggleDescription": "Show configured SSH and remote Orca hosts when any are available.", + "sshToggleDescription": "設定済み SSH ホストとリモート Orca ホストがある場合に表示します。", "resourceUsageToggleDescription": "リソースマネージャーを表示します。これをクリックすると、CPU、メモリ、セッション、デーモン コントロール、およびワークスペース ディスク スキャンが行われます。", "portsToggleDescription": "ライブワークスペースポートを表示します。ワークスペーススコープのポートと外部リスナーの場合はこれをクリックします。" } @@ -107,7 +107,7 @@ "c9d6f98459": "最大化", "66f0a552e5": "復元", "bbb7f90669": "最小化", - "d54e66004c": "terminal", + "d54e66004c": "ターミナル", "9f0152563e": "モバイル", "62ca9895a7": "スペース", "844eb0f4f4": "アクティビティ", @@ -172,8 +172,8 @@ "dcb521ed29": "このファイルは競合状態にありますが、編集できる作業ツリー ファイルがありません。", "51f15c37d3": "ディレクトリを開けません: {{value0}}", "f2e00db373": "ファイルが見つかりません: {{value0}}", - "checkRunDetailsUnavailable": "No details are available for this check.", - "checkRunDetailsLoadFailed": "Failed to load check details." + "checkRunDetailsUnavailable": "このチェックの詳細はありません。", + "checkRunDetailsLoadFailed": "チェック詳細の読み込みに失敗しました。" }, "github": { "f129c42773": "GitHub は新規コメントを返しませんでした。", @@ -247,10 +247,10 @@ "53883b7bc3": "{{value0}} に送信できませんでした" }, "jira": { - "856083302c": "Jira connection was superseded by a newer request." + "856083302c": "Jira 接続は新しいリクエストに置き換えられました。" }, "linear": { - "37d36984d0": "Linear connection was superseded by a newer request." + "37d36984d0": "Linear 接続は新しいリクエストに置き換えられました。" } } }, @@ -510,10 +510,10 @@ } }, "projectSkillRuntime": { - "wslUnavailable": "Project runtime needs WSL before this skill can be installed.", - "distroRequired": "Select a WSL distro for this project before installing this skill.", - "distroMissing": "The selected WSL distro is unavailable. Choose an available distro or switch this project to Windows.", - "wslDefault": "WSL default" + "wslUnavailable": "このスキルをインストールするには、プロジェクトランタイムに WSL が必要です。", + "distroRequired": "このスキルをインストールする前に、このプロジェクトの WSL ディストリビューションを選択してください。", + "distroMissing": "選択した WSL ディストリビューションは利用できません。利用可能なものを選ぶか、このプロジェクトを Windows に切り替えてください。", + "wslDefault": "WSL 既定" } }, "hooks": { @@ -521,7 +521,7 @@ "59718b120b": "ターゲット ワークスペースは使用できなくなりました。", "16a21d6413": "SSH の再接続には対話型の認証情報が必要です。", "386db94f3e": "対象のプロジェクトは利用できなくなりました。", - "3ad7d77f57": "The target workspace is on a different host than this automation run target." + "3ad7d77f57": "対象ワークスペースは、この自動化実行ターゲットとは別のホスト上にあります。" }, "useComposerState": { "7eb3f44ff7": "選択した agent は無効になっています。作成する前に、有効な agent を選択。", @@ -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}} が見つかりません", @@ -570,7 +570,7 @@ "d91ae31fbd": "macOS のアクセス許可", "95a1886d94": "スマートフォンから terminals と agents を操作", "1cd25673df": "モバイル", - "31e57d1c70": "Use existing machines over SSH for files, terminals, Git, and workspaces.", + "31e57d1c70": "ファイル、ターミナル、Git、ワークスペースに既存のマシンを SSH 経由で使用します。", "94a5afe910": "SSH ホスト", "40d80bad8a": "ベータ", "de0c2907a1": "リモート Orca サーバー", @@ -593,7 +593,7 @@ "42ae40842f": "グローバルまたはプロジェクトごとにスコープ設定された、保存された terminal コマンド。", "3fc3db144f": "クイックコマンド", "c33bfd664c": "シェル、レンダラ、セッション、および terminal の動作。", - "a9fb10afca": "Terminal", + "a9fb10afca": "ターミナル", "5235c215ca": "「タスク」ページとサイドバーに表示するタスクプロバイダーを選択します。", "85f4fd7710": "タスクソース", "ab4b21b58e": "ブランチの名前、基本参照、帰属、および Git AI 作成者。", @@ -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 Issue", - "commentTooLarge": "Comment is too large to submit safely." + "commentTooLarge": "コメントが大きすぎるため安全に送信できません。" }, "Landing": { "76a95f7f47": "作成", @@ -944,7 +960,7 @@ "0d0ace8861": "GitHub でスターを付ける", "ec43b38ba7": "GitHub でスター済み", "157bb5ecbb": "GitHub を開く", - "preflightDismiss": "Dismiss" + "preflightDismiss": "閉じる" }, "LinearIssueMarkdownDescriptionEditor": { "d9c47069ef": "Markdown", @@ -1045,7 +1061,7 @@ "39883467f4": "Linear Issue", "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": "プロジェクトを選択", @@ -1079,34 +1095,36 @@ "connectProjectFirst": "先にこのプロジェクトに接続してください", "connectSourceLookup": "接続", "reconnectSourceLookup": "再接続", - "sshNotConnected": "SSH not connected", - "connectingSsh": "Connecting SSH...", - "sshAuthenticationFailed": "SSH authentication failed", - "preparingSshConnection": "Preparing SSH connection...", + "sshNotConnected": "SSH 未接続", + "connectingSsh": "SSH に接続中...", + "sshAuthenticationFailed": "SSH 認証に失敗しました", + "preparingSshConnection": "SSH 接続を準備中...", "connected": "接続済み", - "reconnectingSsh": "Reconnecting SSH...", - "sshReconnectionFailed": "SSH reconnection failed", - "notConnected": "Not connected", - "runOn": "Run on", - "setupHostExistingFolderTitle": "Set up {{value0}}", - "cloneProjectOnHost": "Clone project", + "reconnectingSsh": "SSH に再接続中...", + "sshReconnectionFailed": "SSH 再接続に失敗しました", + "notConnected": "未接続", + "runOn": "実行先", + "setupHostExistingFolderTitle": "{{value0}} を設定", + "cloneProjectOnHost": "プロジェクトをクローン", "cloneUrlPlaceholder": "https://github.com/owner/repo.git", "cloneDestinationPlaceholder": "/parent/directory/on/host", - "cloningHostSetup": "Cloning...", - "cloneHostSetup": "Clone", - "importExistingFolderOnHost": "Import existing folder", + "cloningHostSetup": "クローン中...", + "cloneHostSetup": "クローン", + "importExistingFolderOnHost": "既存フォルダをインポート", "setupHostExistingFolderPlaceholder": "/path/to/project/on/host", - "setupKindGit": "Git repo", - "setupKindFolder": "Folder", - "setupHostExistingFolderHelp": "Link a checkout that already exists there, then create this workspace on that host.", - "importingHostSetup": "Importing...", + "setupKindGit": "Git リポジトリ", + "setupKindFolder": "フォルダ", + "setupHostExistingFolderHelp": "そこに既に存在するチェックアウトをリンクし、そのホスト上にこのワークスペースを作成します。", + "importingHostSetup": "インポート中...", "importHostSetup": "インポート", - "notePasteTooLarge": "Paste is too large for the note field.", - "reuseExistingBranch": "Reuse branch", - "reuseExistingBranchHint": "Check out the existing branch instead of creating a new one from it.", - "createMultiple": "Create more" + "notePasteTooLarge": "メモ欄への貼り付け内容が大きすぎます。", + "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": "移動", @@ -1604,28 +1622,28 @@ "3b7f34282f": "閉まっている", "246bd64aed": "{{value0}} を Linear で開く", "ff90d0abc7": "{{value0}} からワークスペースを開始", - "fe28c9821f": "view", + "fe28c9821f": "ビュー", "8d1e17a3ef": "{{value0}} を GitHub で開く", "4ac8ff2275": "{{value0}} を Jira で開く", - "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": "このリポジトリ内の別の issue と重複", + "duplicateIssueNumberPlaceholder": "Issue 番号", + "closeAsDuplicateSubmit": "重複を閉じる", + "duplicateIssueMissing": "このリポジトリの issue 番号を入力してください。", + "duplicateIssueNotInteger": "整数の issue 番号を使用してください。", + "duplicateIssueNotPositive": "正の issue 番号を使用してください。", + "duplicateIssueSameIssue": "別の issue を選択してください。", + "repository": "リポジトリ", + "backToCloseReasons": "戻る", + "searchIssues": "Issue を検索", + "useIssueNumber": "Issue #{{value0}} を使用", + "noMatchingIssuesLoaded": "一致する issue は読み込まれていません。", + "editReviewersWithCurrent": "レビュアーを編集: {{value0}}" }, "Terminal": { "73768427cf": "閉じる", @@ -1642,8 +1660,8 @@ "46e08bc5c8": "このファイルには保存されていない変更が含まれています。", "61ed600d29": "「{{value0}}」には未保存の変更があります。閉じる前に保存しますか?", "cdc9ac4b2d": "エディタ", - "e57db40c11": "Could not build launch command for {{value0}}.", - "5b2c1a9e44": "No agent CLI detected — install one or pick a default agent in Settings." + "e57db40c11": "{{value0}} の起動コマンドを作成できませんでした。", + "5b2c1a9e44": "エージェント CLI が検出されません。インストールするか、設定で既定のエージェントを選択してください。" }, "TerminalSearch": { "db234b7519": "閉じる", @@ -1725,11 +1743,11 @@ "recentWorktreesHeader": "最近のワークツリー", "settingsBadge": "設定", "actionBadge": "操作", - "paletteHostBadge": "Host: {{value0}}", + "paletteHostBadge": "ホスト: {{value0}}", "workspaceTabMissing": "タブはもう存在しません", - "projectsGroupsHeader": "Projects & Groups", - "projectBadge": "Project", - "repoGroupBadge": "Repo group" + "projectsGroupsHeader": "プロジェクトとグループ", + "projectBadge": "プロジェクト", + "repoGroupBadge": "リポジトリグループ" }, "github": { "pr": { @@ -1872,10 +1890,10 @@ "22df63c393": "トークンではサブ Issue データを利用できません。", "067119985c": "GitHub 検索、例:担当者:@me は:開いています", "1850fceac8": "{{value0}}/{{value1}} は Orca に追加されません。これを追加して作業を開始するか、GitHub で開きます。", - "1aa7c952b9": "Project view", - "f352abf7c3": "Repository list is updating.", - "1ce21b8cff": "This item is outside the selected repositories.", - "030de75bc5": "This item matches multiple selected repositories." + "1aa7c952b9": "プロジェクトビュー", + "f352abf7c3": "リポジトリ一覧を更新中です。", + "1ce21b8cff": "この項目は選択したリポジトリの外にあります。", + "030de75bc5": "この項目は複数の選択済みリポジトリに一致します。" }, "slug": { "dialog": { @@ -1892,7 +1910,7 @@ "463d030ae4": "削除", "8564f58542": "編集", "5f104bf855": "コメントはまだありません。", - "commentTooLarge": "Comment is too large to submit safely." + "commentTooLarge": "コメントが大きすぎるため安全に送信できません。" }, "LabelsEditor": { "34dd57d6c8": "読み込み中…", @@ -1917,11 +1935,11 @@ "e3bd59143c": "入れる", "f24783f470": "https://...", "ec6310b731": "http:// または https:// 画像 URL を使用します。", - "b7e4a1c902": "Paste, drop, or click to add files", - "8f1c2d4e6a": "Nothing to preview", - "c91f0a2b14": "Write", - "d82b1e3f05": "Preview", - "imageUrlTooLarge": "Image URL is too large." + "b7e4a1c902": "貼り付け、ドロップ、またはクリックしてファイルを追加", + "8f1c2d4e6a": "プレビューするものはありません", + "c91f0a2b14": "書く", + "d82b1e3f05": "プレビュー", + "imageUrlTooLarge": "画像 URL が大きすぎます。" }, "IssueSourceSelector": { "d6aeb2012b": "からの Issue を表示しています", @@ -1992,51 +2010,51 @@ "c377a4f06a": "検索", "c392c749a6": "REST API", "bb227706a6": "REST", - "budget_scope_prefix": "Budget scope" + "budget_scope_prefix": "予算スコープ" } } } }, "CloseReasonDropdown": { - "e1f2a3b4c5": "Choose close reason" + "e1f2a3b4c5": "クローズ理由を選択" }, "GitHubIssueCommentComposer": { - "082515176a": "Failed to add comment", - "9f88657c4e": "Issue closed", - "e9b7cb7d17": "Failed to close issue", - "bd3b4492a0": "Issue reopened", - "f2a8c1d903": "Failed to reopen issue", - "a1b2c3d4e5": "Add a comment", - "c5c117270e": "Add your comment here, be kind", - "f6a7b8c9d0": "Close issue", - "b1c2d3e4f5": "Reopen issue", - "0a73f59e85": "Send comment", + "082515176a": "コメントの追加に失敗しました", + "9f88657c4e": "Issue を閉じました", + "e9b7cb7d17": "Issue を閉じられませんでした", + "bd3b4492a0": "Issue を再オープンしました", + "f2a8c1d903": "Issue を再オープンできませんでした", + "a1b2c3d4e5": "コメントを追加", + "c5c117270e": "ここにコメントを追加してください。丁寧にお願いします", + "f6a7b8c9d0": "Issue を閉じる", + "b1c2d3e4f5": "Issue を再オープン", + "0a73f59e85": "コメントを送信", "bf43425540": "コメント", - "commentTooLarge": "Comment is too large to submit safely." + "commentTooLarge": "コメントが大きすぎるため安全に送信できません。" }, "GitHubWorkItemAssigneePopoverContent": { - "cddd9b04a7": "Loading assignees", - "a00830d3f7": "No users", - "4f8b6f2c1d": "Filter assignees..." + "cddd9b04a7": "担当者を読み込み中", + "a00830d3f7": "ユーザーがいません", + "4f8b6f2c1d": "担当者を絞り込み..." }, "GitHubWorkItemLabelPopoverContent": { - "2aa9acdf34": "Edit labels on GitHub", - "cddd9b04a7": "Loading labels", - "de26e2eb06": "No labels", - "8b0d52ee3a": "Filter labels..." + "2aa9acdf34": "GitHub でラベルを編集", + "cddd9b04a7": "ラベルを読み込み中", + "de26e2eb06": "ラベルなし", + "8b0d52ee3a": "ラベルを絞り込み..." }, "githubIssueCloseReasons": { "completed": { - "label": "Close as completed", - "description": "Done, closed, fixed, resolved" + "label": "完了として閉じる", + "description": "完了、クローズ、修正済み、解決済み" }, "notPlanned": { - "label": "Close as not planned", - "description": "Won't fix, can't repro, stale" + "label": "対応予定なしとして閉じる", + "description": "修正しない、再現不可、古い" }, "duplicate": { - "label": "Close as duplicate", - "description": "Duplicate of another issue" + "label": "重複として閉じる", + "description": "別の issue と重複" } } }, @@ -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": "ワークツリーの準備ができていません - しばらくしてからもう一度お試しください。", "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,16 +2484,16 @@ "9acaf92093": "ペインの操作", "1bce81dba6": "シミュレータ", "1ff1c77616": "ブラウザ", - "586d2ac445": "terminal", + "586d2ac445": "ターミナル", "addSplitPane": "分割ペインを追加", "closePaneColumn": "分割ペインを閉じる" }, "AiVaultSessionDropLayer": { - "dropOntoTerminalPane": "Drop onto a terminal pane to resume this session.", - "couldNotReadPayload": "Could not read the session drag payload.", - "localWorkspacesOnly": "Resume from history is only available in local workspaces.", - "openLocalWorkspace": "Open a local workspace before resuming a session.", - "sessionQueued": "Session queued" + "dropOntoTerminalPane": "このセッションを再開するにはターミナルペインにドロップしてください。", + "couldNotReadPayload": "セッションのドラッグデータを読み取れませんでした。", + "localWorkspacesOnly": "履歴からの再開はローカルワークスペースでのみ利用できます。", + "openLocalWorkspace": "セッションを再開する前にローカルワークスペースを開いてください。", + "sessionQueued": "セッションをキューに追加しました" }, "TabGroupDropOverlay": { "paneColumnLabel": "新しい分割" @@ -2596,8 +2614,8 @@ "7b1c9d6ae1": "走る", "c781f992e4": "破壊的な", "be8f0ff166": "削除", - "f3a8c2d1e7": "Search quick commands...", - "b4e7f9a2c1": "No commands match" + "f3a8c2d1e7": "クイックコマンドを検索...", + "b4e7f9a2c1": "一致するコマンドはありません" }, "shell": { "icons": { @@ -2614,32 +2632,32 @@ "5a9c83c04b": "任意のファイル、URL、agent などを開きます...", "90eb94dc48": "http:// または https:// URL を入力します。", "5553b283ce": "URL またはファイル パスを入力します。", - "queryTooLarge": "Search text is too large." + "queryTooLarge": "検索テキストが大きすぎます。" } }, "menu": { "options": { - "5501c2fb7a": "terminal", + "5501c2fb7a": "ターミナル", "9630dd5494": "shell", - "a094576900": "new terminal", - "4f23f4d01d": "new shell", - "4f2a91e15b": "browser", - "6d0e6a4b7a": "new browser", - "c87ad57785": "browser tab", - "cce7ef1d2c": "web", + "a094576900": "新しいターミナル", + "4f23f4d01d": "新しいシェル", + "4f2a91e15b": "ブラウザ", + "6d0e6a4b7a": "新しいブラウザ", + "c87ad57785": "ブラウザタブ", + "cce7ef1d2c": "Web", "5f17fb9d0c": "markdown", "44caaf7b36": "md", "fb50e3d874": "新規 markdown", - "6d8b6b4117": "new file", + "6d8b6b4117": "新しいファイル", "b330f72434": "mark", - "37ff3ddca1": "open markdown", - "164c394bab": "open file", - "bbaf4f85a4": "mobile emulator", - "3784b83bd4": "emulator", - "a63847a742": "simulator", - "1baeb07c17": "ios simulator", - "8a580f88cf": "iphone", - "7ecdc5ef08": "ipad", + "37ff3ddca1": "Markdown を開く", + "164c394bab": "ファイルを開く", + "bbaf4f85a4": "モバイルエミュレータ", + "3784b83bd4": "エミュレータ", + "a63847a742": "シミュレータ", + "1baeb07c17": "iOS シミュレータ", + "8a580f88cf": "iPhone", + "7ecdc5ef08": "iPad", "14965cc123": "モバイル" } } @@ -2748,10 +2766,10 @@ "e9a5d3c2b1f0": "{{value0}} を終了しますか?" }, "SshStatusSegment": { - "3ad70e0365": "Manage Remote Hosts…", - "6e8a9a4242": "Remote Hosts", - "d09ec41831": "Remote Hosts", - "fdc57e9970": "Remote host connection status", + "3ad70e0365": "リモートホストを管理…", + "6e8a9a4242": "リモートホスト", + "d09ec41831": "リモートホスト", + "fdc57e9970": "リモートホスト接続状態", "59b553e2aa": "切断", "63f36455cc": "接続", "bf07aee59e": "切断に失敗しました", @@ -2762,20 +2780,20 @@ "fbb3f9f05e": "競合", "95e4ff5b4b": "押す", "63a2b965f6": "引っ張る", - "remote_server": "Remote Server", + "remote_server": "リモートサーバー", "runtime_checking": "確認中", "runtime_online": "接続済み", - "runtime_unavailable": "Disconnected", - "runtime_connect_unavailable": "Remote host is not reachable", - "runtime_disconnect_failed": "Disconnect failed", - "runtime_reconnecting": "Reconnecting", - "runtime_last_close_reason": "Closed: {{value0}}", - "runtime_reconnect_attempt": "Attempt {{value0}}" + "runtime_unavailable": "切断済み", + "runtime_connect_unavailable": "リモートホストに到達できません", + "runtime_disconnect_failed": "切断に失敗しました", + "runtime_reconnecting": "再接続中", + "runtime_last_close_reason": "クローズ: {{value0}}", + "runtime_reconnect_attempt": "試行 {{value0}}" }, "StatusBar": { "9659e38343": "ポート", "d1e1a7a6bf": "リソースマネージャー", - "24ac89df1a": "Remote Hosts", + "24ac89df1a": "リモートホスト", "5e59007df4": "Kimi 使用量", "8c86cd77b0": "OpenCode Go の使用法", "c1df0d67ec": "Gemini 使用量", @@ -2814,14 +2832,14 @@ "6cd6650b4c": "セッションを再開する", "1446d0d8a0": "{{value0}} Codex セッションはまだ古いアカウントにあります。", "605901a495": "1 Codex セッションはまだ古いアカウントにあります", - "5e5f9f5160": "1 rate-limit reset available", - "5ecae9197c": "{{value0}} rate-limit resets available", - "25d8bbde69": "Using reset…", - "e159fc1fd7": "Reset now", - "972a1ff497": "Reset Codex limits?", - "6d1042aa6f": "This uses one Codex rate-limit reset credit for the active account and resets any eligible usage windows immediately.", - "f077f586db": "Don't ask again", - "c0e972d726": "Cancel" + "5e5f9f5160": "レート制限リセットが 1 回利用可能", + "5ecae9197c": "レート制限リセットが {{value0}} 回利用可能", + "25d8bbde69": "リセットを使用中…", + "e159fc1fd7": "今すぐリセット", + "972a1ff497": "Codex の制限をリセットしますか?", + "6d1042aa6f": "アクティブなアカウントの Codex レート制限リセット枠を 1 回使用し、対象の使用量ウィンドウを直ちにリセットします。", + "f077f586db": "今後表示しない", + "c0e972d726": "キャンセル" }, "StatusBarUsageEmptyCta": { "828c764a79": "アカウントを接続する", @@ -2898,8 +2916,8 @@ "b9b4a3a25d": "ブランチ", "c432278ec7": "エディタバッファ", "0bc756efaf": "Git の変更", - "e9528a89b3": "Terminals", - "a8d9e0de79": "Agents", + "e9528a89b3": "ターミナル", + "a8d9e0de79": "エージェント", "d384a4ce9f": "決定の削除", "7d7745bb8f": "削除できます", "720870a18e": "保持: リンク済み", @@ -2964,25 +2982,25 @@ "7ad719c4bf": "制限中", "e740f92596": "更新に失敗しました", "8418ec448d": "{{value0}} の使用状況を更新できませんでした。Agent セッションは引き続きサインイン済みの場合があります。", - "45198c7d95": "1 rate-limit reset available", - "bce421cba3": "{{value0}} rate-limit resets available", - "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." + "45198c7d95": "レート制限リセットが 1 回利用可能", + "bce421cba3": "レート制限リセットが {{value0}} 回利用可能", + "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 Host" + "sshHost": "SSH ホスト" } } }, @@ -3036,10 +3054,10 @@ "cfe2282ffa": "未知", "7765a4c3e1": "該当なし", "2d41fd45c6": "• 最終スキャン エラー: {{value0}}", - "rangeLast7Days": "Last 7 days", - "rangeLast30Days": "Last 30 days", - "rangeLast90Days": "Last 90 days", - "rangeAllTime": "All time" + "rangeLast7Days": "過去 7 日間", + "rangeLast30Days": "過去 30 日間", + "rangeLast90Days": "過去 90 日間", + "rangeAllTime": "全期間" }, "CodexUsageDailyChart": { "1e6f62d7e3": "推論", @@ -3089,10 +3107,10 @@ "ae255c3dba": "該当なし", "247c93ca92": "• 推定価格", "8a6655f7a2": "• 最終スキャン エラー: {{value0}}", - "rangeLast7Days": "Last 7 days", - "rangeLast30Days": "Last 30 days", - "rangeLast90Days": "Last 90 days", - "rangeAllTime": "All time" + "rangeLast7Days": "過去 7 日間", + "rangeLast30Days": "過去 30 日間", + "rangeLast90Days": "過去 90 日間", + "rangeAllTime": "全期間" }, "OpenCodeUsagePane": { "349f7c3f5c": "合計", @@ -3132,10 +3150,10 @@ "362231082f": "未知", "8095a63426": "該当なし", "6cc7782458": "• 最終スキャン エラー: {{value0}}", - "rangeLast7Days": "Last 7 days", - "rangeLast30Days": "Last 30 days", - "rangeLast90Days": "Last 90 days", - "rangeAllTime": "All time" + "rangeLast7Days": "過去 7 日間", + "rangeLast30Days": "過去 30 日間", + "rangeLast90Days": "過去 90 日間", + "rangeAllTime": "全期間" }, "ShareUsageButton": { "7d6b25323d": "Xで共有する", @@ -3215,7 +3233,7 @@ "8efeae0b22": "トラッキング", "5acbe1fdf2": "時間", "ef8bbf7739": "PRs", - "ce8533f02e": "agents", + "ce8533f02e": "エージェント", "0bba8ca244": "統計", "0e2a0b6431": "使用法", "372debfac0": "統計", @@ -3257,19 +3275,19 @@ }, "UsageBreakdownSection": { "7765a4c3e1": "n/a", - "247c93ca92": "• inferred pricing" + "247c93ca92": "• 推定価格" }, "UsageSessionsTable": { "1afc25eb06": "ターン", - "0f03975d59": "Events", - "21ea00bfa8": "Cache", - "e0b988599d": "Total", - "01476891c7": "Last active", - "c17bed0416": "Project", - "f6a2c8d019": "Model", - "faf3444859": "Input", - "a8b7487ff7": "Output", - "cfe2282ffa": "Unknown" + "0f03975d59": "イベント", + "21ea00bfa8": "キャッシュ", + "e0b988599d": "合計", + "01476891c7": "最終アクティブ", + "c17bed0416": "プロジェクト", + "f6a2c8d019": "モデル", + "faf3444859": "入力", + "a8b7487ff7": "出力", + "cfe2282ffa": "不明" } }, "sparse": { @@ -3354,7 +3372,7 @@ "c234df77f7": "作成する前に、サーバーの親フォルダを選択または入力してください。", "3a13f6e88b": "場所が選択されていません", "6ed14c0281": "サーバーフォルダが選択されていません", - "ssh_parent_manual": "Enter an SSH parent path." + "ssh_parent_manual": "SSH の親パスを入力してください。" }, "AddRepoNestedImportStep": { "496f68cf8c": "repos をスキャンしています。クリックして停止します。", @@ -3389,8 +3407,8 @@ "dd3ff65486": "リモートファイルシステムを参照する", "36d427bb66": "リモートプロジェクトを追加", "35831a7312": "追加中...", - "lockedDescription": "Enter the path to a Git repository on {{value0}}.", - "lockedDisconnected": "{{value0}} is disconnected.", + "lockedDescription": "{{value0}} 上の Git リポジトリへのパスを入力してください。", + "lockedDisconnected": "{{value0}} は切断されています。", "93e0221434": "接続" }, "AddRepoServerStartStep": { @@ -3439,8 +3457,8 @@ "3e64e8a70d": "接続に失敗しました", "32a7256d85": "クローン", "69f5b5380d": "クローン作成中...", - "cloneOnHostDescription": "Enter the Git URL and choose where to clone it on {{value0}}.", - "cloneParentFolder": "Parent folder" + "cloneOnHostDescription": "Git URL を入力し、{{value0}} 上のクローン先を選択してください。", + "cloneParentFolder": "親フォルダ" }, "AutoRenameFailedDialog": { "aed1623b1e": "閉じる", @@ -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 タスクを開く", @@ -3733,13 +3751,13 @@ "c2c7a45cda": "なし", "680043342f": "コンパクト", "c7591b6014": "repo", - "hosts": "Hosts", - "allHostsDetail": "Show every host", - "configuredSshHost": "Configured SSH", - "projectSshHost": "Project SSH", - "activeRuntimeHost": "Active server", - "projectRuntimeHost": "Project server", - "631b97eea9": "Host scope", + "hosts": "ホスト", + "allHostsDetail": "すべてのホストを表示", + "configuredSshHost": "設定済み SSH", + "projectSshHost": "プロジェクト SSH", + "activeRuntimeHost": "アクティブサーバー", + "projectRuntimeHost": "プロジェクトサーバー", + "631b97eea9": "ホストスコープ", "2d4f0eb933": "デフォルト", "1a0eec0d35": "ステータス", "b5536d5a88": "タスク", @@ -3747,8 +3765,8 @@ "2d74665a56": "ポート", "65a9820bd1": "Agent ステータス", "219ebf1961": "ブランチ名", - "automation": "Automation", - "folderPathIdentity": "Branch / folder path" + "automation": "自動化", + "folderPathIdentity": "ブランチ / フォルダパス" }, "SshDisconnectedDialog": { "ca4a7892af": "接続中...", @@ -3759,9 +3777,9 @@ "11552bf786": "SSH が切断されました", "cb5938ae79": "再接続中...", "disconnected": "This remote repository is not connected.", - "reconnecting": "Reconnecting to the remote host...", - "reconnectionFailed": "Reconnection to the remote host failed.", - "authFailed": "Authentication to the remote host failed." + "reconnecting": "リモートホストに再接続中...", + "reconnectionFailed": "リモートホストへの再接続に失敗しました。", + "authFailed": "リモートホストへの認証に失敗しました。" }, "SshTargetRow": { "4677394048": "接続中…", @@ -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": "その他の issue 操作", + "copyLink": "リンクをコピー", + "copyLinkSuccess": "{{value0}} をコピーしました", + "copyLinkFailure": "リンクのコピーに失敗しました", + "issueLinkLabel": "Issue リンク", + "reviewLinkLabel": "{{value0}} リンク" }, "WorktreeCardMetadataStatusBadges": { "fe188062a1": "状態: オープン", @@ -3911,7 +3929,7 @@ "8dacff1fe0": "既読マークを付ける", "3baa7d6507": "ピン", "697d0f6e1b": "固定を解除する", - "250de158fd": "Remove Workspace", + "250de158fd": "ワークスペースを削除", "changeParentWorkspace": "親ワークツリーを変更...", "setParentWorkspace": "親ワークツリーを設定...", "workspaceSection": "ワークスペース" @@ -3943,21 +3961,21 @@ "045a8aed48": "子供たち", "2ca6e29a3c": "repo", "bb85cd86ba": "{{value0}} のワークスペースを作成する", - "ebadb7eadb": "{{value0}} {{value1}} child {{value2}}", + "ebadb7eadb": "{{value0}} {{value1}} 子 {{value2}}", "20bebf9c7f": "{{value0}} 個の子ワークスペースを表示", "c1f4a31623": "{{value0}} 個の子ワークスペースを表示", "e97297cb75": "{{value0}} 個の子ワークスペースを非表示", "0cd15956d4": "{{value0}} 個の子ワークスペースを非表示", - "bd37a57ac8": "Create workspace for {{value0}}", - "b667b59632": "Some projects could not be removed from Orca", - "f94466bc39": "{{value0}} of {{value1}} contained project{{value2}} remained after deleting the group.", - "groupDeleteFailed": "Failed to delete group", - "groupDeleteFailedDesc": "Something went wrong while deleting the group. No projects were removed.", - "7a8b9c0d1e": "Update required", - "hostAuthNeeded": "Authentication needed", - "hostDisconnected": "Disconnected", + "bd37a57ac8": "{{value0}} のワークスペースを作成", + "b667b59632": "一部のプロジェクトを Orca から削除できませんでした", + "f94466bc39": "グループ削除後も、含まれていたプロジェクト{{value2}} {{value1}} 件中 {{value0}} 件が残りました。", + "groupDeleteFailed": "グループの削除に失敗しました", + "groupDeleteFailedDesc": "グループ削除中に問題が発生しました。プロジェクトは削除されませんでした。", + "7a8b9c0d1e": "更新が必要です", + "hostAuthNeeded": "認証が必要です", + "hostDisconnected": "切断済み", "failedNestWorkspace": "ワークスペースをネストできませんでした", - "sidebarRowMissing": "Target no longer exists" + "sidebarRowMissing": "対象はもう存在しません" }, "WorktreeMetaDialog": { "3db0a2a593": "キャンセル", @@ -4241,7 +4259,7 @@ "e8c1f4a2b9": "をワークスペースオプションで選択してください。" }, "AddRepoHostSelector": { - "host": "Host", + "host": "ホスト", "local": "ローカル", "runtime": "Server", "ssh": "SSH" @@ -4276,7 +4294,7 @@ "5b8b4b6a01": "Update server required", "9b3c1d2e44": "Update client required", "2c29e2de68": "Connection failed", - "bf07aee59e": "Disconnect failed", + "bf07aee59e": "切断に失敗しました", "7f1a2b3c4d": "{{value0}} is reachable", "4f2c8a9b10": "Host actions for {{value0}}", "6b7c8d9e10": "Host actions", @@ -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": "No default arguments", - "8fbe1f37c1": "Environment", + "8fbe1f37c1": "環境", "2d133152fa": "No default environment", "agentPermissions": "Agent Permissions", "agentPermissionsInfo": "Agent permissions info", "agentPermissionsTooltip": "Custom agent arguments stay unchanged when switching modes.", "agentPermissionsDescription": "Choose whether Orca launches agents with fewer permission prompts or with manual checks.", "agentPermissionsYolo": "Yolo", - "agentPermissionsManual": "Manual", - "3f1bdf3cb4": "Environment text is too large to parse safely." + "agentPermissionsManual": "手動", + "3f1bdf3cb4": "環境テキストが大きすぎるため安全に解析できません。" }, "AppIconSelector": { "d5a112dc9b": "次へのアイコン", @@ -4644,7 +4662,7 @@ "c0f85056d9": "Browser profiles on this Orca server.", "86b7c83fee": "This computer", "6480776a03": "Browser profiles for the selected host.", - "5e19a692f7": "Host" + "5e19a692f7": "ホスト" }, "BrowserProfileRow": { "8e636cae25": "プロファイル「{{value0}}」が削除されました。", @@ -4926,7 +4944,11 @@ "70bb30feb1": "エディターと編集可能な差分を保存すると、少し停止した後、自動的に変更されます。", "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", @@ -5024,7 +5046,7 @@ "1f744a72f4": "構成" }, "WarpThemeImportModal": { - "title": "Warp からテーマをインポート", + "title": "Warp からインポート", "description": "Warp テーマを Orca の terminal テーマとしてインポートします。", "yaml_title": "テーマ YAML をインポート", "yaml_description": "テーマ YAML ファイル(Warp 形式)を Orca の terminal テーマとしてインポートします。", @@ -5493,7 +5515,7 @@ "3edf3deaf8": "「{{value0}}」を削除しますか?", "9fcfc29519": "入れる", "9b3e338d62": "入力", - "4ccc63da87": "Agent", + "4ccc63da87": "エージェント", "0252ddd578": "コマンドテキストなし", "7784912ed6": "repo", "2bb9e38e93": "無題", @@ -5651,17 +5673,17 @@ "hostSetupMissingCapability": "Update Orca on this host to set up projects", "setupProjectOnHost": "Set up on another host", "setupProjectOnHostHelp": "Choose a host, then import an existing checkout, clone the repository there, or track a setup that will be provisioned later.", - "setupExistingFolder": "Import existing folder", + "setupExistingFolder": "既存フォルダをインポート", "setupExistingFolderHelp": "Make this project available on another host by linking a checkout that already exists there.", "setupExistingFolderPathPlaceholder": "/path/to/project/on/host", "cloneUrlPlaceholder": "Repository URL", "cloneDestinationPlaceholder": "/destination/on/host", - "setupKindGit": "Git repo", - "setupKindFolder": "Folder", - "settingUpHost": "Importing...", + "setupKindGit": "Git リポジトリ", + "setupKindFolder": "フォルダ", + "settingUpHost": "インポート中...", "setupHost": "インポート", - "cloningHost": "Cloning...", - "cloneHost": "Clone", + "cloningHost": "クローン中...", + "cloneHost": "クローン", "creatingPendingSetup": "Creating...", "createPendingSetup": "Track setup", "hostSetupCheckingCapability": "Checking host capabilities", @@ -5671,7 +5693,7 @@ "addProjectHost": "Add project to host", "addProjectHostHelp": "Choose where this project should also be available.", "closeHostSetup": "閉じる", - "setupHostLabel": "Host", + "setupHostLabel": "ホスト", "browseFolder": "Browse folder", "browseFolderHelp": "Use an existing checkout or folder on this host.", "otherWaysToAdd": "Other ways to add", @@ -5690,7 +5712,7 @@ "7a3a8e431d": "CLI 引数", "2b2f38652b": "カスタムコマンド", "0ffb081b3a": "デフォルトの agent を使用する", - "f4310cf63f": "Agent", + "f4310cf63f": "エージェント", "1cd88d470a": "カスタマイズ", "403876bb48": "グローバルを使用する", "f0aa2cfaea": "操作レシピ" @@ -5861,7 +5883,7 @@ "65660d4548": "macOS のアクセス許可", "c6c01ac209": "スマートフォンから terminals と agents を操作", "c40dadaac8": "モバイル", - "c2ee313198": "Use existing machines over SSH for files, terminals, Git, and workspaces.", + "c2ee313198": "ファイル、ターミナル、Git、ワークスペースに既存のマシンを SSH 経由で使用します。", "9b02492d1f": "SSH ホスト", "b5ee17826b": "Pair remote Orca runtimes for persistent sessions, richer remote state, and web or mobile handoff.", "7686cb5c36": "このブラウザを保存された Orca サーバーに接続します。", @@ -5885,7 +5907,7 @@ "6742c7932c": "グローバルまたはプロジェクトごとにスコープ設定された、保存された terminal コマンド。", "13d4fe30ad": "クイックコマンド", "b79b5b31e9": "シェル、レンダラ、セッション、および terminal の動作。", - "3de4bbb841": "Terminal", + "3de4bbb841": "ターミナル", "dd72ed437a": "「タスク」ページとサイドバーに表示するタスクプロバイダーを選択します。", "11faa2f7dd": "タスクソース", "cfa34f4465": "ブランチの名前、基本参照、帰属、および Git AI 作成者。", @@ -5905,7 +5927,7 @@ "21f09426ea": "オプション。 Orca は既存のプロバイダー ログインと連携します。 Orca にアカウント間の切り替えを支援してもらいたい場合にのみ、アカウントを追加。", "ad6c529693": "AI プロバイダー アカウント", "ec1ba547f7": "AI agents を管理し、デフォルトを設定し、コマンドをカスタマイズします。", - "8afa676615": "Agents", + "8afa676615": "エージェント", "add3b97ee6": "」", "3c88ec55d6": "「」の設定が見つかりませんでした。", "c7ad095d96": "設定を読み込んでいます...", @@ -5913,7 +5935,7 @@ "43b68e10f0": "Git AI Author の変更が保存されていません。離れるとそれらは破棄されます。", "17bdee4ff1": "保存されていない Git AI Author の変更を破棄しますか?", "084d8fac5b": "プライバシーとセキュリティ", - "23931df7e8": "Remote Hosts", + "23931df7e8": "リモートホスト", "mobile_group": "モバイル", "8bd117d669": "インターフェース", "e1578cd4bc": "ワークフロー", @@ -5981,7 +6003,7 @@ "3c0fac059a": "terminal にキーボード フォーカスがある間も実行されます。", "25b0004fbf": "Terminal がアクティブです", "781cb74d22": "terminal ペインから実行します。", - "cb02e00202": "Terminal", + "cb02e00202": "ターミナル", "d8c988dab4": "~/.orca/keybindings.json" }, "SourceControlAiActionRecipeDefaults": { @@ -6306,22 +6328,28 @@ "d06664e889": "ダーク" }, "TerminalThemeSections": { - "import_themes_title": "テーマをインポート", - "import_themes_description": "インポートしたテーマは、ダークとライト両方のテーマピッカーで利用できます。", "db210115c5": "ライトモードのプレビュー", "5e0c24b5c8": "ライト モードでペイン間の分割分割線を制御します。", "ec2e33ad80": "ライトディバイダーの色", "d56af60e6f": "Orca がライト モードのときに使用されるテーマを選択します。", "8273bc75d7": "ライトテーマ", - "74b15574c8": "オプションのライトモード terminal の外観を構成します。", - "b584287e84": "無効にすると、ライト モードはダーク terminal テーマを再利用します。", - "d76f60c9cc": "ライトモードで別のテーマを使用する", "bc8e8a251a": "ダークモードのプレビュー", "cbe56a0f79": "ダーク モードでのペイン間の分割分割線を制御します。", "b739d2abfe": "ダークディバイダーカラー", "7add204bd5": "ダークモードで使用する terminal テーマを選択します。", "9499ad1dc4": "ダークテーマ", - "f012172e21": "ダーク モードの terminal ペインに使用されるテーマを選択します。" + "f012172e21": "ダーク モードの terminal ペインに使用されるテーマを選択します。", + "catalog_title": "ターミナルテーマ", + "catalog_description": "ダークモードとライトモードのターミナルテーマと区切り線の色を選択します。", + "target_title": "テーマモード", + "target_label": "テーマモード", + "target_aria": "ターミナルテーマモード", + "target_dark": "ダーク", + "target_light": "ライト", + "match_dark_mode": "ダークモードに合わせる", + "match_dark_mode_description": "ライトモードでダークのターミナルテーマとディバイダー色を共有します。", + "light_preview_description": "有効なライトターミナルの外観を表示します。", + "dark_preview_description": "有効なダークターミナルの外観を表示します。" }, "TerminalWindowSection": { "1705318506": "ANSIマゼンタ色", @@ -6427,7 +6455,7 @@ "f9a9cf6928": "音声ディクテーションを有効にする前に、マイクの許可が必要です。", "1eac933202": "macOS のプライバシーとセキュリティを開きました。アクセスを許可した後、ディクテーションを再度有効にします。", "cd9fe37556": "マイクの許可が与えられました", - "6fa734ed95": "Delete {{value0}}" + "6fa734ed95": "{{value0}} を削除" }, "WorktreeSymlinksSection": { "1c1e35b219": "{{value0}} を削除", @@ -6553,7 +6581,7 @@ "cbdd7f3b9e": "インストールされている agents がこのデバイスまたは WSL で検出されるかどうかを選択します。", "ef804b7337": "Agent の場所", "01926b9d8c": "AI コーディング agents、デフォルト agents、およびコマンド オーバーライドを構成します。", - "bb9ad95777": "Agents", + "bb9ad95777": "エージェント", "d8f3a8b8a0": "デフォルト", "167daeb5e9": "コマンド", "be59907510": "上書き", @@ -6665,7 +6693,7 @@ "cf409b6c4d": "ポート", "cb1cc62cf8": "スペース", "90bdc043ea": "ディスク", - "96b4fb0064": "terminal", + "96b4fb0064": "ターミナル", "4ddbde4999": "CPU", "4355f18ac6": "メモリ", "9c4d5f0894": "マネージャー", @@ -6677,7 +6705,7 @@ "a278406ed5": "リモート", "6ecad74eb3": "ssh", "f17d66d0d2": "Show remote host connection status in the status bar.", - "57fb424c56": "Remote Hosts", + "57fb424c56": "リモートホスト", "35565867cb": "ムーンショット", "de586def95": "サブスクリプション", "00a028f25f": "使用法", @@ -6964,7 +6992,7 @@ "edc49480a1": "ペイン", "268e99d957": "ハイライト", "01567f19ca": "注意", - "9bb3bd5098": "terminal", + "9bb3bd5098": "ターミナル", "11877246fc": "terminal ベルおよび agent 完了イベントの永続的なペインのハイライト。", "9e4ddf776d": "Terminal アテンション", "fe5688b761": "サイドバー", @@ -6972,7 +7000,7 @@ "d01b3882ba": "通知", "244a0ecd3d": "アクティビティ", "92a9357d1f": "agents ビュー", - "fa72e71f05": "agents", + "fa72e71f05": "エージェント", "4d63251595": "agent の完了とブロック状態を示すスレッド化された左側のサイドバー フィード。", "ccc5548ac5": "Agents ビュー", "9af7a518db": "キャラクター", @@ -6986,11 +7014,11 @@ "87d99e634b": "ペット", "agentHibernation": { "agent": "agent", - "agents": "agents", + "agents": "エージェント", "description": "設定したアイドル時間が経過したバックグラウンド agent terminals を停止し、再度開いたときに対応セッションを再開します。Agent のスリープは Orca から起動した agent の起動オプションを保持します。手動で起動した agent は現在の Orca のデフォルトで再開される場合があります。", "minutes": "分", "sleep": "スリープ", - "terminal": "terminal", + "terminal": "ターミナル", "title": "Agent のスリープ" }, "newWorktreeCardStyle": { @@ -7017,7 +7045,7 @@ "156ffeee08": "注記", "884e5e6132": "markdown", "49db74a92d": "ブラウザ", - "6410fe83d8": "terminal", + "6410fe83d8": "ターミナル", "2b5efa55c9": "グローバル", "ebeedb2f6a": "クイック terminal", "6f183fa1b9": "フローティング terminal", @@ -7066,13 +7094,13 @@ "79ff46776e": "アプリのアップデートを確認し、新規 Orca バージョンをインストールします。", "e15af4eb64": "アップデートをチェックする", "6382fe9724": "npx", - "baa263d6d8": "agents", + "baa263d6d8": "エージェント", "bda108e66c": "スキル", "244e3fb4c8": "Orca スキルをインストールして、agents が Orca CLI を使用できるようにします。", "2d9f7b42df": "Agent スキル", "0a00691c06": "シェルコマンド", "dbeb1f348e": "コマンド", - "88d3df9ce9": "terminal", + "88d3df9ce9": "ターミナル", "fb4f338a3d": "パス", "924a660a78": "CLI", "ca529079bf": "Orca CLI コマンドを登録または削除します。", @@ -7338,7 +7366,7 @@ "84e5706975": "サーブシム", "7c5a8a2bee": "xcode", "bec7231663": "iPad", - "49727355a3": "iphone", + "49727355a3": "iPhone", "6b6407dc1f": "エミュレータ", "2d67f708ce": "シミュレータ", "c5eca29310": "iOSシミュレータ", @@ -7358,7 +7386,7 @@ "fadcbfdd99": "フィット", "ad08035c5f": "スマートフォン", "6cd2bfdb0e": "復元", - "b34ad5b3a7": "terminal", + "b34ad5b3a7": "ターミナル", "6db86f445f": "モバイル", "707fc78052": "アプリを閉じるか切り替えた後、モバイルで表示していた terminals がどうなるかを選択します。", "1e711aca11": "モバイルアプリを終了するとき", @@ -7436,7 +7464,7 @@ "96562a72c6": "集中中は抑制", "a2ab73b325": "注意", "ae0487f8fd": "ベル", - "c638ae989d": "terminal", + "c638ae989d": "ターミナル", "d3f1c48677": "バックグラウンド terminal がベル文字を発したときに通知します。", "a5edee1d99": "Terminal ベル", "193e1f107c": "タスク", @@ -7466,7 +7494,7 @@ "eee028ae14": "急送", "9a5ebdca31": "メッセージング", "91fc8ab7e5": "調整", - "13ba5c6cbd": "agents", + "13ba5c6cbd": "エージェント", "d86705ba77": "マルチ agent", "a7f76b4ca7": "オーケストレーション", "e05ff36753": "メッセージング、タスク DAG、ディスパッチ、意思決定ゲートを介して複数のコーディング agents を調整します。", @@ -7519,7 +7547,7 @@ "8bf43c2dad": "グローバル", "a26ecdb77b": "スニペット", "d07d130849": "ショートカット", - "0073cf8ce9": "terminal", + "0073cf8ce9": "ターミナル", "cfffa6cdb6": "コマンド", "fecb031823": "コマンド", "236d4cfac8": "素早い", @@ -7677,7 +7705,7 @@ "0f8cb15582": "agent", "f1adebbe8c": "シェル", "7f1b38f59a": "トゥイ", - "7e3fc707aa": "terminal", + "7e3fc707aa": "ターミナル", "0ecba9aa5f": "キーボード", "ebd7d81e1d": "ショートカットが重なった場合に、Orca とフォーカスされた terminal のどちらが優先されるかを選択します。", "f052906167": "Terminal のショートカット" @@ -7798,7 +7826,7 @@ "d4daf4f612": "解凍する", "88561b3499": "凍った", "0a05629060": "回復する", - "f66a7cf715": "terminal", + "f66a7cf715": "ターミナル", "6892fb1019": "再起動", "cde233f5da": "スクロールバック", "3982d88725": "歴史", @@ -7818,11 +7846,12 @@ "73e9422f19": "サポートされている Ghostty terminal 設定の 1 回限りのインポート。", "a979df0083": "Ghostty からインポート", "warp_import": { - "title": "Warp からテーマをインポート", + "title": "Warp からインポート", "description": "Warp テーマを Orca の terminal テーマとしてインポートします。", "keyword_warp": "warp", "keyword_themes": "テーマ", - "keyword_yaml": "yaml" + "keyword_yaml": "yaml", + "keyword_legacy_title": "Import themes from Warp" }, "yaml_import": { "title": "YAML からインポート", @@ -7869,8 +7898,8 @@ "1dee533bd9": "Orca がライト モードのときに使用されるテーマを選択します。", "1d89457764": "ライトテーマ", "da864e6cec": "ライトモード", - "f268092ee3": "無効にすると、ライト モードはダーク terminal テーマを再利用します。", - "232e532169": "ライトモードで別のテーマを使用する", + "f268092ee3": "ライトモードでは独自のターミナルテーマを使用できます。", + "match_dark_mode_title": "ダークモードに合わせる", "f785374072": "ダーク", "9c32726f47": "ダーク モードでのペイン間の分割分割線を制御します。", "8987db7ff2": "ダークディバイダーカラー", @@ -7936,6 +7965,11 @@ "scrollSpeed": { "title": "スクロール速度", "description": "通常のターミナルスクロールバック、修飾キーでの高速スクロール、全画面 TUI のホイール速度を調整します。" + }, + "theme_target": { + "title": "テーマモード", + "keyword_target": "対象", + "keyword_editing": "編集" } }, "windows": { @@ -7944,7 +7978,7 @@ "fcfa53920b": "ペースト", "e55186fe2b": "右クリック", "28ff08ed35": "窓", - "e7d2793b03": "terminal", + "e7d2793b03": "ターミナル", "8ba875c132": "Windows では、右クリックしてクリップボードを terminal に貼り付けます。 Ctrl キーを押しながら右クリックしてコンテキスト メニューを開きます。", "f0b8448570": "右クリックして貼り付けます", "04994f6929": "デフォルト", @@ -8255,7 +8289,7 @@ "runtimeSessionJoin": "{{value0}}と{{value1}}", "runtimeSessionWarning": "{{value0}}は現在のランタイムで実行を続けます。続行する前にタスクの完了を待つか、ターミナルを再起動してください。", "pendingRuntimeChange": "Runtime change pending. New project work will use the selected runtime after you apply.", - "cancel": "Cancel", + "cancel": "キャンセル", "applyRuntimeChange": "Apply runtime change" }, "PrivacyDiagnosticsRows": { @@ -8775,7 +8809,7 @@ "fe119187bb": "--モデルソネット", "bc8dc39f4b": "CLI 引数", "b99c33cec5": "設定", - "15c5d85706": "Agent", + "15c5d85706": "エージェント", "3e8f21954f": "エラー", "74168d7ada": "idle", "1d47db9bf0": "有効な agents がありません", @@ -8806,7 +8840,7 @@ "4eab815004": "CLI 引数", "914c8f6ac2": "カスタムコマンド", "cce2cbd01d": "agent を選択", - "9c14186dd2": "Agent" + "9c14186dd2": "エージェント" }, "activity": { "bar": { @@ -8900,7 +8934,7 @@ "b8c4e2a1f7": "View full logs", "f8a2c91d04": "Queue for agent", "b4e8a1c902": "Queued", - "7c1f0a2b11": "Open", + "7c1f0a2b11": "開く", "e8b4c1a903": "Resolved · {{value0}}", "c3a8e5d710": "Needs review · {{value0}}", "a7f0c7e8d1": "Queue", @@ -8971,7 +9005,7 @@ "6306b48afd": "ソース管理", "ef182dcb12": "検索", "fc3095d2ed": "エクスプローラ", - "aiVaultSessionHistory": "Agents", + "aiVaultSessionHistory": "エージェント", "folderWorkspaces": "添付ワークツリー", "parentPrChecks": "PR チェック" }, @@ -8986,7 +9020,7 @@ "542bf6a7e2": "イタリック", "256300f8ea": "太字", "87aff03d63": "送信中...", - "commentTooLarge": "Comment is too large to submit safely." + "commentTooLarge": "コメントが大きすぎるため安全に送信できません。" } } } @@ -9132,10 +9166,10 @@ }, "AiVaultPanel": { "resumeCommandCopied": "Resume command copied", - "valueCopied": "{{value0}} copied", + "valueCopied": "{{value0}} をコピーしました", "valueCopyFailed": "{{value0}} をコピーできません", "openWorkspaceBeforeResuming": "Open a workspace before resuming a session.", - "localWorkspacesOnly": "Resume from history is only available in local workspaces.", + "localWorkspacesOnly": "履歴からの再開はローカルワークスペースでのみ利用できます。", "agentSessionQueued": "{{value0}} session queued", "sessionHistory": "Agent Session History", "shownRecent": "{{value0}} shown · {{value1}} recent", @@ -9149,8 +9183,10 @@ "noSessionsMatchFilters": "No sessions match the current filters", "sessionId": "セッション ID", "logPath": "ログパス", - "agents": "Agents", - "sessionsShownCompact": "{{value0}} 件表示" + "agents": "エージェント", + "sessionsShownCompact": "{{value0}} 件表示", + "originalPaneUnavailable": "元のペインは利用できなくなりました。", + "worktreeUnavailable": "ワークツリーは利用できなくなりました。" }, "AiVaultPanelControls": { "scanningSessions": "Scanning sessions", @@ -9165,58 +9201,60 @@ "allSessions": "All sessions", "viewOptionsAriaLabel": "Session History view options", "viewOptions": "View options", - "agents": "Agents", + "agents": "エージェント", "sort": "Sort", "lastUpdated": "Last updated", "created": "Created", "group": "Group", - "folder": "Folder", - "agent": "Agent", + "folder": "フォルダ", + "agent": "エージェント", "resetView": "Reset view", "hideEmptySessions": "Hide empty sessions", "workspaceScope": "ワークスペース", "worktreeScope": "Worktree", - "globalScope": "Global", + "globalScope": "グローバル", "projectScope": "プロジェクト", "currentProjectLower": "現在のプロジェクト", "project": "プロジェクト" }, "AiVaultSessionDetails": { - "updated": "Updated", - "created": "Created", - "workingDir": "Working dir", - "unknownLocation": "Unknown location", - "branch": "Branch", - "model": "Model", - "usage": "Usage", - "usageValue": "{{value0}} msgs{{value1}}", + "updated": "更新日時", + "created": "作成日時", + "workingDir": "作業ディレクトリ", + "unknownLocation": "不明な場所", + "branch": "ブランチ", + "model": "モデル", + "usage": "使用量", + "usageValue": "{{value0}} 件のメッセージ{{value1}}", "tokenSuffix": " · {{value0}} tok", - "session": "Session", - "copyDetailValue": "Copy {{value0}}", - "latestLog": "Latest log", - "noReadablePreview": "No readable message preview in this transcript.", - "resumeCommand": "Resume command", - "sessionActions": "{{value0}} session actions", - "resumeInNewTab": "Resume in New Tab", - "copyResumeCommand": "Copy Resume Command", - "openLog": "Open Log", - "revealLog": "Reveal Log", - "openWorkingDirectory": "Open Working Directory", - "copySessionId": "Copy Session ID", - "copyLogPath": "Copy Log Path", - "unknownTime": "Unknown time", - "unknown": "Unknown", - "user": "User", - "assistant": "Assistant", - "tool": "Tool", - "system": "System", - "log": "Log", - "justNow": "Just now", - "minutesAgo": "{{value0}}m ago", - "hoursAgo": "{{value0}}h ago", - "daysAgo": "{{value0}}d ago", - "monthsAgo": "{{value0}}mo ago", - "yearsAgo": "{{value0}}y ago", + "session": "セッション", + "copyDetailValue": "{{value0}}をコピー", + "latestLog": "最新のログ", + "noReadablePreview": "この履歴には読み取り可能なメッセージプレビューがありません。", + "resumeCommand": "再開コマンド", + "sessionActions": "{{value0}} 件のセッション操作", + "resumeInNewTab": "新しいタブで再開", + "resumeInWorktree": "ワークツリーで再開", + "viewLog": "ログを表示", + "copyResumeCommand": "再開コマンドをコピー", + "openLog": "ログを開く", + "revealLog": "ログを表示", + "openWorkingDirectory": "作業ディレクトリを開く", + "copySessionId": "セッション ID をコピー", + "copyLogPath": "ログパスをコピー", + "unknownTime": "不明な時間", + "unknown": "不明", + "user": "ユーザー", + "assistant": "アシスタント", + "tool": "ツール", + "system": "システム", + "log": "ログ", + "justNow": "たった今", + "minutesAgo": "{{value0}} 分前", + "hoursAgo": "{{value0}} 時間前", + "daysAgo": "{{value0}} 日前", + "monthsAgo": "{{value0}} か月前", + "yearsAgo": "{{value0}} 年前", "sessionId": "セッション ID", "originalAsk": "最初の依頼", "latestTurns": "最新のやり取り", @@ -9226,19 +9264,22 @@ "agentRole": "エージェント", "toolRole": "ツール", "systemRole": "システム", - "sessionRole": "セッション" + "sessionRole": "セッション", + "jumpToOriginalPane": "元のペインにジャンプ", + "worktree": "ワークツリー", + "jumpToWorktree": "ワークツリーにジャンプ" }, "AiVaultSessionRow": { - "resumeAgentSession": "Resume {{value0}} session", - "resumeInNewTab": "Resume in New Tab", - "copyResumeCommand": "Copy Resume Command", - "openLog": "Open Log", - "revealLog": "Reveal Log", - "openWorkingDirectory": "Open Working Directory", - "copySessionId": "Copy Session ID", - "copyLogPath": "Copy Log Path", - "messageCount": "{{value0}} msgs", - "tokenCount": "{{value0}} tok", + "resumeAgentSession": "{{value0}} セッションを再開", + "resumeInNewTab": "新しいタブで再開", + "copyResumeCommand": "再開コマンドをコピー", + "openLog": "ログを開く", + "revealLog": "ログを表示", + "openWorkingDirectory": "作業ディレクトリを開く", + "copySessionId": "セッション ID をコピー", + "copyLogPath": "ログパスをコピー", + "messageCount": "{{value0}} 件のメッセージ", + "tokenCount": "{{value0}} トークン", "toggleSessionDetails": "{{value0}} のセッション詳細", "hideDetails": "詳細を非表示", "showDetails": "詳細を表示", @@ -9250,7 +9291,9 @@ "agentRole": "エージェント", "toolRole": "ツール", "systemRole": "システム", - "sessionRole": "セッション" + "sessionRole": "セッション", + "jumpToOriginalPane": "元のペインにジャンプ", + "jumpToWorktree": "ワークツリーにジャンプ" }, "FileExplorerNameFilter": { "26fb73c6e3": "ファイルを検索", @@ -9280,20 +9323,31 @@ "pull": { "policy": { "notice": { - "merge": "Merge", - "mergeDescription": "Create a merge commit when local and remote both changed.", - "rebase": "Rebase", - "rebaseDescription": "Replay local commits on top of the remote branch.", - "fastForwardOnly": "Fast-forward only", - "fastForwardOnlyDescription": "Only pull when no merge or rebase is needed.", - "title": "Pull needs a policy", - "diverged": "Diverged", - "body": "This branch has local and remote commits. Run one command in this worktree or on the SSH host, then try Pull or Sync again.", - "copyAria": "Copy {{value0}} pull policy command", - "copied": "Copied", - "copyCommand": "Copy command" + "merge": "マージ (Merge)", + "mergeDescription": "ローカルとリモートの両方が変更された場合にマージコミットを作成します。", + "rebase": "リベース (Rebase)", + "rebaseDescription": "ローカルのコミットをリモートブランチの上に再適用します。", + "fastForwardOnly": "ファストフォワードのみ", + "fastForwardOnlyDescription": "マージやリベースが必要ない場合のみプルします。", + "title": "プルのポリシー設定が必要です", + "diverged": "分岐が分かれています", + "body": "このブランチにはローカルとリモートのコミットがあります。このワークツリーまたは SSH ホストでコマンドを 1 つ実行してから、プルまたは同期を再試行してください。", + "copyAria": "{{value0}} のプルポリシーコマンドをコピー", + "copied": "コピーしました", + "copyCommand": "コマンドをコピー" } } + }, + "AiVaultSessionWorktree": { + "currentWorktree": "現在のワークツリー", + "activeWorktree": "アクティブなワークツリー", + "archivedWorktree": "アーカイブされたワークツリー", + "unavailableWorktree": "利用不可なワークツリー", + "jumpToWorktree": "ワークツリーにジャンプ", + "noRecordedWorktree": "このセッションに記録されたワークツリーはありません。", + "archivedJumpUnavailable": "このセッションはアーカイブされたワークツリーにあります。", + "noActiveWorktreeMatch": "このセッションに一致するアクティブなワークツリーはありません。", + "noActiveWorktreeTarget": "利用可能なアクティブなワークツリーはありません。" } } }, @@ -9335,8 +9389,8 @@ "3c5a593bc8": "ウェブ", "477b28c948": "データベース", "787490e9bd": "パッケージ", - "07012dc113": "Agent", - "3eba7387ab": "Terminal", + "07012dc113": "エージェント", + "3eba7387ab": "ターミナル", "65b437c381": "コード", "bed2674f9d": "フォルダ" } @@ -9770,7 +9824,7 @@ "ea8ad0bae8": "の", "0a891e8935": "REST API", "953f7c6062": "この GitLab ホストはレート制限ヘッダーを返しませんでした。", - "budget_scope_prefix": "Budget scope" + "budget_scope_prefix": "予算スコープ" } } } @@ -9810,7 +9864,7 @@ "8b14ba6c17": "新規ブラウザタブ", "b085fb58b5": "このファイルには保存されていない変更が含まれています。", "5ddc688c52": "「{{value0}}」には未保存の変更があります。閉じる前に保存しますか?", - "25d7817f79": "terminal" + "25d7817f79": "ターミナル" }, "FloatingTerminalToggleButton": { "3b04b065b5": "フローティングワークスペースを表示", @@ -9853,7 +9907,7 @@ "be8917699e": "モデル", "4d9b6d84df": "サポートされていません。Claude、Codex、またはカスタムを選択します。", "560d4feb00": "カスタム", - "29d119fe95": "Agent", + "29d119fe95": "エージェント", "f9382b48a1": "AI 著者を有効にする", "1c0cb4fabb": "AI作者", "bd14e9c42a": "未設定", @@ -10467,7 +10521,9 @@ "8368d256ec": "結合分岐", "8f68ad9ca9": "{{value0}} AI {{value1}} を表示", "724a13568d": "{{value0}} に", - "6094135eec": "vs {{value0}}" + "6094135eec": "vs {{value0}}", + "a4420ca1f7": "Wrap On", + "dde325ddfe": "Wrap Off" }, "ConflictComponents": { "f338288514": "競合するコンテンツを読み込んでいます...", @@ -10533,7 +10589,7 @@ "3c6e71df22": "ブランチ比較では、このファイルのテキスト差分は使用できません。", "d07e4b8553": "ブランチ", "d16e037f40": "リッチ", - "6c4f1a8d2e": "Check details are unavailable." + "6c4f1a8d2e": "チェック詳細を利用できません。" }, "EditorPanelHeader": { "fb8331694e": "プレビューを横に開く", @@ -10555,7 +10611,8 @@ "3e0ce48c24": "PDFとしてエクスポート", "561251019a": "その他の操作", "8c8b7f5ff5": "前付を表示", - "10c39d58c1": "前付を隠す" + "10c39d58c1": "前付を隠す", + "1eef809708": "Word Wrap" }, "EditorPanelShell": { "e2c4dec350": "エディターを読み込み中..." @@ -10664,7 +10721,7 @@ "MonacoEditor": { "68cb83f4a7": "選択したテキストにメモを追加する", "fd68ae03b3": "ファイル内を検索", - "largePasteTooLarge": "Paste is too large." + "largePasteTooLarge": "貼り付け内容が大きすぎます。" }, "MonacoGutterContextMenu": { "7b57b1b468": "リモート URL をコピー", @@ -10902,32 +10959,32 @@ "CheckRunDetailsPanel": { "8f2d0f5a91": "通過", "4c8e1b2d73": "Failed", - "91a4c7e2b0": "Cancelled", - "2f6d8a1c45": "Timed out", - "7b3e9d4f12": "Skipped", - "5a1c8e3d67": "Neutral", + "91a4c7e2b0": "キャンセル済み", + "2f6d8a1c45": "タイムアウト", + "7b3e9d4f12": "スキップ済み", + "5a1c8e3d67": "中立", "3d9f2b8e14": "Pending", "b7f5e2c91a": "更新", - "a54ae21c6f": "Status:", - "fd46a70f1a": "Started", + "a54ae21c6f": "ステータス:", + "fd46a70f1a": "開始済み", "00e1c1658a": "Completed", "aa8494ae3c": "check #", "2dd5ddabc4": "workflow #", - "1f2b980522": "Loading check details…", - "d098e5529a": "Output", - "f2fe8a4e8f": "Annotations", + "1f2b980522": "チェック詳細を読み込み中…", + "d098e5529a": "出力", + "f2fe8a4e8f": "注釈", "cdbfda4dec": "Annotation", - "066fedd446": "Failed jobs", + "066fedd446": "失敗したジョブ", "49731703ea": "ジョブ", "ee07b33924": "unknown", - "07eccfa397": "No details are available for this check.", - "a916648574": "Open details", - "834cb3f23d": "Fix with AI", - "c8f1a2d4e7": "Choose the agent and edit the full command input before launch.", - "b3e7f9a1c2": "Check fix context unavailable", - "d5a8c2f1b9": "Start the default AI agent to fix this check", - "e2b4d7c8a1": "Choose an agent for this check", - "f1c9e3a6d4": "Choose agent to fix check" + "07eccfa397": "このチェックの詳細はありません。", + "a916648574": "詳細を開く", + "834cb3f23d": "AI で修正", + "c8f1a2d4e7": "起動前にエージェントを選択し、完全なコマンド入力を編集してください。", + "b3e7f9a1c2": "チェック修正コンテキストを利用できません", + "d5a8c2f1b9": "既定の AI エージェントでこのチェックを修正", + "e2b4d7c8a1": "このチェックのエージェントを選択", + "f1c9e3a6d4": "チェックを修正するエージェントを選択" }, "check": { "run": { @@ -10947,7 +11004,7 @@ } }, "richMarkdownLargeTextPaste": { - "tooLarge": "Paste is too large." + "tooLarge": "貼り付け内容が大きすぎます。" } }, "diff": { @@ -10965,7 +11022,7 @@ "2b3ce6d394": "キャンセル", "e05063cfc1": "{{value0}} 行目", "c845170b3b": "行 {{value0}}-{{value1}}", - "commentTooLarge": "Comment is too large to submit safely." + "commentTooLarge": "コメントが大きすぎるため安全に送信できません。" }, "useDiffCommentDecorator": { "995fa28b50": "このメモ" @@ -11094,8 +11151,8 @@ "palette": { "project": { "results": { - "repoGroup": "Repo group", - "project": "Project" + "repoGroup": "リポジトリグループ", + "project": "プロジェクト" } } } @@ -11211,7 +11268,7 @@ "5c3d530a68": "Downloaded", "4bb7424d6b": "Canceled", "6e776f9ef9": "Download failed", - "756bfc25c9": "Open", + "756bfc25c9": "開く", "09a9489aa5": "Show" }, "BrowserToolbarMenu": { @@ -11279,7 +11336,7 @@ "449fc83bf7": "トークン", "401f40ae79": "推定費用", "a7c312430d": "最後の実行", - "2df8970cd5": "Agent", + "2df8970cd5": "エージェント", "e353ab9516": "事前チェック", "620b22145e": "グレース", "15ea446b93": "セッション", @@ -11302,7 +11359,7 @@ }, "AutomationEditorDialog": { "fb1896a5e7": "キャンセル", - "57b722cbba": "Agent", + "57b722cbba": "エージェント", "6ff66f9012": "新規実行", "a2e688226d": "ワークツリー", "6f9610e667": "ワークツリーは選択したワークスペースで実行されます。新規実行では、選択したブランチから毎回新規ワークスペースが作成されます。", @@ -11578,7 +11635,7 @@ }, "AgentSettingsDialog": { "50cdb57c03": "AI agents を管理し、デフォルトを設定し、コマンドをカスタマイズします。", - "fc0268e4ed": "Agents" + "fc0268e4ed": "エージェント" } }, "activity": { @@ -11596,7 +11653,7 @@ "a472a14700": "その他のオプション", "db8a1878b5": "スレッドリストのオプション", "d1a88df9a8": "未読のスレッドのみを表示", - "f6396e1f85": "Agent", + "f6396e1f85": "エージェント", "b29191b3e0": "ワークツリー", "8c3b621ddf": "プロジェクト", "4a3986b200": "状態", @@ -11611,7 +11668,7 @@ }, "ActivityTitlebarControls": { "f915168c8e": "未読", - "d6a8de3934": "agents", + "d6a8de3934": "エージェント", "dc708f3eff": "agents を閉じる" } }, @@ -11783,7 +11840,7 @@ "starOnGithub": "Star on GitHub", "onboardingCompleted": "Onboarding completed!", "body": "If you’re enjoying Orca so far, a GitHub star helps other developers discover it.", - "dismiss": "Dismiss", + "dismiss": "閉じる", "later": "Later" } } diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 35323ea88fc..87f19f9ddde 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -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": { @@ -6274,17 +6296,23 @@ "ec2e33ad80": "라이트 디바이더 색상", "d56af60e6f": "Orca가 라이트 모드일 때 사용되는 테마를 선택합니다.", "8273bc75d7": "라이트 테마", - "74b15574c8": "선택적 라이트 모드 terminal 모양을 구성합니다.", - "b584287e84": "비활성화되면 라이트 모드는 어두운 terminal 테마를 재사용합니다.", - "d76f60c9cc": "라이트 모드에서 별도 테마 사용", "bc8e8a251a": "다크 모드 미리보기", "cbe56a0f79": "어두운 모드에서 창 사이의 분할 구분선을 제어합니다.", "b739d2abfe": "다크 디바이더 색상", "7add204bd5": "다크 모드에서 사용되는 terminal 테마를 선택하세요.", "9499ad1dc4": "어두운 테마", "f012172e21": "어두운 모드에서 terminal 패널에 사용되는 테마를 선택합니다.", - "import_themes_title": "테마 가져오기", - "import_themes_description": "가져온 테마는 다크 및 라이트 테마 선택기에서 모두 사용할 수 있습니다." + "catalog_title": "터미널 테마", + "catalog_description": "다크 및 라이트 모드의 터미널 테마와 구분선 색상을 선택합니다.", + "target_title": "테마 모드", + "target_label": "테마 모드", + "target_aria": "터미널 테마 모드", + "target_dark": "다크", + "target_light": "라이트", + "match_dark_mode": "다크 모드와 일치", + "match_dark_mode_description": "라이트 모드에서 다크 터미널 테마와 구분선 색상을 공유합니다.", + "light_preview_description": "적용 중인 라이트 터미널 모양을 표시합니다.", + "dark_preview_description": "적용 중인 다크 터미널 모양을 표시합니다." }, "TerminalWindowSection": { "1705318506": "ANSI 마젠타색", @@ -6390,7 +6418,7 @@ "f9a9cf6928": "음성 받아쓰기를 활성화하려면 마이크 권한이 필요합니다.", "1eac933202": "macOS 개인 정보 보호 및 보안을 열었습니다. 액세스 권한을 부여한 후 받아쓰기를 다시 활성화하세요.", "cd9fe37556": "마이크 권한이 부여되었습니다.", - "6fa734ed95": "Delete {{value0}}" + "6fa734ed95": "{{value0}} 삭제" }, "WorktreeSymlinksSection": { "1c1e35b219": "{{value0}} 제거", @@ -6516,7 +6544,7 @@ "cbdd7f3b9e": "설치된 agents가 이 장치에서 감지되는지 아니면 WSL에서 감지되는지 선택합니다.", "ef804b7337": "Agent 위치", "01926b9d8c": "AI 코딩 agents, 기본 agents 및 명령 재정의를 구성합니다.", - "bb9ad95777": "Agents", + "bb9ad95777": "에이전트", "d8f3a8b8a0": "기본값", "167daeb5e9": "명령", "be59907510": "재정의", @@ -6628,7 +6656,7 @@ "cf409b6c4d": "포트", "cb1cc62cf8": "스페이스", "90bdc043ea": "디스크", - "96b4fb0064": "terminal", + "96b4fb0064": "터미널", "4ddbde4999": "CPU", "4355f18ac6": "메모리", "9c4d5f0894": "관리자", @@ -6927,7 +6955,7 @@ "edc49480a1": "패널", "268e99d957": "강조 표시", "01567f19ca": "주목", - "9bb3bd5098": "terminal", + "9bb3bd5098": "터미널", "11877246fc": "terminal 벨 및 agent 완료 이벤트에 대한 지속적인 패널 강조 표시입니다.", "9e4ddf776d": "Terminal 주의", "fe5688b761": "사이드바", @@ -6935,7 +6963,7 @@ "d01b3882ba": "알림", "244a0ecd3d": "활동", "92a9357d1f": "agents 보기", - "fa72e71f05": "agents", + "fa72e71f05": "에이전트", "4d63251595": "agent 완료 및 차단 상태에 대한 스레드 왼쪽 사이드바 피드입니다.", "ccc5548ac5": "Agents 보기", "9af7a518db": "캐릭터", @@ -6949,11 +6977,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 +7008,7 @@ "156ffeee08": "메모", "884e5e6132": "markdown", "49db74a92d": "브라우저", - "6410fe83d8": "terminal", + "6410fe83d8": "터미널", "2b5efa55c9": "글로벌", "ebeedb2f6a": "빠른 terminal", "6f183fa1b9": "플로팅 terminal", @@ -7029,13 +7057,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 +7349,7 @@ "fadcbfdd99": "맞춤", "ad08035c5f": "핸드폰", "6cd2bfdb0e": "복원", - "b34ad5b3a7": "terminal", + "b34ad5b3a7": "터미널", "6db86f445f": "모바일", "707fc78052": "앱을 닫거나 다른 곳으로 전환한 후 모바일에서 보고 있던 terminals에 어떤 일이 발생하는지 선택하세요.", "1e711aca11": "모바일 앱을 나갈 때", @@ -7399,7 +7427,7 @@ "96562a72c6": "집중하는 동안 억제", "a2ab73b325": "주목", "ae0487f8fd": "벨", - "c638ae989d": "terminal", + "c638ae989d": "터미널", "d3f1c48677": "백그라운드 terminal이 벨 문자를 내보낼 때 이를 알립니다.", "a5edee1d99": "Terminal 벨", "193e1f107c": "작업", @@ -7429,7 +7457,7 @@ "eee028ae14": "디스패치", "9a5ebdca31": "메시징", "91fc8ab7e5": "코디네이션", - "13ba5c6cbd": "agents", + "13ba5c6cbd": "에이전트", "d86705ba77": "멀티 agent", "a7f76b4ca7": "오케스트레이션", "e05ff36753": "메시징, 작업 DAG, 디스패치 및 결정 게이트를 통해 여러 코딩 agents를 조정합니다.", @@ -7482,7 +7510,7 @@ "8bf43c2dad": "글로벌", "a26ecdb77b": "스니펫", "d07d130849": "단축키", - "0073cf8ce9": "terminal", + "0073cf8ce9": "터미널", "cfffa6cdb6": "명령", "fecb031823": "명령", "236d4cfac8": "빠른", @@ -7640,7 +7668,7 @@ "0f8cb15582": "agent", "f1adebbe8c": "셸", "7f1b38f59a": "TUI", - "7e3fc707aa": "terminal", + "7e3fc707aa": "터미널", "0ecba9aa5f": "키보드", "ebd7d81e1d": "단축키가 겹칠 때 Orca와 포커스된 terminal 중 어느 쪽이 우선할지 선택합니다.", "f052906167": "Terminal의 단축키" @@ -7761,7 +7789,7 @@ "d4daf4f612": "녹이다", "88561b3499": "정지됨", "0a05629060": "복구", - "f66a7cf715": "terminal", + "f66a7cf715": "터미널", "6892fb1019": "다시 시작", "cde233f5da": "스크롤백", "3982d88725": "기록", @@ -7819,8 +7847,8 @@ "1dee533bd9": "Orca가 라이트 모드일 때 사용되는 테마를 선택합니다.", "1d89457764": "라이트 테마", "da864e6cec": "라이트 모드", - "f268092ee3": "비활성화되면 라이트 모드는 어두운 terminal 테마를 재사용합니다.", - "232e532169": "라이트 모드에서 별도의 테마 사용", + "f268092ee3": "라이트 모드는 자체 터미널 테마를 사용할 수 있습니다.", + "match_dark_mode_title": "다크 모드와 일치", "f785374072": "다크", "9c32726f47": "어두운 모드에서 창 사이의 분할 구분선을 제어합니다.", "8987db7ff2": "다크 모드 구분선 색상", @@ -7882,11 +7910,12 @@ "0fe0073f0c": "새 창 및 라이브 업데이트의 기본 terminal 글꼴 크기입니다.", "5930244899": "글꼴 크기", "warp_import": { - "title": "Warp에서 테마 가져오기", + "title": "Warp에서 가져오기", "description": "Warp 테마를 Orca terminal 테마로 가져옵니다.", "keyword_warp": "warp", "keyword_themes": "themes", - "keyword_yaml": "yaml" + "keyword_yaml": "yaml", + "keyword_legacy_title": "Import themes from Warp" }, "yaml_import": { "title": "YAML에서 가져오기", @@ -7899,6 +7928,11 @@ "scrollSpeed": { "title": "스크롤 속도", "description": "일반 터미널 스크롤백, 수정 키 빠른 스크롤, 전체 화면 TUI 휠 속도를 조정합니다." + }, + "theme_target": { + "title": "테마 모드", + "keyword_target": "대상", + "keyword_editing": "편집" } }, "windows": { @@ -7907,7 +7941,7 @@ "fcfa53920b": "붙여넣기", "e55186fe2b": "오른쪽 클릭", "28ff08ed35": "창", - "e7d2793b03": "terminal", + "e7d2793b03": "터미널", "8ba875c132": "Windows에서는 마우스 오른쪽 버튼을 클릭하여 클립보드를 terminal에 붙여넣습니다. 컨텍스트 메뉴를 열려면 Ctrl+오른쪽 클릭을 사용하세요.", "f0b8448570": "붙여넣으려면 마우스 오른쪽 버튼을 클릭하세요.", "04994f6929": "기본값", @@ -8016,7 +8050,7 @@ } }, "WarpThemeImportModal": { - "title": "Warp에서 테마 가져오기", + "title": "Warp에서 가져오기", "description": "Warp 테마를 Orca terminal 테마로 가져옵니다.", "yaml_title": "테마 YAML 가져오기", "yaml_description": "YAML 파일(Warp 형식)을 Orca terminal 테마로 가져옵니다.", @@ -8775,7 +8809,7 @@ "fe119187bb": "--model sonnet", "bc8dc39f4b": "CLI 인수", "b99c33cec5": "설정", - "15c5d85706": "Agent", + "15c5d85706": "에이전트", "3e8f21954f": "오류", "74168d7ada": "idle", "1d47db9bf0": "활성화된 agents가 없습니다.", @@ -8806,7 +8840,7 @@ "4eab815004": "CLI 인수", "914c8f6ac2": "사용자 정의 명령", "cce2cbd01d": "agent 선택", - "9c14186dd2": "Agent" + "9c14186dd2": "에이전트" }, "activity": { "bar": { @@ -8900,7 +8934,7 @@ "b8c4e2a1f7": "전체 로그 보기", "f8a2c91d04": "Queue for agent", "b4e8a1c902": "Queued", - "7c1f0a2b11": "Open", + "7c1f0a2b11": "열기", "e8b4c1a903": "Resolved · {{value0}}", "c3a8e5d710": "Needs review · {{value0}}", "a7f0c7e8d1": "Queue", @@ -8971,7 +9005,7 @@ "6306b48afd": "소스 제어", "ef182dcb12": "검색", "fc3095d2ed": "탐색기", - "aiVaultSessionHistory": "Agents", + "aiVaultSessionHistory": "에이전트", "folderWorkspaces": "연결된 워크트리", "parentPrChecks": "PR 검사" }, @@ -8986,7 +9020,7 @@ "542bf6a7e2": "기울임", "256300f8ea": "굵게", "87aff03d63": "전송 중...", - "commentTooLarge": "Comment is too large to submit safely." + "commentTooLarge": "댓글이 너무 커서 안전하게 제출할 수 없습니다." } } } @@ -9149,8 +9183,10 @@ "noSessionsMatchFilters": "현재 필터와 일치하는 세션이 없습니다", "sessionId": "세션 ID", "logPath": "로그 경로", - "agents": "Agents", - "sessionsShownCompact": "{{value0}} 표시됨" + "agents": "에이전트", + "sessionsShownCompact": "{{value0}} 표시됨", + "originalPaneUnavailable": "원래 창은 더 이상 사용할 수 없습니다.", + "worktreeUnavailable": "워크트리는 더 이상 사용할 수 없습니다." }, "AiVaultPanelControls": { "scanningSessions": "세션 스캔 중", @@ -9165,13 +9201,13 @@ "allSessions": "모든 세션", "viewOptionsAriaLabel": "세션 기록 보기 옵션", "viewOptions": "보기 옵션", - "agents": "Agents", + "agents": "에이전트", "sort": "정렬", "lastUpdated": "마지막 업데이트", "created": "생성됨", "group": "그룹", "folder": "폴더", - "agent": "Agent", + "agent": "에이전트", "resetView": "보기 재설정", "hideEmptySessions": "빈 세션 숨기기", "workspaceScope": "워크스페이스", @@ -9198,6 +9234,8 @@ "resumeCommand": "재개 명령", "sessionActions": "{{value0}} 세션 작업", "resumeInNewTab": "새 탭에서 재개", + "resumeInWorktree": "워크트리에서 재개", + "viewLog": "로그 보기", "copyResumeCommand": "재개 명령 복사", "openLog": "로그 열기", "revealLog": "로그 위치 열기", @@ -9205,11 +9243,11 @@ "copySessionId": "세션 ID 복사", "copyLogPath": "로그 경로 복사", "unknownTime": "알 수 없는 시간", - "unknown": "Unknown", - "user": "User", - "assistant": "Assistant", - "tool": "Tool", - "system": "System", + "unknown": "알 수 없음", + "user": "사용자", + "assistant": "어시스턴트", + "tool": "도구", + "system": "시스템", "log": "로그", "justNow": "방금 전", "minutesAgo": "{{value0}}분 전", @@ -9226,7 +9264,10 @@ "agentRole": "에이전트", "toolRole": "도구", "systemRole": "시스템", - "sessionRole": "세션" + "sessionRole": "세션", + "jumpToOriginalPane": "원래 창으로 이동", + "worktree": "작업 트리", + "jumpToWorktree": "작업 트리로 이동" }, "AiVaultSessionRow": { "resumeAgentSession": "{{value0}} 세션 재개", @@ -9250,7 +9291,9 @@ "agentRole": "에이전트", "toolRole": "도구", "systemRole": "시스템", - "sessionRole": "세션" + "sessionRole": "세션", + "jumpToOriginalPane": "원래 창으로 이동", + "jumpToWorktree": "작업 트리로 이동" }, "FileExplorerNameFilter": { "26fb73c6e3": "파일 찾기", @@ -9290,10 +9333,21 @@ "diverged": "Diverged", "body": "이 브랜치에는 로컬 commits과 원격 commits이 모두 있습니다. 이 워크트리 또는 SSH 호스트에서 명령 하나를 실행한 다음 풀 또는 동기화를 다시 시도하세요.", "copyAria": "{{value0}} pull 정책 명령 복사", - "copied": "Copied", + "copied": "복사됨", "copyCommand": "명령 복사" } } + }, + "AiVaultSessionWorktree": { + "currentWorktree": "현재 작업 트리", + "activeWorktree": "활성 작업 트리", + "archivedWorktree": "보관된 작업 트리", + "unavailableWorktree": "사용할 수 없는 작업 트리", + "jumpToWorktree": "작업 트리로 이동", + "noRecordedWorktree": "이 세션에 기록된 작업 트리가 없습니다.", + "archivedJumpUnavailable": "이 세션은 보관된 작업 트리에 있습니다.", + "noActiveWorktreeMatch": "이 세션과 일치하는 활성 작업 트리가 없습니다.", + "noActiveWorktreeTarget": "사용 가능한 활성 작업 트리가 없습니다." } } }, @@ -9335,8 +9389,8 @@ "3c5a593bc8": "웹", "477b28c948": "데이터베이스", "787490e9bd": "패키지", - "07012dc113": "Agent", - "3eba7387ab": "Terminal", + "07012dc113": "에이전트", + "3eba7387ab": "터미널", "65b437c381": "코드", "bed2674f9d": "폴더" } @@ -9810,7 +9864,7 @@ "8b14ba6c17": "새 브라우저 탭", "b085fb58b5": "이 파일에는 저장되지 않은 변경사항이 있습니다.", "5ddc688c52": "'{{value0}}'에 저장되지 않은 변경사항이 있습니다. 닫기 전에 저장하시겠습니까?", - "25d7817f79": "terminal" + "25d7817f79": "터미널" }, "FloatingTerminalToggleButton": { "3b04b065b5": "플로팅 워크스페이스 표시", @@ -9853,7 +9907,7 @@ "be8917699e": "모델", "4d9b6d84df": "지원되지 않습니다. Claude, Codex 또는 Custom을 선택하세요.", "560d4feb00": "Custom", - "29d119fe95": "Agent", + "29d119fe95": "에이전트", "f9382b48a1": "AI author 활성화", "1c0cb4fabb": "AI 작성자", "bd14e9c42a": "구성되지 않음", @@ -10225,7 +10279,7 @@ "step": { "0f47ff17c6": "변경", "5538eb6743": "완료", - "open_step": "Open", + "open_step": "열기", "close_step": "닫기" } } @@ -10467,7 +10521,9 @@ "8368d256ec": "결합 브랜치", "8f68ad9ca9": "{{value0}} AI {{value1}} 표시", "724a13568d": "{{value0}}에서", - "6094135eec": "대 {{value0}}" + "6094135eec": "대 {{value0}}", + "a4420ca1f7": "Wrap On", + "dde325ddfe": "Wrap Off" }, "ConflictComponents": { "f338288514": "충돌 콘텐츠 로드 중...", @@ -10555,7 +10611,8 @@ "3e0ce48c24": "PDF로 내보내기", "561251019a": "추가 작업", "8c8b7f5ff5": "머리말 표시", - "10c39d58c1": "머리말 숨기기" + "10c39d58c1": "머리말 숨기기", + "1eef809708": "Word Wrap" }, "EditorPanelShell": { "e2c4dec350": "편집기 로드 중..." @@ -10664,7 +10721,7 @@ "MonacoEditor": { "68cb83f4a7": "선택한 텍스트에 메모 추가", "fd68ae03b3": "파일에서 검색", - "largePasteTooLarge": "Paste is too large." + "largePasteTooLarge": "붙여넣기 내용이 너무 큽니다." }, "MonacoGutterContextMenu": { "7b57b1b468": "원격 URL 복사", @@ -10947,7 +11004,7 @@ } }, "richMarkdownLargeTextPaste": { - "tooLarge": "Paste is too large." + "tooLarge": "붙여넣기 내용이 너무 큽니다." } }, "diff": { @@ -10965,7 +11022,7 @@ "2b3ce6d394": "취소", "e05063cfc1": "라인 {{value0}}", "c845170b3b": "라인 {{value0}}-{{value1}}", - "commentTooLarge": "Comment is too large to submit safely." + "commentTooLarge": "댓글이 너무 커서 안전하게 제출할 수 없습니다." }, "useDiffCommentDecorator": { "995fa28b50": "이 메모" @@ -11094,8 +11151,8 @@ "palette": { "project": { "results": { - "repoGroup": "Repo group", - "project": "Project" + "repoGroup": "리포지토리 그룹", + "project": "프로젝트" } } } @@ -11211,7 +11268,7 @@ "5c3d530a68": "Downloaded", "4bb7424d6b": "Canceled", "6e776f9ef9": "Download failed", - "756bfc25c9": "Open", + "756bfc25c9": "열기", "09a9489aa5": "Show" }, "BrowserToolbarMenu": { @@ -11279,7 +11336,7 @@ "449fc83bf7": "토큰", "401f40ae79": "예상 경비", "a7c312430d": "마지막 실행", - "2df8970cd5": "Agent", + "2df8970cd5": "에이전트", "e353ab9516": "사전 확인", "620b22145e": "유예 시간", "15ea446b93": "세션", @@ -11302,7 +11359,7 @@ }, "AutomationEditorDialog": { "fb1896a5e7": "취소", - "57b722cbba": "Agent", + "57b722cbba": "에이전트", "6ff66f9012": "새로운 실행", "a2e688226d": "워크트리", "6f9610e667": "작업 트리는 선택한 워크스페이스에서 실행됩니다. 새로 실행하면 매번 선택한 브랜치에서 새 워크스페이스가 생성됩니다.", @@ -11578,7 +11635,7 @@ }, "AgentSettingsDialog": { "50cdb57c03": "AI agents를 관리하고, 기본값을 설정하고, 명령을 사용자 정의하세요.", - "fc0268e4ed": "Agents" + "fc0268e4ed": "에이전트" } }, "activity": { @@ -11596,7 +11653,7 @@ "a472a14700": "추가 옵션", "db8a1878b5": "스레드 목록 옵션", "d1a88df9a8": "읽지 않은 스레드만 표시", - "f6396e1f85": "Agent", + "f6396e1f85": "에이전트", "b29191b3e0": "워크트리", "8c3b621ddf": "프로젝트", "4a3986b200": "상태", @@ -11611,7 +11668,7 @@ }, "ActivityTitlebarControls": { "f915168c8e": "읽지 않음", - "d6a8de3934": "agents", + "d6a8de3934": "에이전트", "dc708f3eff": "agents 닫기" } }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 8a95f561d38..f4ed41d6e82 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -20,10 +20,10 @@ "spanish": "Español" }, "statusBar": { - "claudeToggleDescription": "显示 Claude Token 和成本使用情况。", - "codexToggleDescription": "显示 Codex Token 和成本使用情况。", - "geminiToggleDescription": "显示Gemini Token 和成本使用情况。", - "opencodeGoToggleDescription": "显示OpenCode Go Token 和成本使用情况。", + "claudeToggleDescription": "显示 Claude 令牌和成本使用情况。", + "codexToggleDescription": "显示 Codex 令牌和成本使用情况。", + "geminiToggleDescription": "显示 Gemini 令牌和成本使用情况。", + "opencodeGoToggleDescription": "显示 OpenCode Go 令牌和成本使用情况。", "kimiToggleDescription": "显示当前工作区的 Kimi 订阅使用情况。", "sshToggleDescription": "当有可用的 SSH 和远程 Orca 主机时显示它们。", "resourceUsageToggleDescription": "显示资源管理器。点击可查看 CPU、内存、会话、守护进程控制和工作区磁盘扫描。", @@ -87,7 +87,7 @@ "ed6b168d00": "右侧边栏出现错误。", "03a14f6b5b": "重试此页面,或导航到其他 Orca 界面。", "b7a714db1e": "此页面出现错误。", - "98d4ea2823": "此工作区中的 Terminal、浏览器或编辑器渲染失败。请重试以重新挂载。", + "98d4ea2823": "此工作区中的终端、浏览器或编辑器渲染失败。请重试以重新挂载。", "5a9519aef0": "工作区工作台遇到错误。", "cba0fafda5": "活动页面保持打开状态。重试列表或切换视图。", "1468601e7b": "工作区列表出现错误。", @@ -107,7 +107,7 @@ "c9d6f98459": "最大化", "66f0a552e5": "恢复", "bbb7f90669": "最小化", - "d54e66004c": "terminal", + "d54e66004c": "终端", "9f0152563e": "移动端", "62ca9895a7": "空间", "844eb0f4f4": "活动", @@ -177,11 +177,11 @@ }, "github": { "f129c42773": "GitHub 没有回复新评论。", - "683a21264b": "行没有所有者/repo/编号。", + "683a21264b": "行没有所有者/仓库/编号。", "83f9b126ad": "议题类型只能在议题上设置。", "f963485d37": "未找到行", "a967f23983": "项目视图未加载", - "87020f6605": "行没有所有者/repo/编号 - 无法修补底层项目", + "87020f6605": "行没有所有者/仓库/编号 - 无法修补底层项目", "d49ef4b944": "无法保存议题源首选项" }, "sparse": { @@ -198,7 +198,7 @@ "store": { "test": { "helpers": { - "b9a8117c33": "Terminal 1" + "b9a8117c33": "终端 1" } } }, @@ -300,7 +300,7 @@ "2db0bd7515": "Orca CLI 注册不可用", "8d6eedf97e": "无法在 PATH 中注册 Orca CLI。", "0f116999f1": "在安装之前重新启动 shell 或将 Orca CLI 目录添加到 PATH。", - "15cbedc3e3": "在运行 Agent 技能设置之前安装 Orca CLI。" + "15cbedc3e3": "在运行智能体技能设置之前安装 Orca CLI。" } } } @@ -317,13 +317,13 @@ "agent": { "launch": { "027228a06b": "无法找到用于这些检查的工作区。", - "fb6c294e85": "无法构建 Agent 启动命令。", + "fb6c294e85": "无法构建智能体启动命令。", "03c1d61f83": "无法打开附加到这些检查的工作区。", "822bf52295": "无法解析工作区启动平台。", "dfb4dd7c00": "无法找到附加到这些检查的工作区。", "9f00d7df0c": "检查提示为空。请更新源代码管理 AI 设置。", - "2ebf794906": "此工作区主机上未检测到已启用的 AI agent。", - "4c7f783a7a": "保存的检查 Agent 在此工作区主机上不可用。" + "2ebf794906": "此工作区主机上未检测到已启用的 AI 智能体。", + "4c7f783a7a": "保存的检查智能体在此工作区主机上不可用。" } } } @@ -342,8 +342,8 @@ "in": { "new": { "tab": { - "11cce5cc77": "无法在新 terminal 中启动 {{value0}}。", - "a5a1f7033f": "您的 {{value0}} 未发送 - Agent 准备好后将其粘贴。" + "11cce5cc77": "无法在新终端中启动 {{value0}}。", + "a5a1f7033f": "您的 {{value0}} 未发送 - 智能体准备好后将其粘贴。" } } }, @@ -356,12 +356,12 @@ "work": { "item": { "direct": { - "3de6371df3": "无法构建 Agent 启动命令。", + "3de6371df3": "无法构建智能体启动命令。", "67e103dd60": "工作区已创建但无法激活。", - "19c7683acf": "所选 Agent 在创建的工作区中不可用。", + "19c7683acf": "所选智能体在创建的工作区中不可用。", "8bc45efdbc": "无法解析 PR 头引用。", "agent": { - "ceeeb509b5": "Agent 启动时间太长。工作区已准备就绪 - 当 Agent 空闲时粘贴 {{value0}}。" + "ceeeb509b5": "智能体启动时间太长。工作区已准备就绪 - 当智能体空闲时粘贴 {{value0}}。" } } } @@ -416,7 +416,7 @@ "sleeping": { "agent": { "session": { - "f235f604fd": "无法恢复此 Agent 会话。" + "f235f604fd": "无法恢复此智能体会话。" } } } @@ -426,11 +426,11 @@ "agent": { "action": { "plan": { - "3f0ea9aa0d": "无法构建 Agent 启动命令。", + "3f0ea9aa0d": "无法构建智能体启动命令。", "46f1a2c9bd": "命令输入为空。", - "8eb541cc83": "此工作区主机上未检测到所选 agent。", - "b96e091fc9": "选定的 Agent 在“设置”中被禁用。", - "a7ac8717c7": "在开始之前选择一个 Agent。" + "8eb541cc83": "此工作区主机上未检测到所选智能体。", + "b96e091fc9": "选定的智能体在“设置”中被禁用。", + "a7ac8717c7": "在开始之前选择一个智能体。" } } }, @@ -444,7 +444,7 @@ "sparse": { "preset": { "draft": { - "5915a0a1f6": "使用 repo 相对目录,而不是根目录、绝对路径或父段。", + "5915a0a1f6": "使用仓库相对目录,而不是根目录、绝对路径或父段。", "efc05d1820": "添加至少一个目录。" } } @@ -454,7 +454,7 @@ "capture": { "notification": { "b0536028c9": "打开快捷键", - "141ad6c004": "处理 Terminal 快捷方式", + "141ad6c004": "处理终端快捷方式", "0ab0cd001a": "大小 4 文本静音前景" } } @@ -524,7 +524,7 @@ "3ad7d77f57": "目标工作区所在的主机与此自动化运行目标不同。" }, "useComposerState": { - "7eb3f44ff7": "所选 Agent 已禁用。创建之前选择启用的 Agent。", + "7eb3f44ff7": "所选智能体已禁用。创建之前选择启用的智能体。", "b2ead86962": "无法解析 PR 基础引用。", "a9ff236145": "部分附件无法上传。", "3db83fc58a": "没有可用于附件的远程项目路径。", @@ -551,8 +551,8 @@ "f000b2ff76": "没有活动的工作树", "56d3ec4203": "无法创建无标题 Markdown 文件。", "f6300deb8b": "新浏览器选项卡", - "7a64b31991": "当远程运行时处于活动状态时,本地 terminal 创建不可用", - "60428567b4": "当远程运行时处于活动状态时,本地 terminal 显示不可用", + "7a64b31991": "当远程运行时处于活动状态时,本地终端创建不可用", + "60428567b4": "当远程运行时处于活动状态时,本地终端显示不可用", "f8aaf2bde3": "工作区已上传", "2fe88c2e06": "远程工作区同步不可用", "2ec42e1c52": "还没有远程工作区", @@ -566,11 +566,11 @@ "580a04cd81": "高级", "8400cfe1c1": "匿名使用数据和遥测控制。", "3618579df6": "隐私与遥测", - "65ec7d1968": "terminal 启动的开发者工具的 macOS 隐私访问。", + "65ec7d1968": "终端启动的开发者工具的 macOS 隐私访问。", "d91ae31fbd": "macOS 权限", - "95a1886d94": "通过手机控制 terminal 和 Agent。", + "95a1886d94": "通过手机控制终端和智能体。", "1cd25673df": "移动端", - "31e57d1c70": "通过 SSH 使用已有机器处理文件、terminals、Git 和工作区。", + "31e57d1c70": "通过 SSH 使用已有机器处理文件、终端、Git 和工作区。", "94a5afe910": "SSH 主机", "40d80bad8a": "测试版", "de0c2907a1": "远程 Orca 服务器", @@ -578,22 +578,22 @@ "d72a58b5b9": "统计和使用情况", "dcd0d9b74f": "常见操作的键盘快捷键。", "94295ebfb3": "快捷键", - "7682607591": "针对 Agent 和 terminal 事件的原生桌面通知。", + "7682607591": "针对智能体和终端事件的原生桌面通知。", "2eece16ad1": "通知", "1f452cbd4c": "选择和编辑行为。", "0c6ee88a5f": "输入与编辑", - "b11a5a48a2": "主题、缩放、应用程序和 terminal 外观、侧边栏和状态栏。", + "b11a5a48a2": "主题、缩放、应用程序和终端外观、侧边栏和状态栏。", "93d88d20bf": "外观", - "2d0659f6f0": "全局 terminal、浏览器和 Markdown 选项卡。", + "2d0659f6f0": "全局终端、浏览器和 Markdown 选项卡。", "65b19f5bde": "浮动工作区", - "3d65d3f1b9": "配置对 Orca 和编码 Agent 的手机模拟器支持。", + "3d65d3f1b9": "配置对 Orca 和编码智能体的手机模拟器支持。", "1e761cff2b": "手机模拟器", "e815fd01bd": "主页、链接路由和会话 cookie。", "8c197f74a1": "浏览器", - "42ae40842f": "保存的 terminal 命令,范围全局或每个项目。", + "42ae40842f": "保存的终端命令,范围全局或每个项目。", "3fc3db144f": "快捷命令", - "c33bfd664c": "Shell、渲染器、会话和 terminal 行为。", - "a9fb10afca": "Terminal", + "c33bfd664c": "Shell、渲染器、会话和终端行为。", + "a9fb10afca": "终端", "5235c215ca": "选择在“任务”页面和侧栏中显示的任务提供者。", "85f4fd7710": "任务来源", "ab4b21b58e": "分支命名、基础引用、归因和 Git AI Author。", @@ -610,15 +610,15 @@ "5f32ac08f3": "完成 Orca 核心工作流程的入门清单。", "8ac3de82f5": "使用设备端模型进行本地语音转文字听写。", "6a50cdcd7c": "语音", - "0059bd17f3": "使 Agent 能够控制您计算机上的任何应用程序。", + "0059bd17f3": "使智能体能够控制您计算机上的任何应用程序。", "b35e92364b": "计算机控制", - "cd50cec5d7": "通过 Orca 协调多个编码 Agent。", + "cd50cec5d7": "通过 Orca 协调多个编码智能体。", "58a868e8e4": "编排", "7c79d3b7bf": "可选", "b1c2f8b0ac": "Claude、Codex、Gemini 和 OpenCode Go 的可选账户切换。", "f70ac54d38": "AI 提供商账户", - "4121f7a0a2": "管理 AI Agent、设置默认值并自定义命令。", - "b49abbd2f7": "Agents" + "4121f7a0a2": "管理 AI 智能体、设置默认值并自定义命令。", + "b49abbd2f7": "智能体" }, "useAppMenuPaste": { "pasteTooLarge": "粘贴内容过大。" @@ -642,7 +642,7 @@ "94cc673726": "知道了", "fc5cc29955": "退出", "d1deebb050": "隐私政策", - "958d2cc31b": "您使用的功能的匿名计数有助于我们确定要构建的内容的优先级。没有文件内容、提示、terminal 输出或任何可以识别您身份的内容。随时在“设置”->“隐私和遥测”中进行更改。", + "958d2cc31b": "您使用的功能的匿名计数有助于我们确定要构建的内容的优先级。没有文件内容、提示、终端输出或任何可以识别您身份的内容。随时在“设置”->“隐私和遥测”中进行更改。", "9784b4d7bc": "帮助我们决定下一步要构建什么", "fcbee32f08": "遥测通知" }, @@ -706,10 +706,10 @@ "71c11aff84": "重新运行所有检查", "e31651a224": "重新运行失败的检查", "1b56e28faa": "重新运行", - "f4b1292569": "在这些检查中启动默认的 AI Agent", + "f4b1292569": "在这些检查中启动默认的 AI 智能体", "9a1004fc76": "刷新检查", - "03e542fcfe": "无法为失败的检查启动 AI Agent:{{value0}}", - "28986b3747": "已启动 AI Agent 处理失败的检查。", + "03e542fcfe": "无法为失败的检查启动 AI 智能体:{{value0}}", + "28986b3747": "已启动 AI 智能体处理失败的检查。", "1690fd7f4a": "没有需要修复的失败检查。", "9e7c221b8d": "无法重新运行检查", "e463ec935f": "检查请求重新运行", @@ -779,7 +779,7 @@ "73487fb975": "移除评审人失败", "2e69540652": "已移除评审人", "69515bff81": "已移除评审人", - "b4af16bf43": "没有可用于此PR的 repo 上下文。", + "b4af16bf43": "没有可用于此PR的仓库上下文。", "c42d942b75": "请求评审人失败", "c016e4bac3": "已请求评审", "ea985e657f": "已请求评审", @@ -787,12 +787,12 @@ "94ab23a9f9": "输入评审人", "3853476a97": "GitHub 项目", "68796dafa0": "PR", - "b1157c78ff": "sheet", + "b1157c78ff": "面板", "038b3d39b1": "已复制", "04539beb48": "议题", "773ff70035": "未知", "3e544d966d": "议题", - "88fd82474d": "page", + "88fd82474d": "页面", "e517b4d641": "关闭", "d0a05e73f5": "紧凑", "7d42606f66": "批注", @@ -803,7 +803,7 @@ "5752c25aff": "发布…", "ec5c4b3ab2": "重新打开 PR", "21860b58d0": "关闭PR", - "5932578f51": "合并需要注册本地 repo", + "5932578f51": "合并需要注册本地仓库", "ce8a85d209": "默认", "924c2fe05e": "destructive", "e2bf3e41a9": "评论", @@ -822,7 +822,23 @@ "ba8e329d92": "取消标记已查看", "3f79ffc8b7": "打开 PR 详情以查看当前评审人。", "5c1c973855": "移除评审人", - "commentTooLarge": "评论过长,无法安全提交。" + "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": "重新打开", @@ -932,7 +948,7 @@ "520304a067": "Orca 标志", "ce44fad849": "缺少依赖项", "c1cf168479": "隐藏", - "00cee697c1": "在 terminal 中运行“gh auth login”以连接您的 GitHub 账户。", + "00cee697c1": "在终端中运行“gh auth login”以连接您的 GitHub 账户。", "9f96d018b7": "GitHub CLI 未经过身份验证", "73e1ad4282": "Orca 使用 GitHub CLI (gh) 来显示PR、议题和检查。", "5beaef5f9e": "GitHub CLI 未安装", @@ -1008,9 +1024,9 @@ "61f424f8ca": "Linear 议题", "ca8778c124": "未知", "8a33c85e9c": "有人", - "f5a6b38a14": "sheet", + "f5a6b38a14": "面板", "65239a714b": "Linear", - "af6e02c44a": "page", + "af6e02c44a": "页面", "76ffd3c937": "搜索要添加的项目。", "c11b4e3cc2": "没有找到项目。", "519c3587f3": "添加到项目" @@ -1049,29 +1065,29 @@ }, "NewWorkspaceComposerCard": { "cbb47ee0dc": "仅适用于本地 Git 项目。", - "d861de981b": "结账稀疏", - "090cfedeb4": "写一个注释", - "f8728aa4f9": "笔记", + "d861de981b": "Sparse checkout", + "090cfedeb4": "写备注", + "f8728aa4f9": "备注", "0ee17638fe": "工作区名称", - "2688050e4b": "姓名", + "2688050e4b": "名称", "f0470c7383": "高级", - "ba64270bdb": "配置 Agent", - "ab63f25397": "打开 Agent 设置", - "01d1e8f601": "Agent", + "ba64270bdb": "配置智能体", + "ab63f25397": "打开智能体设置", + "01d1e8f601": "智能体", "0c5d6a479c": "[可选]", "b5a0796911": "连接", "dccd26d4e4": "选择项目", "d6b0a96f32": "添加项目", "969a8bff66": "项目", - "23bb365554": "Orca.yaml", + "23bb365554": "orca.yaml", "a239038146": "SSH 连接错误", - "9a70e4859e": "选择是否在创建此工作区之前运行安装程序。", + "9a70e4859e": "选择是否在创建此工作区前运行设置。", "803b7fe72f": "正在检查设置配置...", "92e34f0311": "本地设置", "326a578923": "orca.yaml + 本地", - "2132b670da": "两个都", + "2132b670da": "两者", "0e587e31fb": "yaml", - "ac3748dcda": "名称或“创建自”", + "ac3748dcda": "名称或创建来源", "f660aa1454": "连接中", "7711ad5122": "本地设置命令", "e5db1b0419": "组合设置命令", @@ -1096,18 +1112,20 @@ "cloneHostSetup": "克隆", "importExistingFolderOnHost": "导入现有文件夹", "setupHostExistingFolderPlaceholder": "/path/to/project/on/host", - "setupKindGit": "Git repo", + "setupKindGit": "Git 仓库", "setupKindFolder": "文件夹", "setupHostExistingFolderHelp": "链接已存在的检出目录,然后在该主机上创建此工作区。", "importingHostSetup": "导入中...", "importHostSetup": "导入", "notePasteTooLarge": "笔记字段的粘贴内容过大。", "reuseExistingBranch": "复用分支", - "reuseExistingBranchHint": "检出已有分支,而不是基于它创建新分支。", + "reuseExistingBranchHint": "检出已有分支,而不是从它创建新分支。", "createMultiple": "创建更多" }, "NewWorkspaceComposerModal": { - "fa90f739a5": "创建工作区之前选择项目、工作区名称和 Agent。" + "createWorktree": "创建工作树", + "createWorkspace": "创建工作区", + "fa90f739a5": "创建工作区之前选择项目、工作区名称和智能体。" }, "PullRequestPage": { "2560588245": "请求评审人失败", @@ -1147,8 +1165,8 @@ "a18d01cda3": "尚未报告任何检查", "3912daf310": "此PR尚未报告检查。", "45877f5089": "未找到检查", - "85e62c5266": "已启动 AI Agent 处理失败的检查。", - "ddfd42f460": "选择 Agent 并在启动前编辑完整的命令输入。", + "85e62c5266": "已启动 AI 智能体处理失败的检查。", + "ddfd42f460": "选择智能体并在启动前编辑完整的命令输入。", "a053bdd082": "用 AI 修复失败的检查", "1b14d0a69c": "在 GitHub 中打开", "1550675e5f": "此检查没有可用的内联输出。", @@ -1162,9 +1180,9 @@ "54cddd1858": "重新运行所有检查", "68605516dd": "重新运行失败的检查", "522d9353e1": "重新运行", - "0fa8b8faec": "在这些检查中启动默认的 AI Agent", + "0fa8b8faec": "在这些检查中启动默认的 AI 智能体", "5d0f42766d": "刷新检查", - "98583589c6": "无法为失败的检查启动 AI Agent:{{value0}}", + "98583589c6": "无法为失败的检查启动 AI 智能体:{{value0}}", "51c65c0265": "没有需要修复的失败检查。", "788a782bb0": "无法重新运行检查", "18f2af42ac": "检查请求重新运行", @@ -1235,7 +1253,7 @@ "c798fa0ec7": "移除评审人失败", "1e6d089420": "已移除评审人", "2c1d93da43": "已移除评审人", - "1ae11c905c": "没有可用于此PR的 repo 上下文。", + "1ae11c905c": "没有可用于此PR的仓库上下文。", "102d3d177f": "已请求评审", "03282ff3b9": "已请求评审", "8f369a6b6b": "最多可请求 15 名评审人", @@ -1245,7 +1263,7 @@ "3b6886b2ee": "已复制", "b6bda618cf": "紧凑", "35a0573f41": "批注", - "61a8c69a33": "page", + "61a8c69a33": "页面", "a4541fd3db": "修复失败的检查", "c808db1dd1": "修复检查", "c4c02ea23e": "无法自动创建修复工作区。", @@ -1254,7 +1272,7 @@ "9d5425918e": "重新打开 PR", "96d013ed28": "关闭PR", "d65f70786e": "关闭", - "eca289e593": "合并需要注册本地 repo", + "eca289e593": "合并需要注册本地仓库", "6568ae8ece": "默认", "19f19560d5": "destructive", "aae99c6c04": "PR", @@ -1275,9 +1293,9 @@ "56ec6eafb7": "打开 PR 详情以查看当前评审人。", "7f964a365a": "移除评审人", "commentTooLarge": "评论过长,无法安全提交。", - "8ff5ae8866": "Assignees", - "82c87eceb9": "Edit assignees", - "1ff5d979df": "No one assigned" + "8ff5ae8866": "负责人", + "82c87eceb9": "编辑负责人", + "1ff5d979df": "未分配任何人" }, "QuickOpen": { "1dbd3f59ff": "手机", @@ -1305,7 +1323,7 @@ "StarNagCard": { "92b0f9d921": "已通过身份验证并重试。", "cd8c34aac1": "gh", - "cf82170065": "无法为该 repo 加注星标。确保", + "cf82170065": "无法为该仓库加注星标。确保", "30c36231c1": "Orca 是开源项目。如果它今天帮到了你,GitHub 星标会帮助其他开发者找到它。", "b5e685e4d9": "关闭", "5f6df21046": "喜欢 Orca 吗?", @@ -1512,7 +1530,7 @@ "969e26577c": "最多可请求 15 名评审人", "d00571d9b1": "输入评审人", "edf4bc4135": "没有可分配的用户。", - "53e002d895": "议题没有 repo slug。", + "53e002d895": "议题没有仓库 slug。", "7f94eb6395": "分配议题", "bb63046423": "分配给 {{value0}}", "ca63694b4c": "无法更新负责人。", @@ -1607,30 +1625,30 @@ "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": "关闭", "f82e9f02df": "取消", - "7958465754": "有正在运行的进程的本地 terminals。还是关窗吧?", + "7958465754": "有正在运行的进程的本地终端。还是关窗吧?", "2fa9c69ff3": "关闭窗口?", "cd51e28d8b": "保存", "0037b21794": "不保存", @@ -1642,8 +1660,8 @@ "46e08bc5c8": "该文件有未保存的更改。", "61ed600d29": "“{{value0}}”有未保存的更改。您想在关闭前保存吗?", "cdc9ac4b2d": "编辑", - "e57db40c11": "Could not build launch command for {{value0}}.", - "5b2c1a9e44": "No agent CLI detected — install one or pick a default agent in Settings." + "e57db40c11": "无法为 {{value0}} 构建启动命令。", + "5b2c1a9e44": "未检测到智能体 CLI,请安装一个,或在设置中选择默认智能体。" }, "TerminalSearch": { "db234b7519": "关闭", @@ -1673,7 +1691,7 @@ "9abc59f814": "可用更新", "aad383aecc": "阅读完整的发行说明", "ccd8b0a793": "自您上次更新以来还有更多内容", - "b1d867f4fb": "更新期间您的 terminal 会话不会中断。", + "b1d867f4fb": "更新期间您的终端会话不会中断。", "09a55c39b5": "正在安装...", "ea2a41adbe": "您使用的是最新版本。", "ba5ffc949c": "正在检查更新...", @@ -1727,9 +1745,9 @@ "actionBadge": "操作", "paletteHostBadge": "主机:{{value0}}", "workspaceTabMissing": "标签页不再存在", - "projectsGroupsHeader": "Projects & Groups", - "projectBadge": "Project", - "repoGroupBadge": "Repo group" + "projectsGroupsHeader": "项目和分组", + "projectBadge": "项目", + "repoGroupBadge": "仓库组" }, "github": { "pr": { @@ -1789,15 +1807,15 @@ "df636f5886": "检查是否已设置 (PowerShell)" }, "ProjectCell": { - "4b5b871da8": "此 repo 中没有标签。", + "4b5b871da8": "此仓库中没有标签。", "2219e945ef": "加载中…", - "54cac64427": "Row 没有 repo slug。", + "54cac64427": "Row 没有仓库 slug。", "8ae56a88a6": "标签", "f7cdb78efb": "负责人", "ebde486e3c": "清除", "191905e20e": "当前和即将推出的", "e17bb96881": "已完成", - "943b3dadc9": "该 repo 没有议题类型。", + "943b3dadc9": "该仓库没有议题类型。", "c7b059cf07": "议题类型", "c5f949e489": "议题", "8d669084f6": "受限", @@ -1862,20 +1880,20 @@ "2edf5e7e77": "{{value0}} — Orca 尚不支持 {{value1}} 项目视图。在 {{value2}} 提交功能请求。", "7245c3d7ac": "清除搜索", "c5bc7ec007": "查看搜索器:{{value0}}", - "840c268665": "添加 repo", + "840c268665": "添加仓库", "dffa899f36": "取消", "7037c8f5f1": "存储库不在 Orca 中", "512fc171d6": "选择一个项目来开始。", "71fb69926c": "刷新", "a8fa0d2bf5": "刷新中", "fd15491034": "在 GitHub 中打开视图", - "22df63c393": "您的 Token 无法获取子议题数据。", + "22df63c393": "您的令牌无法获取子议题数据。", "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": { @@ -2179,13 +2197,13 @@ "a9957007eb": "忽略 {{value0}}", "1bffc07ba7": "查看{{value0}}", "bef0adef9b": "分支", - "0b1766738a": "Repo", + "0b1766738a": "仓库", "bbb1ab6a6f": "选择{{value0}}", "d1094dd529": "待评审", "4b93a235d8": "建议", "f68d538c63": "此清理集中没有工作区。", "4719327c9c": "所有清理建议都将被忽略。", - "a19040cd67": "没有与所选 repos 匹配的非活动工作区。", + "a19040cd67": "没有与所选仓库匹配的非活动工作区。", "97c772c4fe": "在检查的存储库中未找到非活动工作区。", "d3eef9463d": "没有要删除的非活动工作区。", "aaee139eab": "恢复忽略的建议", @@ -2197,7 +2215,7 @@ "b299f201b9": "安全移除", "2b31bf68de": "非活动", "ac5ba84cc1": "已选择", - "8b74d4ea6e": "扫描工作树和 git 状态,然后在建议删除之前结合打开的选项卡、terminal、实时 Agent 和远程可用性信号。", + "8b74d4ea6e": "扫描工作树和 git 状态,然后在建议删除之前结合打开的选项卡、终端、实时智能体和远程可用性信号。", "7eee951968": "检查工作区安全", "191f0bc98e": "关闭", "7ae2ad30f4": "刷新", @@ -2256,8 +2274,8 @@ "quick": { "commands": { "TerminalQuickCommandActionToggle": { - "b0d58e37ed": "Agent 提示", - "b5ea4d64f6": "Terminal 命令" + "b0d58e37ed": "智能体提示", + "b5ea4d64f6": "终端命令" }, "TerminalQuickCommandAppendEnterSwitch": { "e4e5fed3b3": "切换追加 Enter", @@ -2269,12 +2287,12 @@ "97e96cc027": "/目标", "e604bd40d6": "支持技能、文件路径和内置命令,例如", "79af0c0841": "npm run dev", - "577a342c7d": "要求 Agent 调查此工作区", + "577a342c7d": "要求智能体调查此工作区", "026cfb232a": "不支持提示命令", - "346d409ab2": "选择 Agent", - "0adba8fa0c": "Agent", + "346d409ab2": "选择智能体", + "0adba8fa0c": "智能体", "ec8f081919": "操作", - "ed04233b3e": "保存 terminal 命令或 Agent 提示以便快速访问。", + "ed04233b3e": "保存终端命令或智能体提示以便快速访问。", "ca414324ee": "命令文本", "dc921c17ee": "提示", "5b3f634a55": "添加快捷命令", @@ -2297,7 +2315,7 @@ "3834d24243": "项目", "b83efc79e2": "全局", "c25cf350ef": "范围", - "f0631e4999": "repo" + "f0631e4999": "仓库" } } }, @@ -2305,14 +2323,14 @@ "CloseTerminalDialog": { "ebd2fa844d": "关闭", "1d1a7a9c1f": "取消", - "6b9a6975f8": "terminal 仍有正在运行的进程。如果关闭 terminal,该进程将被终止。", - "78b79d854d": "关闭 Terminal?", - "stop_agent_title": "停止此 Agent?", + "6b9a6975f8": "终端仍有正在运行的进程。如果关闭终端,该进程将被终止。", + "78b79d854d": "关闭终端?", + "stop_agent_title": "停止此智能体?", "stop_command_title": "停止运行中的命令?", - "stop_agent_description": "关闭此 terminal 将停止 Agent 当前任务。", - "stop_command_description": "关闭此 terminal 将停止其中正在运行的命令。", - "dont_ask_again": "对有运行中进程的 terminals 不再询问", - "stop_agent_confirm": "停止 Agent", + "stop_agent_description": "关闭此终端将停止智能体当前任务。", + "stop_command_description": "关闭此终端将停止其中正在运行的命令。", + "dont_ask_again": "对有运行中进程的终端不再询问", + "stop_agent_confirm": "停止智能体", "stop_command_confirm": "停止并关闭" }, "MobileDriverOverlay": { @@ -2332,10 +2350,10 @@ }, "TerminalAgentSessionForkDialog": { "17fc841e59": "复制上下文", - "0c8a8629b1": "Fork 会显示为独立工作区,而非嵌套子项。新 Agent 会收到一份有界转录稿,可作为可编辑草稿。", + "0c8a8629b1": "Fork 会显示为独立工作区,而非嵌套子项。新智能体会收到一份有界转录稿,可作为可编辑草稿。", "620461df22": "顶级分叉", - "619b5a35d2": "创建一个顶级工作区分支并使用捕获的上下文启动一个新的 Agent 选项卡。", - "64e292e8e3": "分叉 Agent 会话", + "619b5a35d2": "创建一个顶级工作区分支并使用捕获的上下文启动一个新的智能体选项卡。", + "64e292e8e3": "分叉智能体会话", "9d25de2920": "创建分叉", "2b10412cfc": "创建中..." }, @@ -2346,9 +2364,9 @@ "2cf85a6a55": "复制窗格 ID", "39809d152f": "设置标题...", "06c2b0f043": "均衡窗格大小", - "98bccf4fa2": "向下拆分 Terminal", - "20e565d865": "向右拆分 Terminal", - "8a7ddb8b8a": "分叉 Agent 会话...", + "98bccf4fa2": "向下拆分终端", + "20e565d865": "向右拆分终端", + "8a7ddb8b8a": "分叉智能体会话...", "0a82b0608c": "添加快速命令...", "9528a65ef8": "没有快速命令", "3ce594a4a0": "全球的", @@ -2363,7 +2381,7 @@ "e4aa243f8c": "重新启动守护进程", "a7e2fd2699": "提交议题", "5c8ce20be6": "如果这种情况持续存在,请", - "cc6d997c65": "从此处重新启动 terminal 守护程序以清除过时的守护程序状态。" + "cc6d997c65": "从此处重新启动终端守护程序以清除过时的守护程序状态。" }, "TerminalPane": { "ac112e9036": "删除标题", @@ -2375,7 +2393,7 @@ "6bee0c8f17": "打开磁盘空间分析器", "ae20d0ffc2": "关闭", "38c282a2c4": "分析器直接从这里打开。您也可以稍后从左下角的工具箱菜单中选择“空间分析器”将其打开。", - "e2fcf07c0d": "Orca 无法保存此 terminal 会话,因为本地存储已满或不可写。打开磁盘空间分析器以查找可以清理的工作区存储。", + "e2fcf07c0d": "Orca 无法保存此终端会话,因为本地存储已满或不可写。打开磁盘空间分析器以查找可以清理的工作区存储。", "678c780a2c": "磁盘空间不可用" }, "osc52": { @@ -2383,8 +2401,8 @@ "blocked": { "toast": { "97c98f1afe": "打开设置", - "7cf51f74fd": "在 Terminal 设置中启用 TUI 剪贴板写入,以从 SSH、tmux、Neovim 或 fzf 进行复制。", - "89eaa3e80b": "Terminal 剪贴板写入被阻止" + "7cf51f74fd": "在终端设置中启用 TUI 剪贴板写入,以从 SSH、tmux、Neovim 或 fzf 进行复制。", + "89eaa3e80b": "终端剪贴板写入被阻止" } } } @@ -2392,8 +2410,8 @@ "stale": { "agent": { "row": { - "ad991ece5c": "Agent 的窗格不再可用。", - "090d607412": "陈旧 Agent 行-{{value0}}" + "ad991ece5c": "智能体的窗格不再可用。", + "090d607412": "陈旧智能体行-{{value0}}" } } }, @@ -2406,8 +2424,8 @@ "fd3d12a1e1": "无法创建 fork 工作区。", "38e41edc6e": "该工作区无法分叉为 git 工作树。", "f867385bb5": "找不到此分支的源工作区。", - "046e8d853c": "没有要 fork 的 terminal 上下文", - "c00421d320": "已复制分叉上下文。启动 Agent 并粘贴它以启动分叉。" + "046e8d853c": "没有要 fork 的终端上下文", + "c00421d320": "已复制分叉上下文。启动智能体并粘贴它以启动分叉。" } } }, @@ -2418,11 +2436,11 @@ "29c031b49a": "正在上传 {{value0}} 文件{{value1}} 到运行时...", "0c77693641": "工作树尚未准备好 - 请稍后再试。", "ce8248b835": "工作树路径不可用。", - "internalTooManyPaths": "拖放的路径过多,无法安全粘贴到 terminal。", - "internalPathsTooLarge": "拖放的路径列表过大,无法安全粘贴到 terminal。", + "internalTooManyPaths": "拖放的路径过多,无法安全粘贴到终端。", + "internalPathsTooLarge": "拖放的路径列表过大,无法安全粘贴到终端。", "b4cf68e889": "已跳过 {{value0}} {{value1}}。", - "writeTimeout": "文件拖放已取消:terminal 在安全超时前未接受路径。", - "writeRejected": "文件拖放已取消:terminal 无法接受路径。" + "writeTimeout": "文件拖放已取消:终端在安全超时前未接受路径。", + "writeRejected": "文件拖放已取消:终端无法接受路径。" } } }, @@ -2466,12 +2484,12 @@ "9acaf92093": "窗格操作", "1bce81dba6": "模拟器", "1ff1c77616": "浏览器", - "586d2ac445": "terminal", + "586d2ac445": "终端", "addSplitPane": "添加拆分窗格", "closePaneColumn": "关闭拆分窗格" }, "AiVaultSessionDropLayer": { - "dropOntoTerminalPane": "拖放到 terminal 面板以恢复此会话。", + "dropOntoTerminalPane": "拖放到终端面板以恢复此会话。", "couldNotReadPayload": "无法读取会话拖拽数据。", "localWorkspacesOnly": "从历史恢复仅支持本地工作区。", "openLocalWorkspace": "恢复会话前请先打开一个本地工作区。", @@ -2513,11 +2531,11 @@ "8e9d603a09": "取消固定标签" }, "QuickLaunchButton": { - "348a04c1ad": "Agent 设置...", - "ec2adf093e": "在新 terminal 中启动 {{value0}}", + "348a04c1ad": "智能体设置...", + "ec2adf093e": "在新终端中启动 {{value0}}", "465e432ef1": "无法为 {{value0}} 构建启动命令。", - "e518f544b1": "未检测到 agents", - "8dea9b5cdf": "没有启用 Agent" + "e518f544b1": "未检测到智能体", + "8dea9b5cdf": "没有启用智能体" }, "RecentTabSwitcher": { "329638ff6f": "切换选项卡", @@ -2562,8 +2580,8 @@ "aea43b5748": "打开现有的模拟器选项卡。", "b426bb2615": "转到手机模拟器", "4833fb2cbe": "新浏览器选项卡", - "d364f3c8d4": "新 Terminal", - "7c1313d237": "新 Terminal:", + "d364f3c8d4": "新终端", + "7c1313d237": "新终端:", "d1afac112b": "WSL", "efb33546ff": "git bash", "1a8af49530": "命令提示符", @@ -2576,8 +2594,8 @@ "d62d63b807": "创建文件", "25dc1cd653": "打开文件", "7cdf8ee0c8": "打开网址", - "b27864279e": "启动 agent", - "39676a184c": "打开任何文件、URL、Agent..." + "b27864279e": "启动智能体", + "39676a184c": "打开任何文件、URL、智能体..." }, "TabBarQuickCommandsButton": { "a2c7a33831": "命令", @@ -2587,7 +2605,7 @@ "b775303755": "运行快速命令:{{value0}}", "196593b6a9": "删除 {{value0}}", "15529ede69": "编辑 {{value0}}", - "1d411fb6a5": "为此 repo 保存快速命令", + "1d411fb6a5": "为此仓库保存快速命令", "8f1e971966": "添加快捷命令", "3220e2da27": "该快速命令将从您保存的列表中删除。", "e8e1a52edb": "删除“{{value0}}”?", @@ -2611,7 +2629,7 @@ "classifier": { "42e6262ae9": "没有可用的操作。", "097a982ee0": "正在加载文件...", - "5a9c83c04b": "打开任何文件、URL、Agent...", + "5a9c83c04b": "打开任何文件、URL、智能体...", "90eb94dc48": "输入 http:// 或 https:// URL。", "5553b283ce": "输入 URL 或文件路径。", "queryTooLarge": "搜索文本过长。" @@ -2619,7 +2637,7 @@ }, "menu": { "options": { - "5501c2fb7a": "terminal", + "5501c2fb7a": "终端", "9630dd5494": "shell", "a094576900": "新建终端", "4f23f4d01d": "新建 shell", @@ -2627,7 +2645,7 @@ "6d0e6a4b7a": "新建浏览器", "c87ad57785": "浏览器标签页", "cce7ef1d2c": "Web", - "5f17fb9d0c": "markdown", + "5f17fb9d0c": "Markdown", "44caaf7b36": "md", "fb50e3d874": "新建 markdown", "6d8b6b4117": "新文件", @@ -2697,9 +2715,9 @@ }, "ResourceUsageStatusSegment": { "946d9f94d0": "取消", - "67c4ecda49": "强制退出该 terminal。窗格中所有未保存的工作都会丢失。这无法撤销。", + "67c4ecda49": "强制退出该终端。窗格中所有未保存的工作都会丢失。这无法撤销。", "4bb076fa89": "强制结束", - "996295bff2": "孤儿 terminal", + "996295bff2": "孤儿终端", "92924a14e3": "查看非活跃工作区 ({{value0}})", "27a74f91f0": "现在没有任何运行", "1b24a32d3a": "记忆", @@ -2708,18 +2726,18 @@ "30ff2c3c31": "{{value0}} 个孤立项", "6449a95c78": "Orca 跟踪的进程占用了该计算机的物理 RAM 的大小。", "e7ccce7e87": "系统内存", - "9e2525c89f": "Orca 持有的常驻内存加上每个工作树 terminals 下的进程。", + "9e2525c89f": "Orca 持有的常驻内存加上每个工作树终端下的进程。", "1fedf94eae": "组合 CPU 负载。高于 100% 的值意味着多个核心同时工作。", - "e7cf14ec78": "Terminal 会话不可用。该列表可能已过时。", + "e7cf14ec78": "终端会话不可用。该列表可能已过时。", "93b0de3c21": "重新启动", - "f85af9cda6": "资源快照和 terminal 会话不可用。", + "f85af9cda6": "资源快照和终端会话不可用。", "f8e0d794b4": "守护进程没有响应", "bd19fd7a59": "杀死所有会话", "c9382662bb": "重新启动守护进程", "59f178fe11": "{{value0}},守护进程无法访问", "21cacb16d1": "· 偏僻的", - "73a3fd68a9": "折叠 repo", - "b12e31dfcb": "展开 repo", + "73a3fd68a9": "折叠仓库", + "b12e31dfcb": "展开仓库", "d659d71d2d": "恢复工作区 {{value0}}", "bbcd9b7b85": "折叠工作区", "c4a8968bdd": "扩大工作区", @@ -2732,7 +2750,7 @@ "888dad8c55": "加载中…", "56b6888304": "运行时服务器隐藏本地资源使用情况。", "14ff448686": "不可用于运行时服务器", - "6d9793d4bc": "资源管理器 - Terminals", + "6d9793d4bc": "资源管理器 - 终端", "6a822b06a7": "资源管理器", "ca95d077db": "守护进程无法访问", "a82253b458": "删除工作区。", @@ -2743,8 +2761,8 @@ "81cd37af99": "主要的", "fa6d36758d": "终止会话 {{value0}}", "b8f4a2c1d0e3": "{{value0}} 个孤立项", - "c7e3b1a0d9f2": "终止 {{value0}} 个孤立 terminal", - "d8f4c2b1e0a3": "终止 {{value0}} 个孤立 terminals", + "c7e3b1a0d9f2": "终止 {{value0}} 个孤立终端", + "d8f4c2b1e0a3": "终止 {{value0}} 个孤立终端", "e9a5d3c2b1f0": "终止 {{value0}}?" }, "SshStatusSegment": { @@ -2793,7 +2811,7 @@ "f19a63e7cd": "登录查看使用情况", "5c938d39ac": "% 周", "d79c3362c4": "% 5小时", - "8295903d17": "切换后继续旧对话之前,请重新启动实时 Claude terminals。", + "8295903d17": "切换后继续旧对话之前,请重新启动实时 Claude 终端。", "c98ea88392": "没有其他账户", "9332ba8684": "切换到", "d450654fa2": "Claude 账户", @@ -2814,21 +2832,21 @@ "6cd6650b4c": "重新启动会话", "1446d0d8a0": "{{value0}} 个 Codex 会话仍使用旧账户。", "605901a495": "1 个 Codex 会话仍使用旧账户", - "5e5f9f5160": "1 rate-limit reset available", - "5ecae9197c": "{{value0}} rate-limit resets available", - "25d8bbde69": "Using reset…", - "e159fc1fd7": "Reset now", - "972a1ff497": "Reset Codex limits?", - "6d1042aa6f": "This uses one Codex rate-limit reset credit for the active account and resets any eligible usage windows immediately.", - "f077f586db": "Don't ask again", - "c0e972d726": "Cancel" + "5e5f9f5160": "1 次速率限制重置可用", + "5ecae9197c": "{{value0}} 次速率限制重置可用", + "25d8bbde69": "正在使用重置…", + "e159fc1fd7": "立即重置", + "972a1ff497": "重置 Codex 限额?", + "6d1042aa6f": "这会为当前账户使用一次 Codex 速率限制重置额度,并立即重置所有符合条件的用量窗口。", + "f077f586db": "不再询问", + "c0e972d726": "取消" }, "StatusBarUsageEmptyCta": { "828c764a79": "连接账户", "caa0f39811": "支持:", "97957ad3a3": "连接 AI 提供商账户,即可实时查看使用量并在账户间快速切换。", "9a542f46c7": "从状态栏隐藏", - "84c3b15dca": "Agent 用量限制", + "84c3b15dca": "智能体用量限制", "d663430cf9": "设置用量跟踪" }, "UpdateStatusSegment": { @@ -2898,8 +2916,8 @@ "b9b4a3a25d": "分支", "c432278ec7": "编辑器缓冲区", "0bc756efaf": "Git 更改", - "e9528a89b3": "Terminals", - "a8d9e0de79": "Agents", + "e9528a89b3": "终端", + "a8d9e0de79": "智能体", "d384a4ce9f": "删除决定", "7d7745bb8f": "可以删除", "720870a18e": "保持:链接", @@ -2964,22 +2982,22 @@ "7ad719c4bf": "受限", "e740f92596": "刷新失败", "8418ec448d": "{{value0}} 用量无法刷新。智能体会话可能仍处于登录状态。", - "45198c7d95": "1 rate-limit reset available", - "bce421cba3": "{{value0}} rate-limit resets available", - "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." + "45198c7d95": "1 次速率限制重置可用", + "bce421cba3": "{{value0}} 次速率限制重置可用", + "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 主机" @@ -2992,7 +3010,7 @@ "61c58f8976": "缓存读取", "7d2efeff5e": "输出", "d7fb787e6b": "输入", - "a7902d3c1d": "Token", + "a7902d3c1d": "令牌", "059945f71d": "按天汇总的输入、输出、缓存读取和缓存写入。", "c9f7cd30e9": "每日使用量" }, @@ -3012,15 +3030,15 @@ "7dc9e5613b": "按项目", "c3fdbc5474": "常用模型:", "0f394c24e3": "按模型", - "51ae85fa00": "缓存复用率 = 缓存读取 Token /(输入 Token + 缓存读取 Token)。", + "51ae85fa00": "缓存复用率 = 缓存读取令牌 /(输入令牌 + 缓存读取令牌)。", "b26d4ddb58": "预估 API 等效成本", "0f3e696ca9": "会话/轮次", "8cc23be4a3": "零缓存读取轮次", "1634c4f404": "缓存复用率", "b786fb4a70": "缓存写入", "268cf0af51": "缓存读取", - "2b8a2f14aa": "输出 Token", - "ea71fae8fc": "输入 Token", + "2b8a2f14aa": "输出令牌", + "ea71fae8fc": "输入令牌", "7dde9331fd": "此范围内尚无本地 Claude 使用记录。", "424cd50412": "启用 Claude 使用量分析", "8d18bbb771": "刷新", @@ -3030,7 +3048,7 @@ "dd29209b21": "筛选", "e9bf9fce0e": "Claude 使用量选项", "6afacbee37": "Claude 使用量跟踪", - "0cb1a36d7d": "读取本地 Claude 使用日志,显示 Token、模型和会话统计。", + "0cb1a36d7d": "读取本地 Claude 使用日志,显示令牌、模型和会话统计。", "5ce4842c2c": "所有本地 Claude 使用情况", "4f8368c272": "仅 Orca 工作树", "cfe2282ffa": "未知", @@ -3046,7 +3064,7 @@ "c646e1783c": "缓存输入", "7b596a88b2": "输出", "99a91d3143": "输入", - "e4bdcf0071": "Token", + "e4bdcf0071": "令牌", "c756cda6a8": "按天汇总的输入、缓存输入、输出和推理。", "609aa96e8b": "每日使用量" }, @@ -3066,13 +3084,13 @@ "b98718aaab": "按项目", "95d2d89285": "常用模型:", "5a0d1d69cd": "按模型", - "94ac1f1ee7": "显示推理 Token 便于查看,但成本仅按未缓存输入、缓存输入和输出计算。", + "94ac1f1ee7": "显示推理令牌便于查看,但成本仅按未缓存输入、缓存输入和输出计算。", "1a18fbd56b": "预估 API 等效成本", "907b31865f": "会话/事件", "6e18146e9b": "推理输出", "a9ac0f423a": "缓存输入", - "5d8eba87bd": "输出 Token", - "e365eaa6fd": "输入 Token", + "5d8eba87bd": "输出令牌", + "e365eaa6fd": "输入令牌", "4c865393b4": "此范围内尚无本地 Codex 使用记录。", "f7c1affbd5": "启用 Codex 使用量分析", "3022cda443": "刷新", @@ -3082,7 +3100,7 @@ "1af1a39b2f": "筛选", "70b5b8581f": "Codex 使用量选项", "408210470c": "Codex 使用量跟踪", - "13badcd8f2": "读取本地 Codex 使用日志,显示 Token、模型和会话统计。", + "13badcd8f2": "读取本地 Codex 使用日志,显示令牌、模型和会话统计。", "4fe8820098": "所有本地 Codex 使用情况", "201766b754": "仅 Orca 工作树", "bf6cf2d4dd": "未知", @@ -3115,8 +3133,8 @@ "7e9433469a": "会话/活动", "5a65d68b77": "推理输出", "603504ee3b": "缓存输入", - "7aa4d8ce35": "输出 Token", - "d637a892ed": "输入 Token", + "7aa4d8ce35": "输出令牌", + "d637a892ed": "输入令牌", "bb6363e08c": "尚未找到此范围的本地 OpenCode 用法。", "f04131b3be": "启用 OpenCode 使用情况分析", "603cd138dc": "刷新", @@ -3126,7 +3144,7 @@ "01583b30aa": "搜索器", "230d6de108": "OpenCode 使用选项", "bea80ceae0": "OpenCode 使用情况跟踪", - "b8b3522436": "读取本地 OpenCode 使用日志以显示 Token、模型和会话统计信息。", + "b8b3522436": "读取本地 OpenCode 使用日志以显示令牌、模型和会话统计信息。", "144a6050e9": "所有本地 OpenCode 使用情况", "e04c58327c": "仅 Orca 工作树", "362231082f": "未知", @@ -3146,9 +3164,9 @@ }, "ShareUsageCard": { "4a4c6c79a3": "会话·", - "66c83284cf": "每日 Token", + "66c83284cf": "每日令牌", "b760c0b622": "顶级模特", - "2d9eb39264": "Token 总数", + "2d9eb39264": "令牌总数", "beb6f24f37": "预计。成本", "da62578d9d": "用法", "0eb31e79ee": "Orca IDE", @@ -3159,32 +3177,32 @@ "42d3e0bdf7": "使用情况分析提供商:{{value0}}", "c79f073d4c": "使用情况分析", "a58aba506f": "已创建 PR", - "1c96f433e2": "agents 工作时间", - "9dbec9e675": "已启动 Agent", - "73ed07859c": "启动第一个 Agent 以开始统计", + "1c96f433e2": "智能体工作时间", + "9dbec9e675": "已启动智能体", + "73ed07859c": "启动第一个智能体以开始统计", "1e696db2f6": "OpenCode", "7d26110cea": "Codex", "85457c02fe": "Claude", "b2cf4310ce": "概览", "908c470587": "codex", "eb6a066185": "claude", - "eee19cfade": "overview" + "eee19cfade": "概览" }, "UsageOverviewPane": { "22ed1b7669": "会话", "444585cb41": "有数据", "ecb0cd8a4c": "已启用 -", "33f7b043d2": "供应商", - "60002bb22f": "尚未找到本地 Claude、Codex 或 OpenCode 使用情况。概览将在下一个 Agent 会话写入 Token 日志后填充。", + "60002bb22f": "尚未找到本地 Claude、Codex 或 OpenCode 使用情况。概览将在下一个智能体会话写入令牌日志后填充。", "70f36452d4": "缓存共享", "327603fe8b": "活跃天数", "0eaf937335": "预计。成本", - "3887b94ce5": "Token 总数", + "3887b94ce5": "令牌总数", "2d13e57f72": "启用OpenCode", "2f1ee2878b": "启用 Codex", "0ea0cae435": "启用 Claude", - "6c00c46815": "启用提供商以扫描本地 Agent 日志并构建组合的 Token 账本。", - "49405ccc8d": "开始跟踪 Token", + "6c00c46815": "启用提供商以扫描本地智能体日志并构建组合的令牌账本。", + "49405ccc8d": "开始跟踪令牌", "ca6bc5fded": "刷新", "e06d1baf5c": "刷新使用概览", "c760c481c5": "使用概述", @@ -3215,7 +3233,7 @@ "8efeae0b22": "追踪", "5acbe1fdf2": "时间", "ef8bbf7739": "PR", - "ce8533f02e": "agents", + "ce8533f02e": "智能体", "0bba8ca244": "统计数据", "0e2a0b6431": "用法", "372debfac0": "统计数据", @@ -3232,23 +3250,23 @@ }, "sections": { "9564a3b21b": "会话 -", - "32330a6e66": "{{value0}}:{{value1}} Token", + "32330a6e66": "{{value0}}:{{value1}} 令牌", "57d1448ef8": "启用", "f6df0d7d6d": "更多的", "1dd166c920": "较少的", - "52d9221dc0": "近期 Token 活动热图", + "52d9221dc0": "近期令牌活动热图", "c424eb3f8e": "最好的:", - "f28ff1f852": "近期 Claude、Codex 和 OpenCode 的综合 Token 活动。", + "f28ff1f852": "近期 Claude、Codex 和 OpenCode 的综合令牌活动。", "69e2b50427": "每日强度", - "3a795542fa": "综合 Token 分布", + "3a795542fa": "综合令牌分布", "e65084cb4b": "推理", - "3bc4a01b24": "汇总已启用提供商的输入、输出和缓存 Token。", - "4ff104da47": "Token 分布", + "3bc4a01b24": "汇总已启用提供商的输入、输出和缓存令牌。", + "4ff104da47": "令牌分布", "0015facc1f": "缓存", "7f270458af": "输出", "9365b14a4e": "新输入", "3de9bf87fc": "还没有型号", - "6762f6a682": "Token", + "6762f6a682": "令牌", "a7f937fb29": "{{value0}} 个会话 - {{value1}} {{value2}}", "c8f3a2d1e0b4": "轮次", "d9a4b3e2f1c5": "事件" @@ -3257,19 +3275,19 @@ }, "UsageBreakdownSection": { "7765a4c3e1": "n/a", - "247c93ca92": "• inferred pricing" + "247c93ca92": "• 推断价格" }, "UsageSessionsTable": { - "1afc25eb06": "Turns", - "0f03975d59": "Events", - "21ea00bfa8": "Cache", - "e0b988599d": "Total", - "01476891c7": "Last active", - "c17bed0416": "Project", - "f6a2c8d019": "Model", - "faf3444859": "Input", - "a8b7487ff7": "Output", - "cfe2282ffa": "Unknown" + "1afc25eb06": "轮次", + "0f03975d59": "事件", + "21ea00bfa8": "缓存", + "e0b988599d": "合计", + "01476891c7": "最后活跃", + "c17bed0416": "项目", + "f6a2c8d019": "模型", + "faf3444859": "输入", + "a8b7487ff7": "输出", + "cfe2282ffa": "未知" } }, "sparse": { @@ -3308,7 +3326,7 @@ "aa59462502": "存储库", "571c5818c1": "家", "0bc1379f4c": "所有来源", - "38e0951c3a": "Agent 技能", + "38e0951c3a": "智能体技能", "fb6bf60b52": "Claude", "426be2aac6": "Codex", "39b6998ddb": "所有提供商", @@ -3354,7 +3372,7 @@ "c234df77f7": "创建前请选择或输入服务器父文件夹。", "3a13f6e88b": "未选择位置", "6ed14c0281": "未选择服务器文件夹", - "ssh_parent_manual": "Enter an SSH parent path." + "ssh_parent_manual": "输入 SSH 父路径。" }, "AddRepoNestedImportStep": { "496f68cf8c": "扫描存储库。单击停止。", @@ -3389,8 +3407,8 @@ "dd3ff65486": "浏览远程文件系统", "36d427bb66": "添加远程项目", "35831a7312": "添加...", - "lockedDescription": "Enter the path to a Git repository on {{value0}}.", - "lockedDisconnected": "{{value0}} is disconnected.", + "lockedDescription": "输入 {{value0}} 上 Git 仓库的路径。", + "lockedDisconnected": "{{value0}} 已断开连接。", "93e0221434": "连接" }, "AddRepoServerStartStep": { @@ -3403,8 +3421,8 @@ "423b5d3d31": "添加所选运行时服务器上已存在的 Git 存储库或文件夹。", "3d0c035483": "打开服务器项目", "438493f214": "或者手动输入服务器路径", - "6b9958492a": "想要一次导入多个 repos?浏览到父文件夹。", - "d40d751517": "新的 repo 或文件夹", + "6b9958492a": "想要一次导入多个仓库?浏览到父文件夹。", + "d40d751517": "新的仓库或文件夹", "a81ffa0a99": "在服务器上创建", "a2ea37d549": "远程 Git 存储库", "47759c9491": "从 URL 克隆", @@ -3439,7 +3457,7 @@ "3e64e8a70d": "连接失败", "32a7256d85": "克隆", "69f5b5380d": "克隆...", - "cloneOnHostDescription": "Enter the Git URL and choose where to clone it on {{value0}}.", + "cloneOnHostDescription": "输入 Git URL,并选择要在 {{value0}} 上克隆到的位置。", "cloneParentFolder": "父文件夹" }, "AutoRenameFailedDialog": { @@ -3447,7 +3465,7 @@ "eab8b45238": "复制错误", "a23b22d16f": "已复制", "74fc00776f": "错误详情", - "3afcad0497": "从第一条 Agent 消息开始。", + "3afcad0497": "从第一条智能体消息开始。", "ff62a18580": "Orca 无法生成分支名称", "ca3b225195": "分支自动命名失败" }, @@ -3502,7 +3520,7 @@ "NonGitFolderDialog": { "e52454b7f6": "作为文件夹打开", "05b33a17a9": "取消", - "8fba4b8cbb": "此文件夹不是 Git 存储库。您将拥有编辑器、terminal 和搜索,但基于 Git 的功能将不可用。", + "8fba4b8cbb": "此文件夹不是 Git 存储库。您将拥有编辑器、终端和搜索,但基于 Git 的功能将不可用。", "c49fb13492": "添加远程文件夹失败" }, "OrcaYamlTrustDialog": { @@ -3551,7 +3569,7 @@ "51001182e3": "空目录", "971d85cc84": "作为远程项目打开 · {{value0}}", "00c4235c10": "没有匹配“{{value0}}”", - "largeInputNoMatches": "No matches for this long input" + "largeInputNoMatches": "没有匹配此长输入的结果" }, "RemoveFolderDialog": { "4dc5b5065b": "移除", @@ -3587,8 +3605,8 @@ "aef6c0a213": "保存已检测的命令,以便在 Orca 创建工作树时运行它。", "660cdc17f8": "设置脚本。添加本地命令,或在“设置”中更改源。", "8f6be51aa1": "Orca.yaml", - "bb879db364": "此 repo 忽略共享", - "0155fb9ed3": "目前无法验证此 repo 的设置脚本。", + "bb879db364": "此仓库忽略共享", + "0155fb9ed3": "目前无法验证此仓库的设置脚本。", "eefa756190": "手动配置", "ca4efcbc25": "保存", "d02e6a42b1": "检测自", @@ -3645,7 +3663,7 @@ "0c3395fd32": "搜索工作树和浏览器选项卡", "c86d83b5c3": "新建", "1b5c41caee": "Orca Mobile", - "9c95e1ce91": "Agents", + "9c95e1ce91": "智能体", "f323383e9a": "自动化", "e7ad3c540d": "打开 Jira 任务", "c39ab10000": "打开 Linear 任务", @@ -3696,7 +3714,7 @@ "automationCreated": "隐藏自动化创建的工作区" }, "SidebarWorkspaceOptionsMenu": { - "95c9754653": "Agent 活动布局", + "95c9754653": "智能体活动布局", "3d4b9c4997": "徘徊", "ba87080fb7": "显示属性", "320b675c9a": "卡片布局", @@ -3714,12 +3732,12 @@ "7b316bdd51": "手动的", "7153d07485": "拖动工作区以将它们排列在每个组中。", "2170d553cf": "项目", - "b759bb87ee": "需要关注的 Agent,然后是最近的活动。", - "503462f2b4": "Agent 活动", + "b759bb87ee": "需要关注的智能体,然后是最近的活动。", + "503462f2b4": "智能体活动", "3728165cdd": "姓名", "2a81e07366": "完整列表", "25105b28cb": "紧凑", - "d7084e8bc8": "Agent 活动", + "d7084e8bc8": "智能体活动", "b64d8bcca0": "端口", "26c71e536c": "笔记", "b8dcc6f321": "PR/MR链接", @@ -3732,7 +3750,7 @@ "e029a2d775": "状态", "c2c7a45cda": "没有任何", "680043342f": "紧凑", - "c7591b6014": "repo", + "c7591b6014": "仓库", "hosts": "主机", "allHostsDetail": "显示所有主机", "configuredSshHost": "已配置 SSH", @@ -3745,10 +3763,10 @@ "b5536d5a88": "任务", "8d62c68b35": "笔记", "2d74665a56": "端口", - "65a9820bd1": "Agent 状态", + "65a9820bd1": "智能体状态", "219ebf1961": "分支名称", - "automation": "Automation", - "folderPathIdentity": "Branch / folder path" + "automation": "自动化", + "folderPathIdentity": "分支 / 文件夹路径" }, "SshDisconnectedDialog": { "ca4a7892af": "正在连接...", @@ -3759,7 +3777,7 @@ "11552bf786": "SSH 已断开", "cb5938ae79": "正在重新连接...", "disconnected": "This remote repository is not connected.", - "reconnecting": "Reconnecting to the remote host...", + "reconnecting": "正在重新连接远程主机...", "reconnectionFailed": "重新连接到远程主机失败。", "authFailed": "远程主机身份验证失败。" }, @@ -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": "状态:开放", @@ -3941,7 +3959,7 @@ "ebc5c7dcef": "隐藏子工作区", "84a2238242": "显示子工作区", "045a8aed48": "孩子们", - "2ca6e29a3c": "repo", + "2ca6e29a3c": "仓库", "bb85cd86ba": "为 {{value0}} 创建工作区", "ebadb7eadb": "{{value0}} {{value1}} child {{value2}}", "20bebf9c7f": "显示 {{value0}} 个子工作区", @@ -3949,15 +3967,15 @@ "e97297cb75": "隐藏 {{value0}} 个子工作区", "0cd15956d4": "隐藏 {{value0}} 个子工作区", "bd37a57ac8": "为 {{value0}} 创建工作区", - "b667b59632": "Some projects could not be removed from Orca", - "f94466bc39": "{{value0}} of {{value1}} contained project{{value2}} remained after deleting the group.", - "groupDeleteFailed": "Failed to delete group", - "groupDeleteFailedDesc": "Something went wrong while deleting the group. No projects were removed.", - "7a8b9c0d1e": "Update required", - "hostAuthNeeded": "Authentication needed", - "hostDisconnected": "Disconnected", + "b667b59632": "部分项目无法从 Orca 中移除", + "f94466bc39": "删除分组后,{{value1}} 个包含项目{{value2}}中仍剩 {{value0}} 个。", + "groupDeleteFailed": "删除分组失败", + "groupDeleteFailedDesc": "删除分组时出现问题。没有项目被移除。", + "7a8b9c0d1e": "需要更新", + "hostAuthNeeded": "需要身份验证", + "hostDisconnected": "已断开连接", "failedNestWorkspace": "无法嵌套工作区", - "sidebarRowMissing": "Target no longer exists" + "sidebarRowMissing": "目标不再存在" }, "WorktreeMetaDialog": { "3db0a2a593": "取消", @@ -4015,12 +4033,12 @@ "7edb8ebe24": "从 URL 克隆", "a6c20dca96": "从 SSH 目标打开项目", "3d162cc76f": "远程项目", - "fb4fc5380e": "本地项目、Git repo 或包含多个 repos 的文件夹", + "fb4fc5380e": "本地项目、Git 仓库或包含多个仓库的文件夹", "2281fdc8c7": "浏览文件夹", - "sshCreateUnavailable": "Not available for SSH hosts yet", - "sshBrowseTitle": "Open project on SSH host", - "sshBrowseDescription": "Existing Git repository or folder on this SSH host", - "runtimeBrowseDescription": "Existing Git repository or folder on this host" + "sshCreateUnavailable": "SSH 主机暂不可用", + "sshBrowseTitle": "在 SSH 主机上打开项目", + "sshBrowseDescription": "此 SSH 主机上的现有 Git 仓库或文件夹", + "runtimeBrowseDescription": "此主机上的现有 Git 仓库或文件夹" } } } @@ -4180,50 +4198,50 @@ "3d260e1a5d": "设置 › {{value0}}", "34a03a6565": "保持 {{value0}} 最新", "4a18052018": "本地 {{value0}} 落后于 {{value1}}", - "commit": "commit", - "commits": "commits" + "commit": "提交", + "commits": "提交" } } } } }, "LinearAgentSkillSetupPrompt": { - "missingCliAndSkill": "缺少 Orca CLI 和 Linear agent 技能。", + "missingCliAndSkill": "缺少 Orca CLI 和 Linear 智能体技能。", "modalTitle": "启用 Linear ticket 访问", - "modalDescription": "从 terminal 安装 Linear 技能。", - "modalPrompt": "允许 Agent 读取和编辑已附加的 Linear ticket。", + "modalDescription": "从终端安装 Linear 技能。", + "modalPrompt": "允许智能体读取和编辑已附加的 Linear ticket。", "dontShowAgain": "不再显示", "notNow": "暂不", - "missingBoth": "缺少 Orca CLI 和 Linear agent 技能。", + "missingBoth": "缺少 Orca CLI 和 Linear 智能体技能。", "missingCli": "缺少 Orca CLI。", - "missingSkill": "缺少 Linear agent 技能。", - "title": "设置 Linear agent 技能", - "remoteCopy": "这会安装主机设置;远程 agent 环境可能需要单独设置。", - "hostCopy": "为已链接 Linear 工作的主机 agent 交接安装此项。", - "dismiss": "关闭 Linear agent 技能设置", + "missingSkill": "缺少 Linear 智能体技能。", + "title": "设置 Linear 智能体技能", + "remoteCopy": "这会安装主机设置;远程智能体环境可能需要单独设置。", + "hostCopy": "为已链接 Linear 工作的主机智能体交接安装此项。", + "dismiss": "关闭 Linear 智能体技能设置", "setup": "设置", "recheck": "重新检查", - "panelTitle": "Linear agent 技能", - "panelDescription": "安装主机 agent 技能,用于已链接 Linear 任务交接。", - "terminalTitle": "安装 Linear agent 技能", - "terminalAria": "Linear agent 技能安装 terminal", + "panelTitle": "Linear 智能体技能", + "panelDescription": "安装主机智能体技能,用于已链接 Linear 任务交接。", + "terminalTitle": "安装 Linear 智能体技能", + "terminalAria": "Linear 智能体技能安装终端", "install": "安装 CLI 和技能", "successTitle": "Linear 议题访问已就绪", - "successDescription": "Agent 现在可以从此工作区读取和更新已链接的 Linear 议题。", - "successDescriptionWsl": "WSL Agent 现在可以从此工作区使用已链接的 Linear 议题。", - "successDescriptionRemote": "主机 Agent 现在可以使用已链接的 Linear 议题。远程 Agent 环境可能仍需要单独设置。", + "successDescription": "智能体现在可以从此工作区读取和更新已链接的 Linear 议题。", + "successDescriptionWsl": "WSL 智能体现在可以从此工作区使用已链接的 Linear 议题。", + "successDescriptionRemote": "主机智能体现在可以使用已链接的 Linear 议题。远程智能体环境可能仍需要单独设置。", "successStatus": "Linear 议题访问已就绪", "done": "完成", - "wslCopy": "为已链接 Linear 工作的 WSL agent 交接安装此项。", + "wslCopy": "为已链接 Linear 工作的 WSL 智能体交接安装此项。", "wslLabel": "WSL 默认值", "toastMissingCliAndSkill": "缺少 Orca CLI 和 Linear 技能", "toastMissingCli": "缺少 Orca CLI", "toastMissingSkill": "缺少 Linear 技能", - "toastInstallCliAndSkillDescription": "安装 Orca CLI 和 Linear 技能,让 Agents 可以读取并编辑 Linear 任务。", - "toastInstallCliDescription": "安装 Orca CLI,让 Agents 可以读取并编辑 Linear 任务。", - "toastInstallSkillDescription": "安装 Linear 技能,让 Agents 可以通过 Orca CLI 读取并编辑 Linear 任务。", - "toastRemoteDescription": "{{value0}} 远程 Agent 环境可能仍需要自己的设置。", - "toastWslDescription": "{{value0}} 此设置会在所选 WSL Agent 运行时中执行。" + "toastInstallCliAndSkillDescription": "安装 Orca CLI 和 Linear 技能,让智能体可以读取并编辑 Linear 任务。", + "toastInstallCliDescription": "安装 Orca CLI,让智能体可以读取并编辑 Linear 任务。", + "toastInstallSkillDescription": "安装 Linear 技能,让智能体可以通过 Orca CLI 读取并编辑 Linear 任务。", + "toastRemoteDescription": "{{value0}} 远程智能体环境可能仍需要自己的设置。", + "toastWslDescription": "{{value0}} 此设置会在所选 WSL 智能体运行时中执行。" }, "FolderWorkspaceComposerDialog": { "connectFailed": "无法连接到项目。", @@ -4248,18 +4266,18 @@ }, "sidebarHostOptions": { "3e102f111c": "所有主机", - "visibleHostsCount": "{{value0}} hosts" + "visibleHostsCount": "{{value0}} 个主机" }, "SidebarHostScopeStrip": { - "scopedTo": "{{value0}} visible", + "scopedTo": "{{value0}} 可见", "backToAll": "所有主机" }, "HostRemoveDialog": { - "1a2b3c4d5e": "Removed {{value0}}", + "1a2b3c4d5e": "已移除 {{value0}}", "2b3c4d5e6f": "删除主机失败", - "3c4d5e6f7a": "Remove {{value0}}?", - "4d5e6f7a8b": "This opens the Orca servers settings where you can remove this server.", - "5e6f7a8b9c": "This removes the saved SSH host and its credentials from this computer. Remote files are not deleted.", + "3c4d5e6f7a": "移除 {{value0}}?", + "4d5e6f7a8b": "这会打开 Orca 服务器设置,你可以在那里移除此服务器。", + "5e6f7a8b9c": "这会从此计算机移除保存的 SSH 主机及其凭据。远程文件不会被删除。", "6f7a8b9c0d": "取消", "7a8b9c0d1e": "打开设置", "8b9c0d1e2f": "删除主机" @@ -4277,14 +4295,14 @@ "9b3c1d2e44": "需要更新客户端", "2c29e2de68": "连接失败", "bf07aee59e": "断开连接失败", - "7f1a2b3c4d": "{{value0}} is reachable", - "4f2c8a9b10": "Host actions for {{value0}}", + "7f1a2b3c4d": "{{value0}} 可访问", + "4f2c8a9b10": "{{value0}} 的主机操作", "6b7c8d9e10": "主机操作", - "8d1e2f3a4b": "Rename…", + "8d1e2f3a4b": "重命名…", "63f36455cc": "重新连接", "59b553e2aa": "断开连接", "2d3e4f5a6b": "检查连接", - "3c4d5e6f7a": "Manage host…", + "3c4d5e6f7a": "管理主机…", "6e7f8a9b0c": "删除主机..." }, "WorkspaceKanbanDrawer": { @@ -4307,16 +4325,16 @@ "empty": "没有匹配的可用工作树。" }, "WorktreeCardStatusSlot": { - "branchIdentity": "Branch" + "branchIdentity": "分支" } }, "shared": { "useDaemonActions": { "01af244097": "取消", - "28c8e53176": "这会强制退出所有工作区中每个正在运行的 terminal 窗格。这些会话中所有未保存的工作都会丢失。守护进程本身保持运行,并且可以立即打开新 terminal。这无法撤销。", - "1bbea41a77": "杀死所有 terminal 会话?", - "01d6b7c64e": "终止每个正在运行的 terminal 窗格并重新启动守护进程。窗格显示“进程已退出”并且可以立即重新打开。先前应用程序版本的旧协议会话将被保留。这无法撤销。", - "922548bc66": "重新启动 terminal 守护程序?", + "28c8e53176": "这会强制退出所有工作区中每个正在运行的终端窗格。这些会话中所有未保存的工作都会丢失。守护进程本身保持运行,并且可以立即打开新终端。这无法撤销。", + "1bbea41a77": "杀死所有终端会话?", + "01d6b7c64e": "终止每个正在运行的终端窗格并重新启动守护进程。窗格显示“进程已退出”并且可以立即重新打开。先前应用程序版本的旧协议会话将被保留。这无法撤销。", + "922548bc66": "重新启动终端守护程序?", "2b4efdc162": "无法终止会话。", "d18f3005c2": "{{value0}} 会话{{value1}} 拒绝退出。", "baad8cd651": "没有正在运行的会话。", @@ -4334,7 +4352,7 @@ "setup": { "guide": { "SetupGuideModal": { - "3598a3ca0c": "完成使 Orca 对于并行 Agent 工作有用的核心工作流程。", + "3598a3ca0c": "完成使 Orca 对于并行智能体工作有用的核心工作流程。", "48a9e5ef2d": "入门", "28cf59fcb4": "这将从侧边栏隐藏清单", "f3b5ffb2a6": "从侧边栏隐藏清单" @@ -4359,7 +4377,7 @@ "dbdb0b0bd8": "工作区 ID 覆盖", "d70a5287a4": "如果自动查找失败,可选的工作区 ID 覆盖。", "02cb127710": "OpenCode Go 工作区 ID", - "7ce0e1907c": ")。在浏览器的 DevTools → Network → 任意 opencode.ai 请求 → Cookie 请求头中找到它。OpenCode Go 认证基于 Web,可在 Windows 与 WSL terminals 间共享。", + "7ce0e1907c": ")。在浏览器的 DevTools → Network → 任意 opencode.ai 请求 → Cookie 请求头中找到它。OpenCode Go 认证基于 Web,可在 Windows 与 WSL 终端间共享。", "8951c5309f": "auth=Fe26.2**…", "338820326a": ")或完整 cookie 请求头(例如", "922b51e02d": "Fe26.2**…", @@ -4400,7 +4418,7 @@ "72b36ea174": "可选。Orca 可使用系统默认 Claude 登录;仅当你需要快速切换且不想迁移聊天会话时才添加账户。", "26ef4b55be": "Claude", "2743cdc0af": "Claude 账户更新失败。", - "b15ce90870": "{{value0}} -> {{value1}}。在继续旧会话之前重新启动实时 Claude terminals。", + "b15ce90870": "{{value0}} -> {{value1}}。在继续旧会话之前重新启动实时 Claude 终端。", "f921d32606": "Claude 账户已更新。", "5bf8764953": "Codex 账户更新失败。", "9baf45d071": "此设备", @@ -4438,8 +4456,8 @@ "92f4238f1a": "WSL 默认值", "fc806485ae": "正在加载 WSL", "43663b5e69": "WSL", - "9bccf48906": "Agent 位置", - "d00949e59b": "显示来自 {{value0}} 的已安装 Agent。刷新会重新检查该环境中的 PATH。", + "9bccf48906": "智能体位置", + "d00949e59b": "显示来自 {{value0}} 的已安装智能体。刷新会重新检查该环境中的 PATH。", "c7c516946f": "WSL 在此计算机上不可用。", "f97b986b7f": "wsl" }, @@ -4459,16 +4477,16 @@ "runCommandDescription": "按 Enter 运行命令。" }, "AgentsPane": { - "d83834f5e6": "检测已安装的 Agent...", - "024bd95089": "agents", + "d83834f5e6": "检测已安装的智能体...", + "024bd95089": "智能体", "e8da2af684": "可安装", "ed3e110e61": "已检测", "02e0143be5": "已安装", - "110b74b022": "无 Agent(空白 terminal)", + "110b74b022": "无智能体(空白终端)", "92033495ff": "自动", - "9b175d0f5e": "打开新工作区时预先选择的 Agent。", - "385212c7a1": "默认 Agent", - "f9f127d664": "覆盖二进制路径或名称,并编辑此 agent 的默认启动参数或环境。", + "9b175d0f5e": "打开新工作区时预先选择的智能体。", + "385212c7a1": "默认智能体", + "f9f127d664": "覆盖二进制路径或名称,并编辑此智能体的默认启动参数或环境。", "f95b5c79b8": "安装", "fe4d630c94": "文档", "8dc0192e48": "已禁用", @@ -4480,22 +4498,22 @@ "1c9a9679ec": "{{value0}} 可用性", "0d9e293a02": "刷新", "c9b33eb5c0": "刷新中…", - "13647f9f80": "重新读取您的 shell 路径并重新检测已安装的 Agent", + "13647f9f80": "重新读取您的 shell 路径并重新检测已安装的智能体", "dc4a2ffdc0": "扩展命令覆盖", "cea7d97be1": "折叠命令覆盖", "db9e9e5887": "自定义命令", "959b67385b": "设置默认值", "24e032fa34": "默认", "5f986a9b92": "设置为默认值", - "d7625cf8b2": "默认 Agent", + "d7625cf8b2": "默认智能体", "cfb3f35775": "参数", "6f99bf5dd0": "无默认参数", "8fbe1f37c1": "环境", "2d133152fa": "无默认环境", - "agentPermissions": "Agent 权限", - "agentPermissionsInfo": "Agent 权限信息", - "agentPermissionsTooltip": "Custom agent arguments stay unchanged when switching modes.", - "agentPermissionsDescription": "选择 Orca 是以更少的权限提示启动 agent,还是手动检查。", + "agentPermissions": "智能体权限", + "agentPermissionsInfo": "智能体权限信息", + "agentPermissionsTooltip": "切换模式时,自定义智能体参数保持不变。", + "agentPermissionsDescription": "选择 Orca 是以更少的权限提示启动智能体,还是手动检查。", "agentPermissionsYolo": "Yolo", "agentPermissionsManual": "手动", "3f1bdf3cb4": "环境文本过长,无法安全解析。" @@ -4529,7 +4547,7 @@ "d496901cd0": "文件浏览器", "42554f615f": "选择 Orca 界面使用的字体。", "102d6b5f9b": "IDE字体", - "ef89200c1f": "当不在 terminal 窗格中时。", + "ef89200c1f": "当不在终端窗格中时。", "f687711a9b": "缩放整个应用程序界面。使用", "5e6d7aba8d": "用户界面缩放", "622e1c3465": "缩放整个应用程序界面。", @@ -4569,9 +4587,9 @@ "a869d0edd8": "分支名称命令模板", "e784ea62dc": "高级", "d9b65054ef": ")到总结任务的简短名称。只有 Orca 命名的分支才会被重命名,并且在推送后不会被重命名。", - "12ea4a408d": "当 Agent 开始在新工作区中工作时,Orca 会重命名其自动生成的分支(例如", + "12ea4a408d": "当智能体开始在新工作区中工作时,Orca 会重命名其自动生成的分支(例如", "ef787db0e3": "自动重命名分支", - "6a051586d2": "Agent 启动后,根据工作重命名自动生成的分支。", + "6a051586d2": "智能体启动后,根据工作重命名自动生成的分支。", "ec3e0c388e": "保存", "cfd82406dd": "保存中...", "40e7be7850": "已保存", @@ -4606,7 +4624,7 @@ "a5c16712c1": "已检测多个遥控器。输入远程名称(例如", "9a14ec7400": "选择下面的一个基础分支", "086ce7f369": "跟随主分支 ({{value0}})", - "2f3cda96f5": "固定此 repo", + "2f3cda96f5": "固定此仓库", "ee110e1830": "没有默认的基本参考" }, "BrowserDefaultZoomSetting": { @@ -4665,14 +4683,14 @@ "333984cf90": "使用现有的浏览器会话" }, "BrowserUseEnableSwitch": { - "aea3f45349": "启用 Agent 浏览器使用" + "aea3f45349": "启用智能体浏览器使用" }, "BrowserUseExamples": { "1199258ace": "复制", "1188e56af4": "复制示例提示", "b84807f228": "”", "59722f31b4": "”", - "c5325e91f6": "将其中任何内容粘贴到 Claude Code、Codex 或安装该技能的项目中的其他 Agent 中。", + "c5325e91f6": "将其中任何内容粘贴到 Claude Code、Codex 或安装该技能的项目中的其他智能体中。", "2a180694f7": "尝试一下 - 示例提示", "5ec620ccc4": "复制失败。", "a602d43069": "复制{{value0}}。" @@ -4682,18 +4700,18 @@ "e44c5d681e": "从", "67d9a53f47": "管理单独登录的配置文件", "112f70adc4": "上次导入自", - "72d4815523": "将您现有的登录信息带入 Orca,以便 Agent 可以访问经过身份验证的页面。导入到默认配置文件中。", + "72d4815523": "将您现有的登录信息带入 Orca,以便智能体可以访问经过身份验证的页面。导入到默认配置文件中。", "2eb906706c": "导入浏览器 Cookie", - "af8c83ed61": "从 Chrome、Edge 或其他浏览器导入 cookie,以便 Agent 可以重复使用您的登录信息。", - "68ea76eb71": "安装浏览器使用技能,以便 Agent 可以操作 Orca 的浏览器。", + "af8c83ed61": "从 Chrome、Edge 或其他浏览器导入 cookie,以便智能体可以重复使用您的登录信息。", + "68ea76eb71": "安装浏览器使用技能,以便智能体可以操作 Orca 的浏览器。", "2d6ead9ab2": "安装浏览器使用技能", "e9f3f3b488": "安装于", - "9fca1f7f5d": "注册 Orca CLI 命令,以便 Agent 可以从其 shell 编排浏览器。", + "9fca1f7f5d": "注册 Orca CLI 命令,以便智能体可以从其 shell 编排浏览器。", "c6065d205d": "启用 Orca CLI", - "c79eff0213": "注册 Orca CLI,以便 Agent 可以驱动浏览器。", - "702488a5f7": "让编码 Agent 通过您的登录来驱动此浏览器。完成以下三个步骤。", - "b8a1f2d84d": "Agent 浏览器使用", - "96b91c6349": "让编码 Agent 通过您的登录来驱动此浏览器。", + "c79eff0213": "注册 Orca CLI,以便智能体可以驱动浏览器。", + "702488a5f7": "让编码智能体通过您的登录来驱动此浏览器。完成以下三个步骤。", + "b8a1f2d84d": "智能体浏览器使用", + "96b91c6349": "让编码智能体通过您的登录来驱动此浏览器。", "2ea4617e3a": "从 {{value1}}{{value2}} 导入 {{value0}} cookie。", "721aee31b4": "在 PATH 中注册 Orca CLI。", "180a9abf3a": "无法加载 CLI 状态。", @@ -4706,25 +4724,25 @@ "8f2675c2f3": "已从文件导入 {{value0}} 个 Cookie。" }, "BrowserUseSkillStep": { - "0871b6998d": "使 Agent 能够在 Orca 浏览器中导航和验证页面。", + "0871b6998d": "使智能体能够在 Orca 浏览器中导航和验证页面。", "459e24eebc": "浏览器使用技能" }, "CliSection": { "8671e406f0": "取消", "a4aafe46e3": "目标路径:", - "e8012c03a1": "使 Agent 能够使用 Orca 工作区、terminal 和进度命令。", + "e8012c03a1": "使智能体能够使用 Orca 工作区、终端和进度命令。", "cliSkillTerminalTitle": "CLI 技能设置", - "cliSkillTerminalAria": "CLI 技能安装 terminal", + "cliSkillTerminalAria": "CLI 技能安装终端", "6053cf736c": "CLI技能", - "36a6f919ba": "为 Agent 提供 Orca 感知的工作区、terminal 和进度工作流程。", - "04873eea3e": "Agent 技能", + "36a6f919ba": "为智能体提供 Orca 感知的工作区、终端和进度工作流程。", + "04873eea3e": "智能体技能", "7f2747f7dd": "当前在此 shell 的 PATH 上不可见。", "b0c310ab46": "现有启动器目标:", "15eaad0d31": "命令路径:", "5dae812f50": "刷新", "52e640f3a0": "刷新 CLI 状态", "38edbb5721": "外壳命令", - "6930feda9e": "从 terminal 使用 Orca 打开应用程序、管理工作树并与 Orca terminal 交互。", + "6930feda9e": "从终端使用 Orca 打开应用程序、管理工作树并与 Orca 终端交互。", "c5c0f2641d": "Orca CLI", "d77352f2df": "无法从 PATH 中删除 `{{value0}}`。", "af5540930c": "从路径中删除了“{{value0}}”。", @@ -4737,7 +4755,7 @@ "4c7e3e4c5f": "安装", "068552b191": "正在删除...", "8d96213669": "移除", - "aa6536977e": "Orca 将注册 {{value0}} ,以便该命令在您的 terminal 上运行。", + "aa6536977e": "Orca 将注册 {{value0}} ,以便该命令在您的终端上运行。", "a030816e3e": "这将删除 shell 命令符号链接。 Orca 本身仍保持安装状态。", "fa87db3d6e": "在路径中注册`{{value0}}`?", "14444243ba": "从路径中删除`{{value0}}`?", @@ -4752,7 +4770,7 @@ "3728a94fb6": "WSL shell命令需要注意的地方", "775a4cfbb8": "WSL shell 命令注册不可用", "c47127f222": "WSL 默认值", - "0c9f3cf9da": "选择 Orca 检查和安装全局 Agent 技能的位置。", + "0c9f3cf9da": "选择 Orca 检查和安装全局智能体技能的位置。", "f00d6aa9b5": "WSL 在此计算机上不可用。", "7c776ff9d8": "wsl", "fc0fcf72fd": "在技​​能设置之前注册 WSL shell 命令。" @@ -4776,14 +4794,14 @@ "4f722a5f53": "由选择自定义命令的 commit 消息、PR和分支名称配方使用。使用", "47e45cbd5a": "自定义命令", "1ef29f8c29": "当文本配方使用自定义命令时,命令行 Orca 运行。", - "2339a89104": "添加 AI 按钮,用于使用该操作的命令模板运行选定的 Agent。", + "2339a89104": "添加 AI 按钮,用于使用该操作的命令模板运行选定的智能体。", "d5b45a3628": "显示源代码控制 AI 操作", "7bcad2b200": "为源代码管理的 commit、PR、分支命名和修复操作添加操作方案。", "d54c64163d": "已启用", - "4ec89c319e": "agent", + "4ec89c319e": "智能体", "34d0348e34": "产生", "8cd2be0948": "信息", - "ca433708cb": "commit", + "ca433708cb": "提交", "0b7eafe55f": "AI", "2c5436c018": "打开", "6c84ba6de3": "模板", @@ -4801,7 +4819,7 @@ "25350d670f": "风俗" }, "ComputerUsePane": { - "1735461723": "使 Agent 能够检查和操作本地桌面应用程序。", + "1735461723": "使智能体能够检查和操作本地桌面应用程序。", "93255aaf18": "计算机控制技能", "45f8e22c2e": "进行中", "d95d1cfab8": "刷新", @@ -4813,7 +4831,7 @@ "740766c291": "计算机使用设置已完成", "697005758f": "打开 macOS 隐私和安全", "2168fa5ab0": "无法加载计算机使用权限", - "0c9a33f468": "捕获应用程序窗口,以便 Agent 可以检查视觉状态。", + "0c9a33f468": "捕获应用程序窗口,以便智能体可以检查视觉状态。", "07bbe4c4cb": "截图", "4d03dec2d0": "读取应用程序界面树并执行请求的操作。", "6b5a2cd3a5": "无障碍", @@ -4824,7 +4842,7 @@ "DeveloperPermissionsPane": { "4c17304beb": "刷新", "6326a4c5cc": "当 CLI、本地应用程序或自动化工具需要 macOS 隐私访问时,请使用这些控件。 Orca 在启动时不会询问。", - "6f011b9bf6": "Terminal 工具继承了 Orca 的 macOS 隐私信封。", + "6f011b9bf6": "终端工具继承了 Orca 的 macOS 隐私信封。", "bfa3402305": "无法请求许可", "66e94d6cf3": "已发送权限请求", "fa809e8ada": "打开 macOS 隐私和安全", @@ -4854,23 +4872,23 @@ "9762364929": "在可能时在 macOS 上使用 APFS clone-copy;否则,将配置的文件夹或文件符号链接到创建的工作树中。", "24416f42cd": "工作树上的共享路径", "fb82ea1d7a": "自动将配置的文件或文件夹放入新创建的工作树中。", - "a20d5ea365": "在 terminal 响铃或 Agent 完成事件后保持窗格级突出显示可见,直到您与该窗格交互。我们在调整信号时进行实验。", - "ec897e8d89": "Terminal 关注", - "88b7613afb": "terminal 响铃和 Agent 完成事件的持久窗格突出显示。", - "0277901cf7": "将 Agents 条目添加到左侧边栏,其中包含已完成 Agents、阻塞待办、未读状态和工作树创建事件的线程工作树提要。实验性——事件模型和 UI 可能会改变。", - "a05bcdaf57": "Agent 查看", - "f63ea281e3": "用于 Agent 完成和阻塞状态的螺纹左侧边栏提要。", + "a20d5ea365": "在终端响铃或智能体完成事件后保持窗格级突出显示可见,直到您与该窗格交互。我们在调整信号时进行实验。", + "ec897e8d89": "终端关注", + "88b7613afb": "终端响铃和智能体完成事件的持久窗格突出显示。", + "0277901cf7": "将智能体条目添加到左侧边栏,其中包含已完成智能体、阻塞待办、未读状态和工作树创建事件的线程工作树提要。实验性——事件模型和 UI 可能会改变。", + "a05bcdaf57": "智能体查看", + "f63ea281e3": "用于智能体完成和阻塞状态的螺纹左侧边栏提要。", "ca2219fe5e": "显示固定在右下角的小动画宠物。从状态栏宠物菜单中选择一个角色(Claudino、OpenCode、Gremlin)或上传您自己的 PNG、APNG、GIF、WebP、JPG 或 SVG。随时从同一菜单隐藏它,而不禁用此设置。", "dd6f0a1d45": "宠物", "0e89a574ae": "右下角漂浮的动画宠物。", "agentHibernation": { - "copy": "在配置的空闲时间后停止后台 agent 终端,并在你再次打开时恢复受支持的会话。Agent 睡眠会保留由 Orca 启动的 agent 的启动选项。手动启动的 agent 可能会使用当前的 Orca 默认值恢复。我们仍在调校安全模型,此功能为实验性功能。", - "description": "在配置的空闲时间后停止后台 agent 终端,并在你再次打开时恢复受支持的会话。", - "idleMinutesDescription": "已完成的后台 agent 在 Orca 可以将其睡眠前需要等待的空闲分钟数。", + "copy": "在配置的空闲时间后停止后台智能体终端,并在你再次打开时恢复受支持的会话。智能体睡眠会保留由 Orca 启动的智能体的启动选项。手动启动的智能体可能会使用当前的 Orca 默认值恢复。我们仍在调校安全模型,此功能为实验性功能。", + "description": "在配置的空闲时间后停止后台智能体终端,并在你再次打开时恢复受支持的会话。", + "idleMinutesDescription": "已完成的后台智能体在 Orca 可以将其睡眠前需要等待的空闲分钟数。", "idleMinutesLabel": "睡眠等待时间", "idleMinutesSuffix": "分钟", - "title": "Agent 睡眠", - "toggleLabel": "切换 agent 睡眠" + "title": "智能体睡眠", + "toggleLabel": "切换智能体睡眠" }, "newWorktreeCardStyle": { "copy": "预览更新后的 worktree 卡片布局、元数据位置、卡片显示菜单选项和状态呈现。", @@ -4885,8 +4903,8 @@ "3c900e26e5": "无论切换显示在何处,键盘快捷键都有效。", "5e5a8da236": "切换按钮位置", "505001823e": "选择浮动工作区目录", - "81afb79785": "新的浮动 terminal 选项卡从这里开始。 Markdown 笔记保存在 Orca 应用程序拥有的浮动工作区中。", - "12aa09f10c": "Terminal 目录", + "81afb79785": "新的浮动终端选项卡从这里开始。 Markdown 笔记保存在 Orca 应用程序拥有的浮动工作区中。", + "12aa09f10c": "终端目录", "41eb95f7f0": "显示浮动工作区按钮和面板。", "5136813663": "启用浮动工作区", "37df688d6f": "启用浮动工作区并选择新选项卡的开始位置。", @@ -4898,14 +4916,14 @@ "8b9e202e0a": "将此与您的提供商的缓存 TTL 相匹配。默认值为 5 分钟。", "a2a8962138": "定时器持续时间", "b4e7302944": "缓存定时器", - "487b176240": "Claude Agent 空闲后,在侧边栏中显示倒计时。", - "9c20253679": "在 Claude Agent 空闲后显示倒计时。", + "487b176240": "Claude 智能体空闲后,在侧边栏中显示倒计时。", + "9c20253679": "在 Claude 智能体空闲后显示倒计时。", "fe590653c1": "Claude 会缓存对话以降低成本。空闲过久后缓存会过期,下一条消息将以更高成本重新发送完整上下文。此倒计时可帮助您了解何时继续。", "a137f8854d": "提示缓存定时器", "80c454e8a6": "将此与您的提供商的缓存 TTL 相匹配。" }, "GeneralEditorSettingsSection": { - "f80603d293": "在丰富的编辑器模式和 Agent 切换操作中显示本地 Markdown 注释控件。", + "f80603d293": "在丰富的编辑器模式和智能体切换操作中显示本地 Markdown 注释控件。", "4edc104f0f": "Markdown 评审笔记", "5f02e6fb21": "在富文本编辑器模式下显示本地 Markdown 评审笔记控件。", "51161d1647": "编辑文件时显示小地图概述。", @@ -4926,7 +4944,11 @@ "70bb30feb1": "短暂暂停后自动保存编辑器和可编辑差异更改。", "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", @@ -4937,7 +4959,7 @@ "476f302aca": "http://proxy.example.com:8080", "1e214e265a": "留空以使用系统代理设置和继承的代理环境变量。", "f00daf6324": "HTTPAgent", - "823e0f15b1": "Orca 网络请求和本地 terminal 子项的 Agent URL。", + "823e0f15b1": "Orca 网络请求和本地终端子项的智能体 URL。", "d93c7cd531": "配置应用程序级网络路由。", "c46cdbbd4e": "网络", "configureProxy": "配置代理" @@ -4989,7 +5011,7 @@ "31fd7150cf": "正在检查更新...", "3394d1f663": "检查中", "d69a09b672": "启动时会自动检查更新。", - "7173352632": "idle" + "7173352632": "空闲" }, "GeneralWorkspaceSettingsSection": { "3d538a98f7": "从工作区的“打开方式”菜单中选择可用的应用程序。", @@ -5000,7 +5022,7 @@ "28bc3d085e": "从上下文菜单中删除工作区之前显示确认信息。失败的删除仍然会出现强制删除后备。", "9f380934cf": "删除工作区之前询问", "5734db82af": "删除工作区之前显示确认对话框。", - "4fbf910ded": "在以 repo 命名的子文件夹内创建工作区。", + "4fbf910ded": "在以仓库命名的子文件夹内创建工作区。", "ba3480642f": "嵌套工作区", "a246f5ce6f": "创建工作区文件夹的根目录。", "5567191a6e": "浏览", @@ -5111,7 +5133,7 @@ "b8a7efb3f6": "ORCA_BITBUCKET_EMAIL", "8489c0aa49": "Bitbucket", "e74de656ce": "glab 身份验证登录", - "05e5245af7": "GitLab CLI 已安装但未经过身份验证。在 terminal 中运行此命令:", + "05e5245af7": "GitLab CLI 已安装但未经过身份验证。在终端中运行此命令:", "a83cac5726": "安装 GitLab CLI", "35a3379372": "安装 GitLab CLI 以启用合并请求、议题和管道。", "ea160a9978": "命令行界面。", @@ -5119,7 +5141,7 @@ "027440e1cb": "通过合并请求、议题、待办事项和管道", "513abfe47d": "GitLab", "51000487c4": "gh 验证登录", - "09285e9fe6": "GitHub CLI 已安装但未经过身份验证。在 terminal 中运行此命令:", + "09285e9fe6": "GitHub CLI 已安装但未经过身份验证。在终端中运行此命令:", "399cf46867": "安装 GitHub CLI", "c0c8575e05": "安装 GitHub CLI 以启用PR、议题和检查。", "f36365ed45": "gh", @@ -5187,14 +5209,14 @@ }, "ManageSessionsSection": { "33c2a1e1b4": "终止会话 {{value0}}", - "2896a50f50": "前往 Terminal {{value0}}", + "2896a50f50": "前往终端 {{value0}}", "e26a60d9eb": "没有会话。", "39c53d6d74": "加载中…", "5ed15e778c": "重新启动守护进程", "3282db098c": "终止所有会话", "b3b1cc5708": "刷新", "a795a9552a": "会话", - "7c4889a724": "通过终止会话或重新启动底层守护进程,从冻结或行为异常的 terminal 中恢复。", + "7c4889a724": "通过终止会话或重新启动底层守护进程,从冻结或行为异常的终端中恢复。", "d1b80fd5cd": "管理会话", "9c940434af": "切换回本地运行时以重新启动或终止本地守护进程会话。", "ad467eaadc": "当远程运行时服务器处于活动状态时,会话管理不可用。", @@ -5212,12 +5234,12 @@ }, "McpConfigSection": { "4d16a0d9ac": "已检查", - "b900cd6282": "未找到 MCP 配置。当您希望此 repo 定义其自己的 MCP 服务器时,添加一个空的工作区配置。", + "b900cd6282": "未找到 MCP 配置。当您希望此仓库定义其自己的 MCP 服务器时,添加一个空的工作区配置。", "3b224167ff": "服务器", "251b96564a": "已检测·", "f34c152dc0": "刷新 MCP 配置", - "6bac9ddfc6": "SSH repos 是通过远程文件系统读取的。 Starter 创建仅限于工作区根配置。", - "96f5609b04": "检查 Agent 在此 repo 中工作时可以使用的 MCP 服务器定义。", + "6bac9ddfc6": "SSH 仓库是通过远程文件系统读取的。 Starter 创建仅限于工作区根配置。", + "96f5609b04": "检查智能体在此仓库中工作时可以使用的 MCP 服务器定义。", "55eea3ef47": "MCP 配置", "9ee215caf6": ".mcp.json", "1f3665e35a": "MCP 配置已创建", @@ -5228,13 +5250,13 @@ "1861982430": "无法加载 CLI 状态。", "8af7a8bc38": "命令针对当前工作树的活动模拟器。坐标从 0..1 标准化。", "c7f3fe0a6e": "常用模拟器命令", - "d94ca6a623": "使 Agent 能够使用 Orca CLI 命令,包括手机模拟器控制。", + "d94ca6a623": "使智能体能够使用 Orca CLI 命令,包括手机模拟器控制。", "67e19ee03c": "Orca CLI 技能", "aaf62a3dd2": "安装于", - "2fef055608": "注册 Orca CLI 命令,以便 Agent 可以从其 shell 控制活动模拟器。", + "2fef055608": "注册 Orca CLI 命令,以便智能体可以从其 shell 控制活动模拟器。", "4f2205f3b6": "启用 Orca CLI", - "ff4b7e65d6": "让编码 Agent 使用 Orca CLI 命令控制活动手机模拟器。", - "2a674aa810": "Agent 手机模拟器控制", + "ff4b7e65d6": "让编码智能体使用 Orca CLI 命令控制活动手机模拟器。", + "2a674aa810": "智能体手机模拟器控制", "cdeaed9e37": "在 PATH 中注册 Orca CLI。" }, "MobileEmulatorExamples": { @@ -5242,20 +5264,20 @@ "c12b253997": "复制示例提示", "d151e25078": "”", "b525ff2b12": "”", - "4daa95f25a": "将其中任何内容粘贴到安装了 Orca CLI 技能的项目中的 Claude Code、Codex 或其他 Agent 中。", + "4daa95f25a": "将其中任何内容粘贴到安装了 Orca CLI 技能的项目中的 Claude Code、Codex 或其他智能体中。", "0820b3f84f": "尝试一下 - 示例提示", "1f608e7d60": "复制提示失败。", "2b077b5544": "已复制提示。" }, "MobileEmulatorSettingsPane": { - "19d39113b6": "让编码 Agent 使用 Orca CLI 命令控制活动手机模拟器。", - "f2f8d97bb6": "Agent 手机模拟器控制", + "19d39113b6": "让编码智能体使用 Orca CLI 命令控制活动手机模拟器。", + "f2f8d97bb6": "智能体手机模拟器控制", "143961d031": "默认设备", "8aec2f99a0": "刷新模拟器可用性", "ae1612c58c": "可用性", - "f9af91ea26": "显示“新建手机模拟器”操作并允许 Agent 连接到活动模拟器。", + "f9af91ea26": "显示“新建手机模拟器”操作并允许智能体连接到活动模拟器。", "700ddbf9b1": "启用手机模拟器", - "bc39d0f115": "配置对 Orca 和编码 Agent 的手机模拟器支持。", + "bc39d0f115": "配置对 Orca 和编码智能体的手机模拟器支持。", "6593c9ddd3": "手机模拟器", "a4f1c82d90": "已禁用", "b5e2d93e01": "检查中...", @@ -5280,7 +5302,7 @@ }, "MobilePane": { "dd3cd78d04": "使用 Orca Mobile 扫描", - "35100bca5d": "当您在手机上使用 terminal 时,Orca 会将其缩小以适合您的手机屏幕。当您关闭应用程序或切换离开时,这可以控制它是保持手机大小(因此交互式 CLI 工具不会回流)还是将大小调整回桌面。您始终可以在横幅上使用“恢复此终端”或“恢复所有终端”来手动调整大小。", + "35100bca5d": "当您在手机上使用终端时,Orca 会将其缩小以适合您的手机屏幕。当您关闭应用程序或切换离开时,这可以控制它是保持手机大小(因此交互式 CLI 工具不会回流)还是将大小调整回桌面。您始终可以在横幅上使用“恢复此终端”或“恢复所有终端”来手动调整大小。", "ee56f1c7e4": "当您离开手机应用程序时", "3939fd062c": "撤销设备会立即断开连接。", "254a6d09e4": "配对", @@ -5318,10 +5340,10 @@ "c258cb96dc": "选择通知声音", "2a2033c388": "选择发送桌面通知时 Orca 播放的警报。", "88686e6ca8": "通知声音", - "b6fc369244": "后台 terminal 发出响铃字符。", - "591fe605b9": "Terminal 响铃", - "55f901a59b": "编码 Agent 完成并变得空闲。", - "ca76d06fd2": "Agent 任务完成", + "b6fc369244": "后台终端发出响铃字符。", + "591fe605b9": "终端响铃", + "55f901a59b": "编码智能体完成并变得空闲。", + "ca76d06fd2": "智能体任务完成", "deff6d30da": "后台事件的本机系统通知。", "841c8c549f": "启用通知", "0fadad17ce": "无法播放通知声音", @@ -5365,8 +5387,8 @@ "e4064916aa": "添加应用程序", "9d0413817d": "从工作区的“打开方式”菜单中选择可用的应用程序。", "6ed52fe71e": "在应用程序中打开", - "eb55b87570": "您在 Terminal 中键入的命令以打开此应用程序。", - "ba1422ee07": "Terminal 命令", + "eb55b87570": "您在终端中键入的命令以打开此应用程序。", + "ba1422ee07": "终端命令", "e1fc0085c6": "菜单标签", "a261931d29": "删除应用程序", "af7d1c3656": "编辑应用程序", @@ -5384,21 +5406,21 @@ "80c6f2feb8": "复制示例提示。" }, "OrchestrationPane": { - "52e0634e2c": "要求协调 Agent 使用编排进行切换、工作树切换以及顺序或并行子 Agent。", + "52e0634e2c": "要求协调智能体使用编排进行切换、工作树切换以及顺序或并行子智能体。", "ae79504732": "如何使用", "7bc082f4de": "复制安装命令", - "832f1f3ee6": "更喜欢自己的 terminal?", - "9bedd2a6e5": "使 Agent 能够通过 Orca 传递上下文并协调工作。", + "832f1f3ee6": "更喜欢自己的终端?", + "9bedd2a6e5": "使智能体能够通过 Orca 传递上下文并协调工作。", "07641b9768": "编排技能", - "2aacdb0517": "跨切换、工作树切换和子 Agent 工作协调编码 Agent。", - "191ac34567": "Agent 编排" + "2aacdb0517": "跨切换、工作树切换和子智能体工作协调编码智能体。", + "191ac34567": "智能体编排" }, "OrchestrationSetupCard": { - "e7d2a5146c": "使 Agent 能够通过 Orca 传递上下文并协调工作。", + "e7d2a5146c": "使智能体能够通过 Orca 传递上下文并协调工作。", "2777ff0fdc": "编排技能" }, "OrchestrationSkillAgentCoverage": { - "6dec5ce2d2": "Agent 覆盖范围", + "6dec5ce2d2": "智能体覆盖范围", "ffe13e36fb": "缺失", "1e8f8d8fae": "就绪" }, @@ -5406,7 +5428,7 @@ "f08d45293d": "复制命令", "35550f3b3b": "完成", "1bdce1911e": "复制编排技能安装命令", - "b99f375eb2": "在 terminal 中运行此命令来为您的 Agent 安装编排技能。", + "b99f375eb2": "在终端中运行此命令来为您的智能体安装编排技能。", "2914abcfa2": "安装编排技能", "d3dc559225": "无法复制安装命令。", "239bf9132b": "复制安装命令。" @@ -5442,7 +5464,7 @@ "fe904ac984": "共享匿名使用数据", "77410e0566": "隐私政策", "8bfdd23a88": "帮助我们弄清楚下一步要构建什么。 Orca 会发送匿名计数,记录您使用的功能以及出现故障的位置。", - "afec8b03be": "ci" + "afec8b03be": "CI" }, "QuickCommandsPane": { "8764c6e9e4": "删除 {{value0}}", @@ -5450,15 +5472,15 @@ "8c877dec41": "全球的", "c6b155911b": "所有命令", "5aacc8f7dc": "添加命令", - "c36912efd5": "从选项卡栏中的“快速命令”按钮运行它们,或者在任何 terminal 内右键单击。", + "c36912efd5": "从选项卡栏中的“快速命令”按钮运行它们,或者在任何终端内右键单击。", "f91b649324": "保存的命令", "3d9dc558e8": "该快速命令将从您保存的列表中删除。", "3edf3deaf8": "删除“{{value0}}”?", "9fcfc29519": "插入", "9b3e338d62": "进入", - "4ccc63da87": "Agent", + "4ccc63da87": "智能体", "0252ddd578": "无命令文本", - "7784912ed6": "repo", + "7784912ed6": "仓库", "2bb9e38e93": "无题", "3eb9897ab0": "所选范围内没有命令。", "38d61927e6": "没有保存快速命令。", @@ -5481,7 +5503,7 @@ "bbbd6e0bc4": "命令源 & orca.yaml", "c9bc1bfd8f": "高级", "610d90fdbd": "命令源和 orca.yaml 详细信息。", - "52aef29e69": "留空以使用 repo 默认值", + "52aef29e69": "留空以使用仓库默认值", "4084720f47": "完成 {{artifact_url}}", "13394103bd": "自定义 GitHub 议题命令", "70ad20f883": "对于链接的议题或 PR URL。", @@ -5509,7 +5531,7 @@ "b2b06c7ce8": "可用的环境变量(将鼠标悬停以查看详细信息):", "95a0411b3e": "模板", "175daba180": "例子", - "b20c5df6ca": "添加“orca.yaml”文件以启用此 repo 的共享设置、存档或议题自动化默认值。示例模板:", + "b20c5df6ca": "添加“orca.yaml”文件以启用此仓库的共享设置、存档或议题自动化默认值。示例模板:", "56f9a4a1d0": "正在使用 `orca.yaml`", "623e0c9f31": "无法解析 `orca.yaml`", "5a67e4793d": "未检测到 `orca.yaml`", @@ -5517,16 +5539,16 @@ "787ca433ef": "仅定义支持的键:`scripts`、`setup`、`archive` 和 `issueCommand`。", "ecc73d9125": "将你的文件与下面可用的模板进行比较,并在需要时复制该结构。", "925f9e0dc4": "文本前景", - "0cc712b823": "核心配置文件存在于 repo 根目录中,但 Orca 尚无法解析支持的钩子定义。", + "0cc712b823": "核心配置文件存在于仓库根目录中,但 Orca 尚无法解析支持的钩子定义。", "c90b858573": "文本-​​Amber-700 深色:文本-Amber-300", "aba825233f": "该文件包含此版本的 Orca 无法识别的配置密钥。您可能需要更新 Orca,或检查文件是否有拼写错误。", - "ca424ff135": "共享挂钩和议题自动化默认值在 repo 中定义,可供所有使用它的人使用。", + "ca424ff135": "共享挂钩和议题自动化默认值在仓库中定义,可供所有使用它的人使用。", "32f417fe17": "文本-​​emerald-700 深色:文本-emerald-300", "8bfe65fc60": "使用本地命令", "8d6c56bff8": "运行两者", "0fa21e19ec": "工作区的名称,通常基于分支名称。", "54c73d88d0": "正在创建的工作树的路径。安装命令从此目录运行。", - "30952c4aa4": "主 repo 结帐的路径。对于将共享文件(例如 .env)复制到工作树中非常有用。", + "30952c4aa4": "主仓库结帐的路径。对于将共享文件(例如 .env)复制到工作树中非常有用。", "9b821fa19d": "# e.g. echo \"Cleaning up $ORCA_WORKSPACE_NAME\"", "6f90ebe3fd": "在归档或删除工作树之前运行。", "a3fc966677": "# e.g. pnpm install cp \"$ORCA_ROOT_PATH/.env\" \"$ORCA_WORKTREE_PATH/.env\"", @@ -5534,7 +5556,7 @@ "8561b0665f": "首先是 orca.yaml,然后是本地命令。", "0e8b2a520d": "忽略 orca.yaml;仅运行本地命令。", "83dc78202a": "仅限本地", - "29397e8bbc": "仅运行已 commit 的 repo 命令;忽略本地命令。", + "29397e8bbc": "仅运行已 commit 的仓库命令;忽略本地命令。", "d88b6ff88f": "仅 Orca.yaml", "99e3264a49": "仅在选择时运行安装程序。", "15debc1fd9": "默认跳过", @@ -5560,7 +5582,7 @@ "3149964b66": "已复制" }, "RepositoryIconPicker": { - "2b7d27b93c": "使用 {{value0}} repo 颜色", + "2b7d27b93c": "使用 {{value0}} 仓库颜色", "fde066a63b": "PNG 上传必须为 256KB 或更小。", "cc1286e263": "网站图标", "03ca1a4e9b": "example.com", @@ -5570,16 +5592,16 @@ "c490787d24": "表情符号", "b2d7fd2116": "图标", "2d8bd302fa": "阿凡达", - "913c55833d": "自定义 repo 颜色 {{value0}}", - "0e5f0693c1": "选择自定义 repo 颜色", + "913c55833d": "自定义仓库颜色 {{value0}}", + "0e5f0693c1": "选择自定义仓库颜色", "642dc29c6d": "颜色", "549d126081": "重置", "4e2a14f967": "Repo 图标", - "d71df44587": "无法解析 GitHub repo。", - "f79972271a": "找不到此 repo 的 GitHub 远程。", + "d71df44587": "无法解析 GitHub 仓库。", + "f79972271a": "找不到此仓库的 GitHub 远程。", "4d039317f4": "网站图标", "acf31559a0": "输入有效的网站 URL。", - "868c5c9b56": "无法导入 repo 图标" + "868c5c9b56": "无法导入仓库图标" }, "RepositoryPane": { "15a99d9b9f": "相对路径从该项目根解析。", @@ -5601,15 +5623,15 @@ "availableHostsDescription": "已设置此项目的主机。", "availableHostsHelp": "项目路径和工作树设置按主机独立;创建工作区时可指向任意已就绪的设置。", "viewingHost": "查看主机", - "currentSetup": "Current", - "hostSetupStateReady": "Ready", + "currentSetup": "当前", + "hostSetupStateReady": "就绪", "hostSetupStateNotSetUp": "未设置", "hostSetupStateSettingUp": "设置中", "hostSetupStateError": "错误", "hostSetupStateUnsupported": "不支持", "setupPathPending": "路径待定", - "openSetup": "Open", - "removeSetup": "Remove", + "openSetup": "打开", + "removeSetup": "移除", "hostSetupBlockedVersion": "Orca 服务器版本不兼容", "hostSetupMissingCapability": "在此主机上更新 Orca 以设置项目", "setupProjectOnHost": "在其他主机上设置", @@ -5619,13 +5641,13 @@ "setupExistingFolderPathPlaceholder": "/path/to/project/on/host", "cloneUrlPlaceholder": "仓库 URL", "cloneDestinationPlaceholder": "/destination/on/host", - "setupKindGit": "Git repo", + "setupKindGit": "Git 仓库", "setupKindFolder": "文件夹", "settingUpHost": "导入中...", "setupHost": "导入", "cloningHost": "克隆中...", "cloneHost": "克隆", - "creatingPendingSetup": "Creating...", + "creatingPendingSetup": "创建中...", "createPendingSetup": "跟踪设置", "hostSetupCheckingCapability": "检查主机能力", "hostAvailability": "主机可用性", @@ -5643,7 +5665,7 @@ "addPlannedHost": "添加主机占位符", "addPlannedHostHelp": "记下此主机,稍后再完成添加项目。", "existingFolder": "已有文件夹", - "addPlannedHostToHost": "Add {{host}}", + "addPlannedHostToHost": "添加 {{host}}", "addPlannedHostConfirm": "这仅记录项目应在此主机上可用。你可以稍后添加文件夹或克隆。", "projectRuntime": "项目运行时", "projectRuntimeDescription": "选择此项目运行在 Windows 还是 WSL。" @@ -5652,8 +5674,8 @@ "548a6e1281": "命令模板", "7a3a8e431d": "CLI 参数", "2b2f38652b": "自定义命令", - "0ffb081b3a": "使用默认 Agent", - "f4310cf63f": "Agent", + "0ffb081b3a": "使用默认智能体", + "f4310cf63f": "智能体", "1cd88d470a": "定制", "403876bb48": "使用全局", "f0aa2cfaea": "操作方案" @@ -5820,11 +5842,11 @@ "1c87f8d024": "高级", "c1b43dc4e2": "匿名使用数据和遥测控制。", "d7e3f62d70": "隐私与遥测", - "9b83cc62c2": "terminal 启动的开发者工具的 macOS 隐私访问。", + "9b83cc62c2": "终端启动的开发者工具的 macOS 隐私访问。", "65660d4548": "macOS 权限", "c6c01ac209": "通过手机控制终端和智能体。", "c40dadaac8": "移动端", - "c2ee313198": "通过 SSH 使用已有机器处理文件、terminals、Git 和工作区。", + "c2ee313198": "通过 SSH 使用已有机器处理文件、终端、Git 和工作区。", "9b02492d1f": "SSH 远程主机", "b5ee17826b": "配对远程 Orca 运行时,以获得持久会话、更丰富的远程状态,以及 Web 或移动端接续。", "7686cb5c36": "将此浏览器连接到已保存的 Orca 服务器。", @@ -5833,22 +5855,22 @@ "954a8f5aef": "统计和使用情况", "a737a4bb22": "常见操作的键盘快捷键。", "23bf7a1ad4": "快捷键", - "7210ac09c4": "针对 Agent 活动和 terminal 事件的原生桌面通知。", + "7210ac09c4": "针对智能体活动和终端事件的原生桌面通知。", "9907545fa3": "通知", "d0b7021d64": "选择和编辑行为。", "d7a3e635b6": "输入与编辑", - "6d1a27e193": "主题、缩放、应用程序和 terminal 外观、侧边栏和状态栏。", + "6d1a27e193": "主题、缩放、应用程序和终端外观、侧边栏和状态栏。", "2b4474780a": "外观", - "3d9adfe6a5": "全局 terminal、浏览器和 Markdown 选项卡。", + "3d9adfe6a5": "全局终端、浏览器和 Markdown 选项卡。", "3eb22a3ada": "浮动工作区", - "01f9d36292": "配置对 Orca 和编码 Agent 的手机模拟器支持。", + "01f9d36292": "配置对 Orca 和编码智能体的手机模拟器支持。", "f75daf1002": "手机模拟器", "ad9788036f": "主页、链接路由和会话 cookie。", "c46215ea03": "浏览器", - "6742c7932c": "保存的 terminal 命令,范围全局或每个项目。", + "6742c7932c": "保存的终端命令,范围全局或每个项目。", "13d4fe30ad": "快捷命令", - "b79b5b31e9": "Shell、渲染器、会话和 terminal 行为。", - "3de4bbb841": "Terminal", + "b79b5b31e9": "Shell、渲染器、会话和终端行为。", + "3de4bbb841": "终端", "dd72ed437a": "选择在“任务”页面和侧栏中显示的任务提供者。", "11faa2f7dd": "任务来源", "cfa34f4465": "分支命名、基础引用、归因和 Git AI Author。", @@ -5857,18 +5879,18 @@ "c9ca101a3b": "集成", "f9b77539fd": "工作区默认值、应用程序设置和维护。", "7807c11c4d": "通用", - "6855b0f77d": "完成使 Orca 对于并行 Agent 工作有用的核心工作流程。", + "6855b0f77d": "完成使 Orca 对于并行智能体工作有用的核心工作流程。", "6d119427ef": "入门清单", "eb1176a14e": "使用设备上模型进行本地语音到文本听写。", "5063bb47a5": "语音", - "7118953f14": "使 Agent 能够控制您计算机上的任何应用程序。", + "7118953f14": "使智能体能够控制您计算机上的任何应用程序。", "c9841721cb": "计算机控制", - "475980f53d": "通过 Orca 协调多个编码 Agent。", + "475980f53d": "通过 Orca 协调多个编码智能体。", "00c3a7950d": "编排", "21f09426ea": "可选。 Orca 可与您现有的提供商登录配合使用;仅当您希望 Orca 帮助在账户之间切换时才添加账户。", "ad6c529693": "AI 提供商账户", - "ec1ba547f7": "管理 AI Agent、设置默认值并自定义命令。", - "8afa676615": "Agents", + "ec1ba547f7": "管理 AI 智能体、设置默认值并自定义命令。", + "8afa676615": "智能体", "add3b97ee6": "”", "3c88ec55d6": "找不到“的设置", "c7ad095d96": "正在加载设置...", @@ -5903,7 +5925,7 @@ "builtin_themes": "内置", "imported_from": "从 {{value0}} 导入", "imported_themes": "已导入", - "search_terminal_themes": "搜索 terminal 主题" + "search_terminal_themes": "搜索终端主题" }, "SettingsSidebar": { "e0900f83e7": "SSH", @@ -5927,24 +5949,24 @@ "4ce3cd24d9": "没有与这些搜索器匹配的快捷键。" }, "ShortcutTerminalPolicyControl": { - "0762983d13": "Terminal 优先", + "0762983d13": "终端优先", "63308571d8": "Orca 优先", "c43c7ff5f9": "决定谁首先拦截捷径", - "c3a554288e": "Terminal 中的快捷键", - "0f55c6f15c": "选择当快捷键重叠时 Orca 或焦点 terminal 是否获胜。" + "c3a554288e": "终端中的快捷键", + "0f55c6f15c": "选择当快捷键重叠时 Orca 或焦点终端是否获胜。" }, "ShortcutsPane": { "4b7ae34062": "直接地。", "38e86e206a": "直观地自定义快捷键或编辑", "47f8f7aef9": "键盘快捷键", - "f0b35b0b2e": "当 terminal 或 TUI 具有键盘焦点时禁用。", - "5c65d5db9d": "Terminal 优先", - "dfa8ff612f": "也在 terminal 或 TUI 具有键盘焦点时运行。", + "f0b35b0b2e": "当终端或 TUI 具有键盘焦点时禁用。", + "5c65d5db9d": "终端优先", + "dfa8ff612f": "也在终端或 TUI 具有键盘焦点时运行。", "2a0e8aeccf": "Orca 优先", - "3c0fac059a": "当 terminal 具有键盘焦点时仍然运行。", - "25b0004fbf": "Terminal 活跃", - "781cb74d22": "从 terminal 窗格运行。", - "cb02e00202": "Terminal", + "3c0fac059a": "当终端具有键盘焦点时仍然运行。", + "25b0004fbf": "终端活跃", + "781cb74d22": "从终端窗格运行。", + "cb02e00202": "终端", "d8c988dab4": "~/.orca/keybindings.json" }, "SourceControlAiActionRecipeDefaults": { @@ -5953,10 +5975,10 @@ "fb09da4345": "命令模板", "2cb4bb7e5d": "CLI 参数", "0740d30915": "自定义命令", - "ee0e5c2a48": "使用默认 Agent", - "bf84dea6af": "仅当您希望 Orca 注入上下文时才使用变量。将 Agent 保留为默认值以遵循您的正常 Agent 偏好。", + "ee0e5c2a48": "使用默认智能体", + "bf84dea6af": "仅当您希望 Orca 注入上下文时才使用变量。将智能体保留为默认值以遵循您的正常智能体偏好。", "a79c567194": "操作方案", - "cf01d41bce": "每个源代码管理 AI 按钮使用的 Agent、CLI 参数和命令模板。", + "cf01d41bce": "每个源代码管理 AI 按钮使用的智能体、CLI 参数和命令模板。", "a9359c8aa9": "未知错误", "b5f46664d3": "无法保存源代码控制 AI 操作默认值:{{value0}}", "d18d665e12": "保存", @@ -5964,7 +5986,7 @@ "9d3cc627f8": "已保存", "817128d94e": "未保存的更改", "7ab1437a12": "PR", - "e5b24893ba": "commit", + "e5b24893ba": "提交", "06a9dab64d": "检查", "cb67b938c5": "修复", "2037c78a6f": "模板", @@ -5972,7 +5994,7 @@ "d74fdc776c": "命令", "673369fe0c": "命令行", "db9bd75d10": "论点", - "926d58e87f": "agent" + "926d58e87f": "智能体" }, "SparsePresetSettingsSection": { "6fa754d20f": "删除", @@ -5983,7 +6005,7 @@ "388513be2d": "稀疏结账预设", "a05bc9183f": "保存预设", "2d7d45e991": "取消", - "c240a16f25": "使用 repo 相对路径,例如packages/web 或apps/api。", + "c240a16f25": "使用仓库相对路径,例如packages/web 或apps/api。", "fde7ff2cc3": "包/网络共享/ui", "caf33029cc": "目录", "3b6f1abd3e": "例如仅限网络", @@ -6020,8 +6042,8 @@ "81d08bcddf": "连接成功", "2c4ee7332b": "远程继电器复位失败", "db2e48975e": "远程继电器复位", - "025e107643": "无法结束远程 terminals", - "90e308c98b": "远程 terminals 结束", + "025e107643": "无法结束远程终端", + "90e308c98b": "远程终端结束", "a43de1d3ee": "断开连接失败", "e95d5ae10e": "连接失败", "c2a69510e3": "删除目标失败", @@ -6060,25 +6082,25 @@ "3d8af2949f": "编辑目标", "762a48c662": "重置远程继电器", "97dea4e8cf": "重置远程继电器", - "da16e108e6": "结束远程 terminals", - "c77f1abfe3": "结束远程 terminals", + "da16e108e6": "结束远程终端", + "c77f1abfe3": "结束远程终端", "18968ede9e": "错误", "f0871e6bfb": "连接", "47e94bd6ba": "已连接" }, "SshTargetDestructiveActions": { - "7e66942808": "这将停止此 SSH 目标上的活动 terminal 会话。重新连接不会恢复它们。", - "accf177a03": "结束远程 Terminals?", - "26be00392d": "这将强制停止此 SSH 目标的远程中继。该目标的活动远程 terminals 和端口转发将结束。", + "7e66942808": "这将停止此 SSH 目标上的活动终端会话。重新连接不会恢复它们。", + "accf177a03": "结束远程终端?", + "26be00392d": "这将强制停止此 SSH 目标的远程中继。该目标的活动远程终端和端口转发将结束。", "570a7a0574": "重置远程继电器?", - "3bb0cf0ee4": "这将删除目标并结束任何活动的远程 terminals。", + "3bb0cf0ee4": "这将删除目标并结束任何活动的远程终端。", "4808966c41": "删除 SSH 目标" }, "SshTargetForm": { "fea9cb402e": "取消", "1b19b00e93": "(7 天)。", - "137e88ce8d": "断开连接后继电器使 terminals 保持活动状态的时间。默认值:10800(3 小时)。最大限度:", - "b574994adc": "远程 terminals 保持可用状态,直到您结束它们或重置继电器。", + "137e88ce8d": "断开连接后继电器使终端保持活动状态的时间。默认值:10800(3 小时)。最大限度:", + "b574994adc": "远程终端保持可用状态,直到您结束它们或重置继电器。", "71fc546097": "保持活动状态直至重置", "92f80edbfd": "中继宽限期(秒)", "feae1d1e69": "可选。相当于 ProxyJump / ssh -J。", @@ -6087,8 +6109,8 @@ "3b01ca44a0": "可选。用于隧道(例如 Cloudflare Access、ProxyCommand)。", "f42d844544": "例如cloudflared 访问 ssh --主机名 %h", "c7d0e18ecb": "代理命令", - "cb91f6375c": "可选。默认使用 SSH Agent。", - "d6a5f2ee5c": "~/.ssh/id_ed25519(SSH Agent 留空)", + "cb91f6375c": "可选。默认使用 SSH 智能体。", + "d6a5f2ee5c": "~/.ssh/id_ed25519(SSH 智能体留空)", "63c0c145c1": "身份文件", "c94cfa634c": "端口", "47e082bc17": "部署", @@ -6122,8 +6144,8 @@ "db632cb50e": "不透明度应用于当前不活动的窗格。", "a6fdd6a3b1": "非活动窗格不透明度", "1b79379d4f": "控制非活动窗格调光和分割分隔板厚度。", - "e1a5c25555": "Terminal 面板", - "04cdf85dec": "terminal 光标的不透明度。", + "e1a5c25555": "终端面板", + "04cdf85dec": "终端光标的不透明度。", "b9f1804422": "Cursor 不透明度", "2de6b5a699": "使用所选光标形状的闪烁变体。", "74736cc9b1": "闪烁 Cursor", @@ -6131,8 +6153,8 @@ "52854a5608": "堵塞", "e070e8aeba": "酒吧", "db270cc9a9": "Cursor 形状", - "d455f2ef4f": "Orca terminal 窗格的默认光标外观。", - "abcb4dd019": "Terminal Cursor", + "d455f2ef4f": "Orca 终端窗格的默认光标外观。", + "abcb4dd019": "终端光标", "70beb1bbc7": "预览", "31f6e61085": "目前连字", "870377082f": "关", @@ -6144,15 +6166,15 @@ "04569feb07": "始终关闭,即使对于附带它们的字体也是如此。", "7234abcd08": "始终开启。没有连字的字体只是按原样渲染。", "7233d594bf": "为发布它们的字体渲染编程连字(例如 =>、!=、===)。 “自动”仅对已知的连字字体(Fira Code、JetBrains Mono、Cascadia Code、Iosevka 等)启用连字。", - "bafc80efbc": "控制 terminal 线高度乘数。", + "bafc80efbc": "控制终端线高度乘数。", "c084eb7d4c": "行高", - "36af8ad94c": "控制 terminal 文本字体粗细。", + "36af8ad94c": "控制终端文本字体粗细。", "4aae5db258": "字体粗细", - "f04b17a50e": "新窗格和实时更新的默认 terminal 字体系列。", + "f04b17a50e": "新窗格和实时更新的默认终端字体系列。", "a408266e67": "字体家族", "855a76343a": "从幽灵导入", - "711e589f18": "新窗格和实时更新的默认 terminal 排版。", - "048aac8a64": "Terminal 排版", + "711e589f18": "新窗格和实时更新的默认终端排版。", + "048aac8a64": "终端排版", "4415beb958": "已禁用", "4e7d41a9f0": "已启用", "e90afcc44f": "关", @@ -6160,7 +6182,7 @@ }, "TerminalFontSizeSetting": { "9b5252c85a": "像素", - "0f4c92e595": "新窗格和实时更新的默认 terminal 字体大小。", + "0f4c92e595": "新窗格和实时更新的默认终端字体大小。", "a4a352b1e9": "字体大小" }, "TerminalPane": { @@ -6183,17 +6205,17 @@ "fe20f79dd1": "PowerShell版本", "822f62ddcd": "下载 PowerShell 7+", "a016ffbeed": "Auto 现在使用 Windows PowerShell,并在安装后切换到 PowerShell 7+。", - "5ed5c95344": "为新的 terminal 窗格选择 Windows PowerShell 和 PowerShell 7+。", - "3d88af864d": "选择 PowerShell shell 选项是为新 terminal 窗格启动 Windows PowerShell 还是 PowerShell 7+。", + "5ed5c95344": "为新的终端窗格选择 Windows PowerShell 和 PowerShell 7+。", + "3d88af864d": "选择 PowerShell shell 选项是为新终端窗格启动 Windows PowerShell 还是 PowerShell 7+。", "8a956cc91e": "双击选择时将字符视为单词边界。", "4bebcc2b2c": "单词分隔符", "12e06178fa": "MB", "907b0b9d3e": "自定义", "5336c096af": "{{value0}} 兆字节", - "81d86b2dd2": "新 terminal 窗格的最大 terminal 回滚缓冲区大小。", + "81d86b2dd2": "新终端窗格的最大终端回滚缓冲区大小。", "9df53f7c14": "回滚大小", - "c3810b2b42": "最大 terminal 回滚缓冲区大小。", - "267d020745": "回滚、字边界和特定于平台的 terminal 行为。", + "c3810b2b42": "最大终端回滚缓冲区大小。", + "267d020745": "回滚、字边界和特定于平台的终端行为。", "5e5f06c82c": "高级", "003df129fe": "水平分割", "623e62df99": "水平分割", @@ -6205,22 +6227,22 @@ "d23b43c5be": "安装脚本位置", "34a0dfa06e": "创建新工作区时运行存储库设置脚本的位置。", "21f8da2078": "工作区设置脚本", - "6e6480a7df": "让 terminal 中的程序(tmux、Neovim、fzf、SSH)复制到系统剪贴板。", + "6e6480a7df": "让终端中的程序(tmux、Neovim、fzf、SSH)复制到系统剪贴板。", "3338dcf8c1": "允许 TUI 剪贴板写入 (OSC 52)", "69c64a479c": "让 tmux、Neovim 和 fzf 通过 PTY(包括通过 SSH)复制到系统剪贴板。", - "4729c645fc": "自动将 terminal 选择复制到剪贴板。", + "4729c645fc": "自动将终端选择复制到剪贴板。", "902f5dee1f": "选择时复制", - "9129b7e805": "将鼠标悬停在 terminal 窗格上即可将其激活,无需单击。", + "9129b7e805": "将鼠标悬停在终端窗格上即可将其激活,无需单击。", "8eefeaa3da": "焦点跟随鼠标", - "96fe15def8": "terminal 窗格的鼠标和剪贴板行为。", - "45721f3e67": "Terminal 交互", + "96fe15def8": "终端窗格的鼠标和剪贴板行为。", + "45721f3e67": "终端交互", "9c0b1c1792": "开", "c1fc9e9444": "GPU加速", "e0996d141a": "Auto 尝试使用 WebGL,并针对不受支持或有风险的渲染器使用 DOM 回退。", - "7eaccc1424": "始终尝试将 WebGL 用于 terminal 窗格。", + "7eaccc1424": "始终尝试将 WebGL 用于终端窗格。", "fe4acf36c6": "WebGL 已禁用; DOM 渲染器可实现最大兼容性。", - "f07dfb4466": "控制 terminal 是否使用 xterm.js WebGL 渲染。当渲染器受支持时,Auto 会尝试 WebGL,并为软件或未知 GPU 渲染器提供保守的 Linux 后备方案。", - "72bc9334a0": "实时窗格和新窗格的 Terminal 渲染器行为。", + "f07dfb4466": "控制终端是否使用 xterm.js WebGL 渲染。当渲染器受支持时,Auto 会尝试 WebGL,并为软件或未知 GPU 渲染器提供保守的 Linux 后备方案。", + "72bc9334a0": "实时窗格和新窗格的终端渲染器行为。", "2fba319f21": "渲染", "cc8c5ca224": "Windows 默认值", "d78fc4fdef": "加载分布", @@ -6242,12 +6264,12 @@ "adbafefe56": "风俗", "16753eea48": "在 Windows 上,右键单击粘贴剪贴板。 Ctrl+右键单击打开上下文菜单。", "9c178cf8aa": "右键单击粘贴", - "af0c3b6e39": "在 Windows 上,右键单击将剪贴板粘贴到 terminal 中。使用 Ctrl+右键单击打开上下文菜单。", + "af0c3b6e39": "在 Windows 上,右键单击将剪贴板粘贴到终端中。使用 Ctrl+右键单击打开上下文菜单。", "29154326bb": "开", "ab20575a8a": "关", "ab3a1f9068": "执行程序", - "ask_before_closing_running_terminals_title": "关闭运行中的 Terminal 前询问", - "ask_before_closing_running_terminals_description": "在关闭有运行中命令或 agent 的 terminal 前显示确认。", + "ask_before_closing_running_terminals_title": "关闭运行中的终端前询问", + "ask_before_closing_running_terminals_description": "在关闭有运行中命令或智能体的终端前显示确认。", "scrollSpeed": { "title": "滚动速度", "description": "调整普通终端回滚、修饰键快速滚动以及全屏 TUI 滚轮速度。", @@ -6274,36 +6296,42 @@ "ec2e33ad80": "分光器颜色", "d56af60e6f": "选择 Orca 处于浅色模式时使用的主题。", "8273bc75d7": "浅色主题", - "74b15574c8": "配置可选的轻型模式 terminal 外观。", - "b584287e84": "禁用后,浅色模式会重用深色 terminal 主题。", - "d76f60c9cc": "在浅色模式下使用单独的主题", "bc8e8a251a": "深色模式预览", "cbe56a0f79": "控制深色模式下窗格之间的分割分隔线。", "b739d2abfe": "深色分隔线颜色", - "7add204bd5": "选择深色模式下使用的 terminal 主题。", + "7add204bd5": "选择深色模式下使用的终端主题。", "9499ad1dc4": "黑暗主题", - "f012172e21": "选择深色模式下用于 terminal 窗格的主题。", - "import_themes_title": "导入主题", - "import_themes_description": "已导入的主题在深色和浅色主题选择器中都可用。" + "f012172e21": "选择深色模式下用于终端窗格的主题。", + "catalog_title": "终端主题", + "catalog_description": "为深色和浅色模式选择终端主题和分隔线颜色。", + "target_title": "主题模式", + "target_label": "主题模式", + "target_aria": "终端主题模式", + "target_dark": "深色", + "target_light": "浅色", + "match_dark_mode": "匹配深色模式", + "match_dark_mode_description": "在浅色模式中共享深色终端主题和分隔线颜色。", + "light_preview_description": "显示实际生效的浅色终端外观。", + "dark_preview_description": "显示实际生效的深色终端外观。" }, "TerminalWindowSection": { "1705318506": "ANSI 洋红色", "03c855d15f": "重置所有颜色覆盖", "63f8d9336e": "颜色覆盖", - "e86e09b5c7": "覆盖各个 terminal 颜色。", - "1d1920dc8a": "在 terminal 中输入时隐藏鼠标光标。", + "e86e09b5c7": "覆盖各个终端颜色。", + "1d1920dc8a": "在终端中输入时隐藏鼠标光标。", "3530908ef9": "打字时隐藏鼠标", - "1846f6ee6a": "terminal 网格周围的垂直填充(以像素为单位)。", + "1846f6ee6a": "终端网格周围的垂直填充(以像素为单位)。", "1afcc1d973": "垂直内边距", - "25e2f8e8e1": "terminal 网格周围的水平填充(以像素为单位)。", + "25e2f8e8e1": "终端网格周围的水平填充(以像素为单位)。", "36b8402015": "水平填充", "53ce336e15": "重新启动 Orca 以应用窗口模糊更改。", "c65bb9ce63": "需要重新启动", - "97950bb087": "将背景模糊应用到 terminal 窗口。需要重新启动。", + "97950bb087": "将背景模糊应用到终端窗口。需要重新启动。", "2b82242f43": "窗口模糊", - "809f37738d": "控制 terminal 背景的透明度。 1 是完全不透明,0 是完全透明。", + "809f37738d": "控制终端背景的透明度。 1 是完全不透明,0 是完全透明。", "ea7b1a158e": "背景不透明度", - "03acb60aa0": "控制 terminal 背景的透明度。", + "03acb60aa0": "控制终端背景的透明度。", "00eaa6b881": "窗口外观和背景设置。", "b96ba13ed1": "窗户", "42e01a6055": "ANSI 亮白色", @@ -6329,7 +6357,7 @@ "fb8bb4eb1f": "青色", "d5e92fcd94": "品红", "9635a71c51": "ANSI 蓝色", - "292a4c7316": "Blue", + "292a4c7316": "蓝色", "09c1c6b096": "ANSI 黄色", "bb516de873": "黄色的", "8a673d4206": "ANSI 绿色", @@ -6348,8 +6376,8 @@ "7f4063076c": "光标下文本的颜色(块光标)", "a2d9f095a7": "Cursor 文本", "cd0700762b": "Cursor 颜色", - "c9e1fdf42f": "Cursor", - "da64e8f4c1": "Terminal 背景颜色", + "c9e1fdf42f": "光标", + "da64e8f4c1": "终端背景颜色", "cc1b2ffeb2": "背景", "026a0b8013": "主要文字颜色", "79f6bfb76e": "前景", @@ -6390,7 +6418,7 @@ "f9a9cf6928": "启用语音听写之前需要麦克风许可。", "1eac933202": "打开 macOS 隐私和安全。授予访问权限后再次启用听写。", "cd9fe37556": "已授予麦克风权限", - "6fa734ed95": "Delete {{value0}}" + "6fa734ed95": "删除 {{value0}}" }, "WorktreeSymlinksSection": { "1c1e35b219": "删除 {{value0}}", @@ -6426,7 +6454,7 @@ "41a1480d3e": "安装", "4598b18464": "正在删除...", "7c3bb36706": "移除", - "7ee4e52b99": "Orca 将注册 {{value0}} ,以便该命令在 WSL terminals 上运行。", + "7ee4e52b99": "Orca 将注册 {{value0}} ,以便该命令在 WSL 终端上运行。", "d8216eb22e": "这会删除 WSL shell 命令。 Orca 本身仍安装在 Windows 上。", "e49688f67f": "在 WSL 中注册 `{{value0}}`?", "61ac55278e": "从 WSL 中删除“{{value0}}”?", @@ -6512,11 +6540,11 @@ "d608654c03": "wsl", "77c02fa3c3": "视窗", "d2952dfd74": "位置", - "96ba2373b6": "agent", - "cbdd7f3b9e": "选择是否在此设备上或 WSL 中检测已安装的 Agent。", - "ef804b7337": "Agent 位置", - "01926b9d8c": "配置 AI 编码 Agent、默认 Agent 和命令覆盖。", - "bb9ad95777": "Agents", + "96ba2373b6": "智能体", + "cbdd7f3b9e": "选择是否在此设备上或 WSL 中检测已安装的智能体。", + "ef804b7337": "智能体位置", + "01926b9d8c": "配置 AI 编码智能体、默认智能体和命令覆盖。", + "bb9ad95777": "智能体", "d8f3a8b8a0": "默认", "167daeb5e9": "命令", "be59907510": "覆盖", @@ -6555,8 +6583,8 @@ "8a17fd6026": "稳定", "a79d266f71": "会话", "afbf35be68": "稳定会话", - "agentPermissions": "Agent 权限", - "agentPermissionsDescription": "在 Yolo 和手动之间切换 agent 权限默认值。" + "agentPermissions": "智能体权限", + "agentPermissionsDescription": "在 Yolo 和手动之间切换智能体权限默认值。" } }, "appearance": { @@ -6628,12 +6656,12 @@ "cf409b6c4d": "端口", "cb1cc62cf8": "空间", "90bdc043ea": "磁盘", - "96b4fb0064": "terminal", + "96b4fb0064": "终端", "4ddbde4999": "中央处理器", "4355f18ac6": "记忆", "9c4d5f0894": "经理", "c690a15849": "资源", - "81ef5abc2f": "在状态栏中显示 CPU、内存、terminal 会话和工作区磁盘使用情况。", + "81ef5abc2f": "在状态栏中显示 CPU、内存、终端会话和工作区磁盘使用情况。", "7cf005b29f": "资源管理器", "fe192b060e": "主持人", "f4997e0f8a": "联系", @@ -6700,16 +6728,16 @@ "50139297e6": "内置提示", "502aa57681": "指示", "40d21f2efc": "提示词", - "672387fb77": "生成分支名称时使用的 Agent 命令模板。", + "672387fb77": "生成分支名称时使用的智能体命令模板。", "722551c5b3": "分支名称命令模板", "f41833025e": "产生", "ed677944cc": "工作树", - "3ef3cbe98c": "agent", + "3ef3cbe98c": "智能体", "f0acf64301": "生物名称", "7803423877": "自动", "55a1860e47": "重命名", "9319bd9827": "分支", - "ea94b9da8a": "Agent 启动后,根据工作重命名自动生成的分支。", + "ea94b9da8a": "智能体启动后,根据工作重命名自动生成的分支。", "427f2cd1eb": "自动重命名分支" } } @@ -6784,16 +6812,16 @@ "02837ee497": "会话", "fb8178824f": "曲奇饼", "ba4eb53b72": "浏览器使用", - "2fb24d17db": "从 Chrome、Edge 或其他浏览器导入 cookie,以便 Agent 可以重复使用您的登录信息。", + "2fb24d17db": "从 Chrome、Edge 或其他浏览器导入 cookie,以便智能体可以重复使用您的登录信息。", "614c756ab1": "导入浏览器 Cookie", "cee44fb442": "自动化", - "a57c2172dc": "Agent 浏览器", + "a57c2172dc": "智能体浏览器", "6ea88e5206": "恩克斯", "f5b8fdddf5": "orca-cli", "e5a784bc54": "安装", - "9d97446873": "agent", + "9d97446873": "智能体", "a2d489263e": "技能", - "a7e82445fa": "安装浏览器使用技能,以便 Agent 可以操作 Orca 的浏览器。", + "a7e82445fa": "安装浏览器使用技能,以便智能体可以操作 Orca 的浏览器。", "a1414dcefb": "安装浏览器使用技能", "e56c7b55c9": "设置", "034c5e8d7f": "启用", @@ -6802,7 +6830,7 @@ "30c74aaa1f": "路径", "ff05cbc344": "orca", "85fab5e12c": "CLI", - "890ddf943d": "注册 Orca CLI,以便 Agent 可以驱动浏览器。", + "890ddf943d": "注册 Orca CLI,以便智能体可以驱动浏览器。", "50f0860e18": "启用 Orca CLI" } } @@ -6811,7 +6839,7 @@ "message": { "ai": { "search": { - "3766941527": "agent", + "3766941527": "智能体", "181cdb0637": "打开", "8e9cc598d7": "产生", "b7d50da4d8": "模板", @@ -6821,7 +6849,7 @@ "001ca3f2af": "创建 PR 编辑器打开时使用的默认值。", "eefd33788c": "PR 创建默认值", "d32936bb2a": "分支", - "127d512e75": "commit", + "127d512e75": "提交", "d22a6459e4": "冲突", "53e8504fb2": "ci", "c46e665f7e": "检查", @@ -6832,7 +6860,7 @@ "57c851a68c": "CLI", "61117e57f3": "参数", "0f29331fed": "论点", - "18b6d38835": "每个源代码管理 AI 按钮使用的 Agent、CLI 参数和命令模板。", + "18b6d38835": "每个源代码管理 AI 按钮使用的智能体、CLI 参数和命令模板。", "3c4e5e5938": "操作方案", "ee14a9e9f7": "已启用", "82109d627d": "源代码控制", @@ -6855,7 +6883,7 @@ "26c1290d83": "屏幕录制", "82f01c2d2c": "可达性", "fefb452f5b": "计算机控制", - "9210db582b": "当您要求时,允许 Agent 检查屏幕截图并操作本地应用程序。", + "9210db582b": "当您要求时,允许智能体检查屏幕截图并操作本地应用程序。", "442bec10fe": "计算机控制" } } @@ -6870,7 +6898,7 @@ "e3fbc48083": "蓝牙", "c4a4a02ea4": "USB", "fa3239cd42": "本地网络", - "acad3d4743": "允许从 terminal 会话使用设备和本地网络工具。", + "acad3d4743": "允许从终端会话使用设备和本地网络工具。", "3e0131e45d": "云", "ce07159ff5": "桌面", "a0c19119fb": "下载", @@ -6900,7 +6928,7 @@ "0c13b249e3": "TCC", "2270ccff3f": "隐私", "a98aa11a9c": "权限", - "bc8ac95310": "terminal 启动的开发者工具的 macOS 权限。", + "bc8ac95310": "终端启动的开发者工具的 macOS 权限。", "e92cb0896d": "开发者权限" } } @@ -6921,23 +6949,23 @@ "78c2a8dc74": "工作树上的共享路径", "7b79081695": "未读", "f10d307468": "完成", - "5f067ba0f9": "agent", + "5f067ba0f9": "智能体", "7695fd30e9": "通知", "8facf10138": "钟", "edc49480a1": "窗格", "268e99d957": "强调", "01567f19ca": "注意力", - "9bb3bd5098": "terminal", - "11877246fc": "terminal 响铃和 Agent 完成事件的持久窗格突出显示。", - "9e4ddf776d": "Terminal 关注", + "9bb3bd5098": "终端", + "11877246fc": "终端响铃和智能体完成事件的持久窗格突出显示。", + "9e4ddf776d": "终端关注", "fe5688b761": "侧边栏", "ca5d1f3f46": "时间线", "d01b3882ba": "通知", "244a0ecd3d": "活动", - "92a9357d1f": "Agent 视图", - "fa72e71f05": "agents", - "4d63251595": "用于 Agent 完成和阻塞状态的螺纹左侧边栏提要。", - "ccc5548ac5": "Agent 查看", + "92a9357d1f": "智能体视图", + "fa72e71f05": "智能体", + "4d63251595": "用于智能体完成和阻塞状态的螺纹左侧边栏提要。", + "ccc5548ac5": "智能体查看", "9af7a518db": "特点", "791fefc0b0": "角落", "65df471ab2": "动画", @@ -6948,13 +6976,13 @@ "6b5a56ac35": "右下角漂浮的动画宠物。", "87d99e634b": "宠物", "agentHibernation": { - "agent": "agent", - "agents": "agents", - "description": "在配置的空闲时间后停止后台 agent 终端,并在再次打开时恢复受支持的会话。Agent 睡眠会保留由 Orca 启动的 agent 的启动选项;手动启动的 agent 可能会使用当前的 Orca 默认值恢复。", + "agent": "智能体", + "agents": "智能体", + "description": "在配置的空闲时间后停止后台智能体终端,并在再次打开时恢复受支持的会话。智能体睡眠会保留由 Orca 启动的智能体的启动选项;手动启动的智能体可能会使用当前的 Orca 默认值恢复。", "minutes": "分钟", "sleep": "睡眠", "terminal": "终端", - "title": "Agent 睡眠" + "title": "智能体睡眠" }, "newWorktreeCardStyle": { "card": "卡片", @@ -6964,8 +6992,8 @@ "metadata": "元数据", "status": "状态", "title": "新卡片样式", - "worktree": "worktree", - "worktrees": "worktrees" + "worktree": "工作树", + "worktrees": "工作树" } } }, @@ -6980,9 +7008,9 @@ "156ffeee08": "笔记", "884e5e6132": "markdown", "49db74a92d": "浏览器", - "6410fe83d8": "terminal", + "6410fe83d8": "终端", "2b5efa55c9": "全球的", - "ebeedb2f6a": "快捷 terminal", + "ebeedb2f6a": "快捷终端", "6f183fa1b9": "浮动码头", "a08e482f6d": "浮动工作区", "b96b5ee6cf": "启用浮动工作区,选择新选项卡的开始位置,然后选择切换按钮的显示位置。", @@ -7011,16 +7039,16 @@ "aea7d2cccb": "openclaude", "95b63edde7": "claude", "41c2f9a025": "默认", - "8ea37a05bc": "agent", - "e2da948f59": "在新工作区编辑器中预先选择 AI 编码 Agent。", - "db11502270": "默认 Agent", + "8ea37a05bc": "智能体", + "e2da948f59": "在新工作区编辑器中预先选择 AI 编码智能体。", + "db11502270": "默认智能体", "3462308bd3": "Token", "660528b048": "成本", "585beac3f8": "TTL", "0efc9d96ad": "提示词", "939b80f5fd": "计时器", "b2601a778c": "缓存", - "40c9585e43": "显示提示词缓存到期倒计时的计时器(Claude agents)。", + "40c9585e43": "显示提示词缓存到期倒计时的计时器(Claude 智能体)。", "1e0f28c6f1": "提示缓存定时器", "e49e739a59": "下载", "c9d8c1ce66": "发行说明", @@ -7029,13 +7057,13 @@ "79ff46776e": "检查应用程序更新并安装较新的 Orca 版本。", "e15af4eb64": "检查更新", "6382fe9724": "恩克斯", - "baa263d6d8": "agents", + "baa263d6d8": "智能体", "bda108e66c": "技能", - "244e3fb4c8": "安装 Orca 技能,以便 Agent 知道如何使用 Orca CLI。", - "2d9f7b42df": "Agent 技能", + "244e3fb4c8": "安装 Orca 技能,以便智能体知道如何使用 Orca CLI。", + "2d9f7b42df": "智能体技能", "0a00691c06": "Shell 命令", "dbeb1f348e": "命令", - "88d3df9ce9": "terminal", + "88d3df9ce9": "终端", "fb4f338a3d": "路径", "924a660a78": "CLI", "ca529079bf": "注册或删除 Orca CLI 命令。", @@ -7094,7 +7122,7 @@ "9da6c875e5": "码头", "b9096a44cf": "https_Agent", "8f03d44672": "http_Agent", - "e3b1d42f95": "Orca 网络请求和本地 terminal 子项的 Agent URL。", + "e3b1d42f95": "Orca 网络请求和本地终端子项的智能体 URL。", "c29f23ab57": "HTTPAgent", "6c2ce8457c": "文件浏览器", "c9d9636f24": "发现者", @@ -7209,7 +7237,7 @@ "16a486a49d": "连接 Linear 以浏览和链接议题。", "b027b4b318": "Linear 集成", "20540996ef": "凭据", - "2ec2bd328c": "api token", + "2ec2bd328c": "API 令牌", "7345b7c3e6": "阿特拉斯语", "e1263dd748": "Jira", "76f6af7c57": "连接 Jira Cloud 或更新 Jira API 令牌凭据。", @@ -7277,9 +7305,9 @@ "bbe4267416": "模拟器类型", "64494f03c3": "模拟器附加", "6f728f1456": "模拟器抽头", - "f8b871d655": "Agent 客户端", + "f8b871d655": "智能体客户端", "2e0b45b2ba": "使用 Orca CLI 命令列出、附加、点击和输入手机模拟器。", - "ea3eac39bb": "Agent CLI 控制", + "ea3eac39bb": "智能体 CLI 控制", "8ef0f08d36": "运行时", "27397fe8e9": "xcode 命令行工具", "7650063d17": "模拟控制", @@ -7293,7 +7321,7 @@ "1dc8c52ffa": "默认 iPhone", "ab4814f3c5": "默认模拟器", "54184cb9c5": "默认模拟器设备", - "b8ddd13195": "Agent 模拟器", + "b8ddd13195": "智能体模拟器", "1ad6fb6230": "默认设备", "ac0a985873": "模拟器技能", "9353854ff3": "Orca 模拟器", @@ -7306,7 +7334,7 @@ "2d67f708ce": "模拟器", "c5eca29310": "ios模拟器", "25159de808": "手机模拟器", - "9595354cff": "配置对 Orca 和编码 Agent 的手机模拟器支持。", + "9595354cff": "配置对 Orca 和编码智能体的手机模拟器支持。", "cdd3c31918": "手机模拟器" } }, @@ -7321,9 +7349,9 @@ "fadcbfdd99": "合身", "ad08035c5f": "手机", "6cd2bfdb0e": "恢复", - "b34ad5b3a7": "terminal", + "b34ad5b3a7": "终端", "6db86f445f": "移动端", - "707fc78052": "选择关闭应用程序或切换离开后,您在手机设备上查看的 terminals 会发生什么情况。", + "707fc78052": "选择关闭应用程序或切换离开后,您在手机设备上查看的终端会发生什么情况。", "1e711aca11": "当您离开手机应用程序时", "126afc5dbd": "偏僻的", "70f505f3c3": "局域网", @@ -7365,7 +7393,7 @@ "cf2c93b479": "一对", "f4ed142753": "手机", "f213400800": "移动端", - "671eb4173c": "通过手机控制 terminal 和 Agent。", + "671eb4173c": "通过手机控制终端和智能体。", "ffd52a96e4": "移动端" } } @@ -7399,15 +7427,15 @@ "96562a72c6": "专注时抑制", "a2ab73b325": "注意力", "ae0487f8fd": "钟", - "c638ae989d": "terminal", - "d3f1c48677": "当后台 terminal 发出响铃字符时发出通知。", - "a5edee1d99": "Terminal 响铃", + "c638ae989d": "终端", + "d3f1c48677": "当后台终端发出响铃字符时发出通知。", + "a5edee1d99": "终端响铃", "193e1f107c": "任务", "dd9d3e5f0f": "idle", "5f7472d3fb": "完全的", - "7fa07e9600": "agent", - "10d83ef8dc": "当编码 Agent 从工作状态转换为空闲状态时发出通知。", - "bdc1edaeb4": "Agent 任务完成", + "7fa07e9600": "智能体", + "10d83ef8dc": "当编码智能体从工作状态转换为空闲状态时发出通知。", + "bdc1edaeb4": "智能体任务完成", "adbc3a0fcf": "本国的", "72539aede4": "系统", "51ae2183e1": "桌面", @@ -7429,11 +7457,11 @@ "eee028ae14": "派遣", "9a5ebdca31": "消息传递", "91fc8ab7e5": "协调", - "13ba5c6cbd": "agents", - "d86705ba77": "多 Agent", + "13ba5c6cbd": "智能体", + "d86705ba77": "多智能体", "a7f76b4ca7": "编排", - "e05ff36753": "通过消息传递、任务 DAG、调度和决策门来协调多个编码 Agent。", - "c34045764e": "Agent 编排" + "e05ff36753": "通过消息传递、任务 DAG、调度和决策门来协调多个编码智能体。", + "c34045764e": "智能体编排" } }, "privacy": { @@ -7477,16 +7505,16 @@ "0b78c4a165": "启动", "2d8aff42be": "跑步", "1c5bdcd0f2": "存储库", - "89d2a9ad9f": "repo", + "89d2a9ad9f": "仓库", "f58b92a48f": "项目", "8bf43c2dad": "全球的", "a26ecdb77b": "片段", "d07d130849": "捷径", - "0073cf8ce9": "terminal", + "0073cf8ce9": "终端", "cfffa6cdb6": "命令", "fecb031823": "命令", "236d4cfac8": "快的", - "d691c4e8d8": "保存的 terminal 命令可以从任何 terminal 启动,范围全局或特定项目。", + "d691c4e8d8": "保存的终端命令可以从任何终端启动,范围全局或特定项目。", "4c8945952b": "快捷命令" } } @@ -7605,8 +7633,8 @@ "keepForkUpToDateDescription": "从 upstream 安全地快进此 Fork。", "projectRuntime": "项目运行时", "projectRuntimeDescription": "选择此项目运行在 Windows 还是 WSL。", - "copy": "copy", - "clone": "clone", + "copy": "复制", + "clone": "克隆", "apfs": "apfs" } }, @@ -7634,16 +7662,16 @@ "shortcuts": { "search": { "ca6a0c2df7": "捷径", - "4811a8264a": "terminal 优先", + "4811a8264a": "终端优先", "afda131738": "Orca 优先", "0ecfc47434": "冲突", - "0f8cb15582": "agent", + "0f8cb15582": "智能体", "f1adebbe8c": "壳", "7f1b38f59a": "推", - "7e3fc707aa": "terminal", + "7e3fc707aa": "终端", "0ecba9aa5f": "键盘", - "ebd7d81e1d": "选择当快捷键重叠时 Orca 或焦点 terminal 是否获胜。", - "f052906167": "Terminal 中的快捷键" + "ebd7d81e1d": "选择当快捷键重叠时 Orca 或焦点终端是否获胜。", + "f052906167": "终端中的快捷键" } }, "ssh": { @@ -7701,7 +7729,7 @@ "10d73e22d3": "剪贴板", "9dfc125cd3": "操作系统52", "62d1208b90": "振荡器52", - "459fea094a": "让 terminal 中的程序通过 OSC 52(包括通过 SSH)复制到系统剪贴板。", + "459fea094a": "让终端中的程序通过 OSC 52(包括通过 SSH)复制到系统剪贴板。", "74db8721e4": "允许 TUI 剪贴板写入 (OSC 52)", "4043e294d2": "gnome", "cf83ac3dbd": "操作系统", @@ -7710,7 +7738,7 @@ "664789b73a": "自动", "c38c18be15": "选择", "797fdfe4ca": "选择", - "603818e8d8": "一旦做出选择,就会自动将 terminal 选择复制到剪贴板。", + "603818e8d8": "一旦做出选择,就会自动将终端选择复制到剪贴板。", "3bdc84f059": "选择时复制" } }, @@ -7732,36 +7760,36 @@ "11fd3fbcf2": "安西", "d8bd6182b8": "覆盖", "674b7c8436": "颜色", - "3023e01415": "覆盖各个 terminal 颜色。", + "3023e01415": "覆盖各个终端颜色。", "aed2a4b4eb": "颜色覆盖", "6eaf7ee0e4": "光标", "34fe1af39d": "打字", "ee611ae238": "隐藏", "ea364ce6e4": "老鼠", - "77201c0bb2": "在 terminal 中输入时隐藏鼠标光标。", + "77201c0bb2": "在终端中输入时隐藏鼠标光标。", "d1fe5f99ff": "打字时隐藏鼠标", "f25d948664": "利润", "b2f52cb96c": "间距", "e8baf0d12c": "填充", - "4655567c37": "terminal 网格周围的垂直填充(以像素为单位)。", + "4655567c37": "终端网格周围的垂直填充(以像素为单位)。", "692c4ad032": "垂直内边距", - "75691e4911": "terminal 网格周围的水平填充(以像素为单位)。", + "75691e4911": "终端网格周围的水平填充(以像素为单位)。", "b4f182f24d": "水平填充", "6c2f9f05c8": "活力", "4f7f8f28ca": "透明度", "f6dd9ff606": "背景", "71eb45e293": "模糊", "0838b3717b": "窗户", - "bc2054657a": "将背景模糊应用到 terminal 窗口。需要重新启动。", + "bc2054657a": "将背景模糊应用到终端窗口。需要重新启动。", "72d0482137": "窗口模糊", "7db59c4738": "阿尔法", "46d99ef4bb": "不透明度", - "4c643695aa": "控制 terminal 背景的透明度。", + "4c643695aa": "控制终端背景的透明度。", "b36fd2416d": "背景不透明度", "d4daf4f612": "解冻", "88561b3499": "冷冻的", "0a05629060": "恢复", - "f66a7cf715": "terminal", + "f66a7cf715": "终端", "6892fb1019": "重新启动", "cde233f5da": "回滚", "3982d88725": "历史", @@ -7772,13 +7800,13 @@ "d802a578bf": "会话", "9f2dda133c": "普蒂", "f35400f7e8": "守护进程", - "f72abc493c": "通过终止会话、清除保存的回滚或重新启动守护程序,从冻结的 terminals 中恢复。", + "f72abc493c": "通过终止会话、清除保存的回滚或重新启动守护程序,从冻结的终端中恢复。", "6f5d486a68": "管理会话", "10f9fb6fea": "设置", "2ade3ea490": "配置", "fd752b3cac": "导入", "82b63d07fe": "Ghostty", - "73e9422f19": "一次性导入受支持的 Ghostty terminal 设置。", + "73e9422f19": "一次性导入受支持的 Ghostty 终端设置。", "a979df0083": "从幽灵导入", "4cec42dbf7": "国际", "b495dc6a9f": "吉斯", @@ -7809,7 +7837,7 @@ "957a0203fc": "单词分隔符", "56fff3d113": "记忆", "fffdff40a7": "缓冲", - "f7d56b6281": "最大 terminal 回滚缓冲区大小。", + "f7d56b6281": "最大终端回滚缓冲区大小。", "7674e758e1": "回滚大小", "411229c636": "浅色", "781f49d942": "分隔线", @@ -7819,19 +7847,19 @@ "1dee533bd9": "选择 Orca 处于浅色模式时使用的主题。", "1d89457764": "浅色主题", "da864e6cec": "灯光模式", - "f268092ee3": "禁用后,浅色模式会重用深色 terminal 主题。", - "232e532169": "在浅色模式下使用单独的主题", + "f268092ee3": "浅色模式可以使用自己的终端主题。", + "match_dark_mode_title": "匹配深色模式", "f785374072": "深色", "9c32726f47": "控制深色模式下窗格之间的分割分隔线。", "8987db7ff2": "深色分隔线颜色", - "13f6310dd3": "选择深色模式下使用的 terminal 主题。", + "13f6310dd3": "选择深色模式下使用的终端主题。", "ec07ce9b02": "黑暗主题", "f036794286": "活跃", "846a7a1204": "窗格", "d1fa00a9cb": "徘徊", "b5116e7b12": "如下", "f5d1e3d472": "重点", - "17cc3ea102": "将鼠标悬停在 terminal 窗格上即可将其激活,无需单击。反映 Ghostty 的焦点跟随鼠标设置。选择和窗口切换保持安全。", + "17cc3ea102": "将鼠标悬停在终端窗格上即可将其激活,无需单击。反映 Ghostty 的焦点跟随鼠标设置。选择和窗口切换保持安全。", "c6178a2b4d": "焦点跟随鼠标", "f637a7dee9": "厚度", "e58d4040d0": "窗格分隔线的厚度。", @@ -7839,7 +7867,7 @@ "6c4c85ba43": "调光", "18dd5026c6": "不透明度应用于当前不活动的窗格。", "72bbcbd1dd": "非活动窗格不透明度", - "d4f7d1ce5c": "terminal 光标的不透明度。", + "d4f7d1ce5c": "终端光标的不透明度。", "7f1e356a54": "Cursor 不透明度", "25f606d9e5": "眨", "a27f6edf52": "使用所选光标形状的闪烁变体。", @@ -7856,7 +7884,7 @@ "6cddc858ba": "网页GL", "4b4e80d850": "加速度", "db82cb13b0": "图形处理器", - "8f9f953de7": "控制 terminal 是否使用 xterm.js WebGL 渲染。当渲染器受支持时,自动尝试 WebGL,并为软件或未知 GPU 渲染器提供保守的后备方案。", + "8f9f953de7": "控制终端是否使用 xterm.js WebGL 渲染。当渲染器受支持时,自动尝试 WebGL,并为软件或未知 GPU 渲染器提供保守的后备方案。", "13a2502dfc": "GPU加速", "d5e6c7fab1": "字体特性", "a16224d16a": "calt", @@ -7870,35 +7898,41 @@ "893aa92997": "为发布它们的字体渲染编程连字(例如 => → ≠ ≥)。 “自动”仅对已知的连字字体(Fira Code、JetBrains Mono、Cascadia Code、Iosevka 等)启用连字。", "58da1ae45d": "字体连字", "7341e3d00e": "行高", - "36a1b38bc8": "控制 terminal 线高度乘数。", + "36a1b38bc8": "控制终端线高度乘数。", "0f2fb0cb74": "行高", "20ce287cc6": "重量", - "98c18f2c77": "控制 terminal 文本字体粗细。", + "98c18f2c77": "控制终端文本字体粗细。", "28ea41bd2d": "字体粗细", "b0bb76ae6b": "字体", - "0acdc17891": "新窗格和实时更新的默认 terminal 字体系列。", + "0acdc17891": "新窗格和实时更新的默认终端字体系列。", "e989914ad6": "字体家族", "33031c1465": "文字大小", - "0fe0073f0c": "新窗格和实时更新的默认 terminal 字体大小。", + "0fe0073f0c": "新窗格和实时更新的默认终端字体大小。", "5930244899": "字体大小", "warp_import": { - "title": "从 Warp 导入主题", - "description": "将 Warp 主题导入为 Orca terminal 主题。", + "title": "从 Warp 导入", + "description": "将 Warp 主题导入为 Orca 终端主题。", "keyword_warp": "warp", "keyword_themes": "themes", - "keyword_yaml": "yaml" + "keyword_yaml": "yaml", + "keyword_legacy_title": "Import themes from Warp" }, "yaml_import": { "title": "从 YAML 导入", - "description": "将主题 YAML 文件导入为 Orca terminal 主题。", + "description": "将主题 YAML 文件导入为 Orca 终端主题。", "keyword_yaml": "yaml", "keyword_custom": "自定义" }, - "ask_before_closing_running_terminals_title": "关闭运行中的 Terminal 前询问", - "ask_before_closing_running_terminals_description": "在关闭有运行中命令或 agent 的 terminal 前显示确认。", + "ask_before_closing_running_terminals_title": "关闭运行中的终端前询问", + "ask_before_closing_running_terminals_description": "在关闭有运行中命令或智能体的终端前显示确认。", "scrollSpeed": { "title": "滚动速度", "description": "调整普通终端回滚、修饰键快速滚动以及全屏 TUI 滚轮速度。" + }, + "theme_target": { + "title": "主题模式", + "keyword_target": "目标", + "keyword_editing": "编辑" } }, "windows": { @@ -7907,8 +7941,8 @@ "fcfa53920b": "粘贴", "e55186fe2b": "右键单击", "28ff08ed35": "视窗", - "e7d2793b03": "terminal", - "8ba875c132": "在 Windows 上,右键单击将剪贴板粘贴到 terminal 中。使用 Ctrl+右键单击打开上下文菜单。", + "e7d2793b03": "终端", + "8ba875c132": "在 Windows 上,右键单击将剪贴板粘贴到终端中。使用 Ctrl+右键单击打开上下文菜单。", "f0b8448570": "右键单击粘贴", "04994f6929": "默认", "fc564eadaf": "德比安", @@ -7933,7 +7967,7 @@ "12519edb5d": "命令提示符", "6cd20b9e64": "指令", "7c7056940a": "壳", - "713c4a2f92": "为 Windows 上的新 terminal 窗格选择默认 shell。", + "713c4a2f92": "为 Windows 上的新终端窗格选择默认 shell。", "13715f9d23": "默认外壳" } } @@ -7974,31 +8008,31 @@ "options": { "commitMessage": "从分阶段更改生成 commit 消息。", "pullRequest": "生成托管评论标题和描述。", - "branchName": "重命名 Orca 从初始 Agent 任务创建的分支。", - "fixCommitFailure": "当 commit 挂钩或 git commit 失败时启动 Agent。", - "fixChecks": "从失败的托管评审检查中启动 agent。", - "resolveConflicts": "启动用于解决本地或托管评审合并冲突的 agent。", + "branchName": "重命名 Orca 从初始智能体任务创建的分支。", + "fixCommitFailure": "当 commit 挂钩或 git commit 失败时启动智能体。", + "fixChecks": "从失败的托管评审检查中启动智能体。", + "resolveConflicts": "启动用于解决本地或托管评审合并冲突的智能体。", "customCommand": "自定义命令", - "supportedAgents": "此配方支持的 agent:{{value0}}。", - "unsupportedSavedAgent": "{{value0}} 无法运行此文本生成配方。请在下方选择一个支持的 agent。", - "resolveComments": "从选中的未解决 PR 或 MR 评论启动 agent。" + "supportedAgents": "此配方支持的智能体:{{value0}}。", + "unsupportedSavedAgent": "{{value0}} 无法运行此文本生成配方。请在下方选择一个支持的智能体。", + "resolveComments": "从选中的未解决 PR 或 MR 评论启动智能体。" } } } } }, "agent-awake-copy": { - "e5995ce268": "Agent 工作时保持电脑唤醒", - "95d3031db2": "在 Agent 工作时保持此电脑和显示器唤醒。合盖行为遵循此设备的电源设置。", - "a42f6fbdd8": "在 Agent 工作时保持此电脑和显示器唤醒。Orca 还会根据电源策略,在合盖时请求此设备保持唤醒。" + "e5995ce268": "智能体工作时保持电脑唤醒", + "95d3031db2": "在智能体工作时保持此电脑和显示器唤醒。合盖行为遵循此设备的电源设置。", + "a42f6fbdd8": "在智能体工作时保持此电脑和显示器唤醒。Orca 还会根据电源策略,在合盖时请求此设备保持唤醒。" }, "agent-status-hooks-copy": { - "7707c15abb": "Agent 状态钩子", + "7707c15abb": "智能体状态钩子", "a68a642835": "在 Orca 中显示工作中、等待中和完成状态。关闭后将移除 Orca 管理的钩子并停止重新安装。" }, "agent-generated-tab-title-copy": { "19ad21615a": "自动生成标签页标题", - "b036c7a409": "根据第一个已知 Agent 提示词生成简短稳定的标签页名称。手动重命名始终优先。" + "b036c7a409": "根据第一个已知智能体提示词生成简短稳定的标签页名称。手动重命名始终优先。" }, "keep": { "local": { @@ -8016,10 +8050,10 @@ } }, "WarpThemeImportModal": { - "title": "从 Warp 导入主题", - "description": "将 Warp 主题导入为 Orca terminal 主题。", + "title": "从 Warp 导入", + "description": "将 Warp 主题导入为 Orca 终端主题。", "yaml_title": "导入主题 YAML", - "yaml_description": "将主题 YAML 文件(Warp 格式)导入为 Orca terminal 主题。", + "yaml_description": "将主题 YAML 文件(Warp 格式)导入为 Orca 终端主题。", "yaml_no_themes_found": "所选文件中未找到主题。", "choose_file": "选择文件", "choose_folder": "选择文件夹", @@ -8032,7 +8066,7 @@ "colors_only": "仅颜色", "no_themes_found": "未找到自定义 Warp 主题。", "builtin_themes_hint": "Warp 的预装主题是 Warp 应用的一部分,无法从磁盘读取。Orca 已包含其中大多数,如 Dracula、Gruvbox、Solarized 和 Tokyo Night。", - "custom_theme_yaml_hint": "自定义和社区主题必须以 YAML 文件形式存在于 Warp themes 文件夹中,自动导入才能发现它们。如果你克隆了 Warp 的公开主题 repo,请使用 Choose Folder 导入该 repo 副本。", + "custom_theme_yaml_hint": "自定义和社区主题必须以 YAML 文件形式存在于 Warp themes 文件夹中,自动导入才能发现它们。如果你克隆了 Warp 的公开主题仓库,请使用 Choose Folder 导入该仓库副本。", "choose_manually": "选择主题 YAML 文件或文件夹以手动导入。", "skipped_files": "已跳过文件", "more_skipped_files": "还有 {{value0}} 个已跳过的文件。", @@ -8046,8 +8080,8 @@ "imported_one": "已导入 1 个主题", "imported_other": "已导入 {{value0}} 个主题", "import_failed": "导入主题失败", - "over_limit_one": "导入这些主题会超出 {{value0}} 个自定义 terminal 主题的上限。请取消选择 1 个新主题后重试。", - "over_limit_other": "导入这些主题会超出 {{value0}} 个自定义 terminal 主题的上限。请取消选择 {{value1}} 个新主题后重试。" + "over_limit_one": "导入这些主题会超出 {{value0}} 个自定义终端主题的上限。请取消选择 1 个新主题后重试。", + "over_limit_other": "导入这些主题会超出 {{value0}} 个自定义终端主题的上限。请取消选择 {{value1}} 个新主题后重试。" }, "YamlThemeImportButton": { "label": "从 YAML 导入" @@ -8060,17 +8094,17 @@ "d5b3be8ecd": "重新检查", "8cbc39f862": "了解更多", "707180d09c": "glab auth login", - "4be0616873": "GitLab CLI 已安装但未通过身份验证。请在 terminal 中运行此命令:", - "54a640af7a": "Install GitLab CLI", - "b56fd5676a": "Install the GitLab CLI to enable merge requests, issues, and pipelines.", + "4be0616873": "GitLab CLI 已安装但未通过身份验证。请在终端中运行此命令:", + "54a640af7a": "安装 GitLab CLI", + "b56fd5676a": "安装 GitLab CLI 以启用合并请求、议题和流水线。", "faddeb763d": "此运行时暂不支持 GitLab CLI 状态。", "a47f71e357": "CLI。", "2a6b359e75": "glab", "1f2b347bd3": "通过", "8d90249d22": "gh auth login", - "2e44dda68a": "GitHub CLI 已安装但未通过身份验证。请在 terminal 中运行此命令:", - "7755c28af5": "Install GitHub CLI", - "23cb5a0dee": "Install the GitHub CLI to enable pull requests, issues, and checks.", + "2e44dda68a": "GitHub CLI 已安装但未通过身份验证。请在终端中运行此命令:", + "7755c28af5": "安装 GitHub CLI", + "23cb5a0dee": "安装 GitHub CLI 以启用拉取请求、议题和检查。", "6f30fc4216": "此运行时暂不支持 GitHub CLI 状态。", "6b2cfb52b4": "gh", "b4d900e7f1": "通过", @@ -8089,7 +8123,7 @@ "8b2408a8e5": "此运行时已连接 Jira。如果已连接的站点列表看起来过期,请重新检查。", "8c20e76308": "每个已连接的 Jira 站点都由活动运行时存储一个 token。", "c24e56c532": "测试", - "3e7c10d286": "Testing...", + "3e7c10d286": "测试中...", "a2c0015fb8": "已验证", "e2ff968276": "连接 Jira", "60996beda6": "添加 Jira 站点", @@ -8141,7 +8175,7 @@ "6154b02093": "Bitbucket 凭据已配置但无法通过身份验证。请检查 token 和仓库权限;如果环境变量已更改,请重启 Orca。", "e63fe8f627": "ORCA_BITBUCKET_ACCESS_TOKEN", "19416c874c": "ORCA_BITBUCKET_API_TOKEN", - "fc71a0e7aa": "and", + "fc71a0e7aa": "和", "63a7f47392": "ORCA_BITBUCKET_EMAIL", "24ac1c69dc": "此运行时暂不支持 Bitbucket 状态。", "a924e8dcd1": "通过 Bitbucket Cloud API token 获取 pull request 和构建状态。", @@ -8152,13 +8186,13 @@ } }, "computerUseSummary": { - "permissionsRequired": "在 agent 可操作应用窗口前需要 {{value0}} 项权限{{value1}}。", + "permissionsRequired": "在智能体可操作应用窗口前需要 {{value0}} 项权限{{value1}}。", "checkingTitle": "正在检查计算机控制访问权限。", "checkingDescription": "Orca 正在检查计算机控制助手的 macOS 隐私权限。", "unavailableTitle": "计算机控制不可用。", "unavailableDescription": "Computer Use permissions are unavailable because {{value0}}.", "readyTitle": "计算机控制已就绪。", - "readyDescription": "当你要求时,Agent 可以检查并操作应用窗口。", + "readyDescription": "当你要求时,智能体可以检查并操作应用窗口。", "permissionsTitle": "完成设置以使用本地应用。" }, "computerUseSkillRuntime": { @@ -8186,12 +8220,12 @@ }, "settingOwnership": { "clientDefault": "客户端默认", - "sourceControlAiDefaults": "方案、提示和托管评审默认值由此客户端共享;模型选择和发现则限定在 agent 运行的主机内。", + "sourceControlAiDefaults": "方案、提示和托管评审默认值由此客户端共享;模型选择和发现则限定在智能体运行的主机内。", "projectOnThisHost": "此主机上的项目", "repositorySourceControlAi": "这些覆盖项应用于此项目设置,并继承客户端的源代码管理 AI 默认值,直至自定义。", - "agentLaunchDefaults": "默认 agent、命令覆盖、CLI 参数和启动环境是客户端偏好。SSH 和远程服务器启动仍会在运行时验证主机可用性。", + "agentLaunchDefaults": "默认智能体、命令覆盖、CLI 参数和启动环境是客户端偏好。SSH 和远程服务器启动仍会在运行时验证主机可用性。", "clientDefaultProjectScopes": "客户端默认 + 项目范围", - "terminalQuickCommands": "命令保存在此客户端,然后限定为全局或某项目设置,以便从所选 terminal 上下文运行。", + "terminalQuickCommands": "命令保存在此客户端,然后限定为全局或某项目设置,以便从所选终端上下文运行。", "hostOverride": "主机覆盖", "workspaceDirectory": "在主机需要自己的工作树目录之前,继承客户端默认值。", "providerHost": "提供商主机", @@ -8240,7 +8274,7 @@ "windows": "Windows", "wsl": "WSL", "selectDistro": "选择发行版", - "runtimeChangeHelp": "运行时更改应用于此项目的新 terminal、agent 检查和技能发现。已有 terminal 保留其当前运行时。", + "runtimeChangeHelp": "运行时更改应用于此项目的新终端、智能体检查和技能发现。已有终端保留其当前运行时。", "wslUnavailable": "WSL 不可用。请将此项目切换到 Windows 或修复 WSL。", "distroMissing": "WSL 中未安装 {{value0}}。请选择已安装的发行版,或将此项目切换到 Windows。", "distroRequired": "选择一个 WSL 发行版,或将此项目切换到 Windows。", @@ -8255,7 +8289,7 @@ "runtimeSessionJoin": "{{value0}}和{{value1}}", "runtimeSessionWarning": "{{value0}}会继续在当前运行时中运行。继续前请等待任务完成或重启终端。", "pendingRuntimeChange": "运行时更改待处理。应用后,新项目工作将使用所选运行时。", - "cancel": "Cancel", + "cancel": "取消", "applyRuntimeChange": "应用运行时更改" }, "PrivacyDiagnosticsRows": { @@ -8304,8 +8338,8 @@ "60ed678138": "已选择" }, "ChecksPanel": { - "2ef90c9819": "已启动 AI Agent 处理失败的检查。", - "a0181a8d76": "已启动 AI Agent 处理冲突。", + "2ef90c9819": "已启动 AI 智能体处理失败的检查。", + "a0181a8d76": "已启动 AI 智能体处理冲突。", "34464d00b9": "已更新", "058039787c": "取消", "2ab7fd4b6d": "保存", @@ -8314,7 +8348,7 @@ "b5dd73a105": "选择一个工作区以查看检查", "a4ef4e0832": "未选择工作区", "5594400d73": "没有需要修复的失败检查。", - "abf59262fb": "在启动 Agent 之前查看并编辑完整的命令输入。", + "abf59262fb": "在启动智能体之前查看并编辑完整的命令输入。", "4ede779461": "使用 AI 解决评审冲突", "3b203c62f8": "这将从 PR 中永久删除该评论。", "ea9b649ce3": "删除评论?", @@ -8337,11 +8371,11 @@ "653c105ecc": "更多 PR 操作", "f316a8ca2b": "未选择未解决的评论。", "d00ebdc402": "使用 AI 解决 {{value0}} 评论", - "ed3f79c031": "启动 agent 前请检查提示词。启动后,选中的线程会被标记为已解决。", - "f273f2271c": "已启动 agent。已标记 {{value0}} 个为已解决,跳过 {{value1}} 个,失败 {{value2}} 个。", - "aa95b81a3a": "已启动 agent。已标记 {{value0}} 个为已解决,跳过 {{value1}} 个,失败 {{value2}} 个。", - "495b2f8c4b": "已启动 agent,但无法将选中的评论标记为已解决。", - "3c3ad3a1d2": "已启动 agent。选中的评论中没有可在托管平台上标记为已解决的评论。" + "ed3f79c031": "启动智能体前请检查提示词。启动后,选中的线程会被标记为已解决。", + "f273f2271c": "已启动智能体。已标记 {{value0}} 个为已解决,跳过 {{value1}} 个,失败 {{value2}} 个。", + "aa95b81a3a": "已启动智能体。已标记 {{value0}} 个为已解决,跳过 {{value1}} 个,失败 {{value2}} 个。", + "495b2f8c4b": "已启动智能体,但无法将选中的评论标记为已解决。", + "3c3ad3a1d2": "已启动智能体。选中的评论中没有可在托管平台上标记为已解决的评论。" }, "CreatePullRequestDialog": { "2bc1b4345e": "取消", @@ -8592,14 +8626,14 @@ "37a81f29ad": "生成 commit 消息。单击停止。", "b94112eb9e": "Commit 消息", "0d0a8359d3": "信息", - "15b7f210d7": "选择 Agent 并在启动前编辑完整的命令输入。", + "15b7f210d7": "选择智能体并在启动前编辑完整的命令输入。", "054ead86b1": "使用 AI 修复 Commit 失败", "9e5ccd00aa": "Commit 失败上下文不可用", "f0a2dc9e46": "自定义启动...", - "ec7bfced55": "选择 Agent 来修复 commit 失败", - "dd43c47089": "选择针对此 commit 失败的 Agent", + "ec7bfced55": "选择智能体来修复 commit 失败", + "dd43c47089": "选择针对此 commit 失败的智能体", "30b8d4f181": "使用 AI 修复 commit 失败", - "4b37ae99b0": "启动默认的AIAgent 来修复此 commit 失败", + "4b37ae99b0": "启动默认 AI 智能体来修复此 commit 失败", "ae743199cd": "在创建 {{value0}} 之前,请选择其他基础分支。", "318e2a7f88": "请等待 AI 生成完成。", "f76307c1f7": "请选择基础分支。", @@ -8620,11 +8654,11 @@ "b355e740b2": "停止生成 {{value0}} 详细信息", "527e130b6f": "停止生成", "e1970d327d": "新建 {{value0}}", - "f4c766f1ca": "为此运行选择 Agent 和命令模板。", + "f4c766f1ca": "为此运行选择智能体和命令模板。", "1a6a6e0bc5": "生成托管评论详细信息", "6b122529d4": "生成 Commit 消息", - "e48caaf0dd": "已启动 AI Agent 处理冲突。", - "901140f47d": "在启动 Agent 之前查看并编辑完整的命令输入。", + "e48caaf0dd": "已启动 AI 智能体处理冲突。", + "901140f47d": "在启动智能体之前查看并编辑完整的命令输入。", "19652ddd76": "用 AI 解决冲突", "c9ad22888e": "选择此存储库的分支比较目标。", "574d2f4413": "清晰的笔记", @@ -8762,7 +8796,7 @@ "e9c2a5d416": "与 {{value0}} 同步" }, "SourceControlAgentActionDialog": { - "8e856842d1": "无法启动选定的 Agent。", + "8e856842d1": "无法启动选定的智能体。", "c075d00de1": "无法解析工作区连接。", "808cfe0a3b": "仅保存到此存储库", "994cddd1f7": "不保存", @@ -8775,22 +8809,22 @@ "fe119187bb": "——模范十四行诗", "bc8dc39f4b": "CLI 参数", "b99c33cec5": "设置", - "15c5d85706": "Agent", + "15c5d85706": "智能体", "3e8f21954f": "错误", - "74168d7ada": "idle", - "1d47db9bf0": "没有启用 Agent", + "74168d7ada": "空闲", + "1d47db9bf0": "没有启用智能体", "c7ff8cef11": "检测剂...", "013c9ac04a": "保存范围", "1bb611240f": "Use {basePrompt} for Orca's default prompt.", - "23280cbab1": "This template does not include {basePrompt}, so the agent will not receive Orca's default prompt.", - "5421a96acb": "保存并启动 Agent", - "6cefcdfba1": "You can change it later in Source Control AI settings.", - "c29f9cf266": "Save this prompt and don't show this review next time", + "23280cbab1": "此模板不包含 {basePrompt},因此智能体不会收到 Orca 的默认提示词。", + "5421a96acb": "保存并启动智能体", + "6cefcdfba1": "稍后可以在源代码管理 AI 设置中更改。", + "c29f9cf266": "保存此提示,下次不再显示此评审", "d8f40128ee": "{basePrompt} is Orca's default prompt.", "ea4788705e": "取消", "b0da3a4d3e": "启动配方已保存", - "bff4795a6d": "更改 Agent、参数或提示词模板以更新已保存的配方。", - "5c75b24735": "自定义 Orca 启动 Agent 前发送给它的内容。" + "bff4795a6d": "更改智能体、参数或提示词模板以更新已保存的配方。", + "5c75b24735": "自定义 Orca 启动智能体前发送给它的内容。" }, "SourceControlTextGenerationDialog": { "c5b7fa7cb6": "保存为全局默认值", @@ -8805,8 +8839,8 @@ "551ffd111b": "——模范十四行诗", "4eab815004": "CLI 参数", "914c8f6ac2": "自定义命令", - "cce2cbd01d": "选择 Agent", - "9c14186dd2": "Agent" + "cce2cbd01d": "选择智能体", + "9c14186dd2": "智能体" }, "activity": { "bar": { @@ -8873,7 +8907,7 @@ "b652f38caf": "检查失败", "87cd07c69a": "该分支存在必须解决的冲突", "0975eeaaef": "冲突的文件", - "6fa7f8723f": "commit", + "6fa7f8723f": "提交", "2b2be92919": "添加评论", "7440d09d2c": "开始对话", "b37ebdc51c": "无法发表评论。", @@ -8886,28 +8920,28 @@ "066fedd446": "失败的工作", "ae8a04ef17": "冲突文件详细信息不可用", "73d0675356": "刷新冲突细节...", - "f5bc5c4cf1": "The hosting provider reports conflicts, but local Git did not reproduce them. Refresh the review or push the branch to recalculate mergeability.", - "5bc9bda2af": "Run from this worktree", - "e87fb3d929": "Copy mergeability refresh commands", - "1e53e45072": "Copied", - "084c516efb": "Copy commands", + "f5bc5c4cf1": "托管提供方报告存在冲突,但本地 Git 未能复现。请刷新评审或推送分支以重新计算可合并性。", + "5bc9bda2af": "从此工作树运行", + "e87fb3d929": "复制可合并性刷新命令", + "1e53e45072": "已复制", + "084c516efb": "复制命令", "5dc3af25c0": "选择评论", "d7a2f9c401": "Send unresolved {{value0}} comments", "d91f2a6c39": "发送 {{value0}} 条已排队评论给 AI", "a6de3e5a20": "清除已排队评论", "49ea0937e4": "将评论添加到解决列表", "9fecebb29d": "添加", - "b8c4e2a1f7": "View full logs", - "f8a2c91d04": "Queue for agent", - "b4e8a1c902": "Queued", - "7c1f0a2b11": "Open", + "b8c4e2a1f7": "查看完整日志", + "f8a2c91d04": "加入智能体队列", + "b4e8a1c902": "已加入队列", + "7c1f0a2b11": "打开", "e8b4c1a903": "Resolved · {{value0}}", "c3a8e5d710": "Needs review · {{value0}}", - "a7f0c7e8d1": "Queue", - "8a621a2c4f": "Grouped", - "b13f85d75c": "Timeline", - "f5cf324efa": "Comment display options", - "5e6e5a13fa": "View" + "a7f0c7e8d1": "加入队列", + "8a621a2c4f": "已分组", + "b13f85d75c": "时间线", + "f5cf324efa": "评论显示选项", + "5e6e5a13fa": "查看" }, "empty": { "state": { @@ -8971,7 +9005,7 @@ "6306b48afd": "源代码控制", "ef182dcb12": "搜索", "fc3095d2ed": "探险家", - "aiVaultSessionHistory": "Agents", + "aiVaultSessionHistory": "智能体", "folderWorkspaces": "已附加的工作树", "parentPrChecks": "PR 检查" }, @@ -8997,12 +9031,12 @@ "commit": { "failure": { "launch": { - "a8b97d2318": "针对 commit 失败启动了 AI Agent。", - "5540ff50cc": "无法构建 Agent 启动命令。", - "9bbd9077a2": "没有启用的 AI Agent。在“设置”中配置 Agent。", - "d481ab22f9": "已保存的 AI Agent 不可用。使用自定义启动来选择另一个 Agent。", + "a8b97d2318": "针对 commit 失败启动了 AI 智能体。", + "5540ff50cc": "无法构建智能体启动命令。", + "9bbd9077a2": "没有启用的 AI 智能体。在“设置”中配置智能体。", + "d481ab22f9": "已保存的 AI 智能体不可用。使用自定义启动来选择另一个智能体。", "f2b47026e8": "Commit 失败提示为空。更新源代码管理 AI 设置。", - "4f4e0418a0": "无法构建 Agent 提示符。", + "4f4e0418a0": "无法构建智能体提示符。", "216f762bd7": "无法解析工作区连接。" } } @@ -9038,7 +9072,7 @@ "226b85a3a7": "fetch", "323bb614aa": "Commit 并同步", "2b8e6595fd": "Commit", - "04d709801d": "Fetch from remote without merging" + "04d709801d": "从远程获取但不合并" } }, "primary": { @@ -9071,7 +9105,7 @@ "8c6d15a07d": "创建 PR", "d37e68f61d": "正在准备分支以供评审…", "c72e5e65d1": "准备此分支并创建 {{value0}}", - "b8e4f2a901": "Wait for the remote operation to finish.", + "b8e4f2a901": "等待远程操作完成。", "c9f3a1b802": "Resolve conflicts before creating a {{value0}}.", "d2a8c4e703": "No changes on this branch to include in a {{value0}}.", "e3b9d5f814": "Cannot create a {{value0}} from the default branch.", @@ -9103,8 +9137,8 @@ "d979a4fbb5": "永久删除“{{value0}}”?", "a76c74f105": "destructive", "92276aceb7": "删除", - "7fb9435c86": "This permanently deletes the directory and its contents on the remote host. This cannot be undone.", - "23e98f192f": "This permanently deletes the file on the remote host. This cannot be undone." + "7fb9435c86": "这会永久删除远程主机上的目录及其内容。此操作无法撤销。", + "23e98f192f": "这会永久删除远程主机上的文件。此操作无法撤销。" }, "useFileExplorerHandlers": { "32cd9fd991": "无法打开符号链接目标" @@ -9131,13 +9165,13 @@ } }, "AiVaultPanel": { - "resumeCommandCopied": "Resume command copied", - "valueCopied": "{{value0}} copied", + "resumeCommandCopied": "恢复命令已复制", + "valueCopied": "{{value0}} 已复制", "valueCopyFailed": "无法复制 {{value0}}", - "openWorkspaceBeforeResuming": "Open a workspace before resuming a session.", - "localWorkspacesOnly": "Resume from history is only available in local workspaces.", + "openWorkspaceBeforeResuming": "恢复会话前请先打开一个工作区。", + "localWorkspacesOnly": "从历史记录恢复仅适用于本地工作区。", "agentSessionQueued": "{{value0}} 会话已排队", - "sessionHistory": "Agent 会话历史", + "sessionHistory": "智能体会话历史", "shownRecent": "已显示 {{value0}} 项 · 最近 {{value1}} 项", "resumePastSessions": "恢复过往会话", "refreshSessionHistory": "刷新会话历史", @@ -9150,7 +9184,9 @@ "sessionId": "会话 ID", "logPath": "日志路径", "agents": "智能体", - "sessionsShownCompact": "已显示 {{value0}} 个" + "sessionsShownCompact": "已显示 {{value0}} 个", + "originalPaneUnavailable": "Original pane is no longer available.", + "worktreeUnavailable": "Worktree is no longer available." }, "AiVaultPanelControls": { "scanningSessions": "正在扫描会话", @@ -9165,13 +9201,13 @@ "allSessions": "所有会话", "viewOptionsAriaLabel": "会话历史查看选项", "viewOptions": "查看选项", - "agents": "Agents", + "agents": "智能体", "sort": "排序", "lastUpdated": "最后更新", "created": "创建时间", "group": "分组", "folder": "文件夹", - "agent": "Agent", + "agent": "智能体", "resetView": "重置视图", "hideEmptySessions": "隐藏空会话", "workspaceScope": "工作区", @@ -9198,6 +9234,8 @@ "resumeCommand": "恢复命令", "sessionActions": "{{value0}} 会话操作", "resumeInNewTab": "在新标签页中恢复", + "resumeInWorktree": "在工作树中恢复", + "viewLog": "查看日志", "copyResumeCommand": "复制恢复命令", "openLog": "打开日志", "revealLog": "显示日志", @@ -9226,7 +9264,10 @@ "agentRole": "智能体", "toolRole": "工具", "systemRole": "系统", - "sessionRole": "会话" + "sessionRole": "会话", + "jumpToOriginalPane": "跳转到原始窗格", + "worktree": "工作树", + "jumpToWorktree": "跳转到工作树" }, "AiVaultSessionRow": { "resumeAgentSession": "恢复 {{value0}} 会话", @@ -9250,7 +9291,9 @@ "agentRole": "智能体", "toolRole": "工具", "systemRole": "系统", - "sessionRole": "会话" + "sessionRole": "会话", + "jumpToOriginalPane": "跳转到原始窗格", + "jumpToWorktree": "跳转到工作树" }, "FileExplorerNameFilter": { "26fb73c6e3": "查找文件", @@ -9280,20 +9323,31 @@ "pull": { "policy": { "notice": { - "merge": "Merge", - "mergeDescription": "Create a merge commit when local and remote both changed.", - "rebase": "Rebase", - "rebaseDescription": "Replay local commits on top of the remote branch.", - "fastForwardOnly": "Fast-forward only", - "fastForwardOnlyDescription": "Only pull when no merge or rebase is needed.", - "title": "Pull needs a policy", - "diverged": "Diverged", - "body": "This branch has local and remote commits. Run one command in this worktree or on the SSH host, then try Pull or Sync again.", - "copyAria": "Copy {{value0}} pull policy command", - "copied": "Copied", - "copyCommand": "Copy command" + "merge": "合并 (Merge)", + "mergeDescription": "当本地和远程都发生更改时,创建一个合并提交。", + "rebase": "变基 (Rebase)", + "rebaseDescription": "在远程分支之上重新应用本地提交。", + "fastForwardOnly": "仅快进 (Fast-forward)", + "fastForwardOnlyDescription": "仅在不需要合并或变基时进行拉取。", + "title": "拉取需要策略", + "diverged": "分支已分叉", + "body": "此分支具有本地和远程提交。在此工作树或 SSH 主机上运行一个命令,然后重新尝试拉取或同步。", + "copyAria": "复制 {{value0}} 拉取策略命令", + "copied": "已复制", + "copyCommand": "复制命令" } } + }, + "AiVaultSessionWorktree": { + "currentWorktree": "当前工作树", + "activeWorktree": "活动工作树", + "archivedWorktree": "已归档工作树", + "unavailableWorktree": "不可用的工作树", + "jumpToWorktree": "跳转到工作树", + "noRecordedWorktree": "此会话没有记录工作树。", + "archivedJumpUnavailable": "此会话位于已归档的工作树中。", + "noActiveWorktreeMatch": "没有与此会话匹配的活动工作树。", + "noActiveWorktreeTarget": "没有可用的活动工作树。" } } }, @@ -9335,8 +9389,8 @@ "3c5a593bc8": "网络", "477b28c948": "数据库", "787490e9bd": "包裹", - "07012dc113": "Agent", - "3eba7387ab": "Terminal", + "07012dc113": "智能体", + "3eba7387ab": "终端", "65b437c381": "代码", "bed2674f9d": "文件夹" } @@ -9357,26 +9411,26 @@ }, "onboarding": { "AgentStep": { - "e6a369bd04": "热门 Agent", + "e6a369bd04": "热门智能体", "d7b3ef168b": "在系统中已检测", "9c163bb0e0": "安装说明", "69af7e9c1c": "尚未加入 PATH。Orca 会将其设为默认,你可随时安装。", - "1eee1c7bd8": "PATH 中未检测到 agents。可选择一个稍后安装,或使用空白 terminal 继续。", - "hideAgents": "隐藏 Agent", - "showMoreAgents": "显示另外 {{value0}} 个 Agent→", - "yoloPermissionsLabel": "Yolo / Dangerously skip permissions", - "yoloPermissionsInfo": "Agent permission info", - "yoloPermissionsTooltip": "Skip permission checks for agents for less interruptions" + "1eee1c7bd8": "PATH 中未检测到智能体。可选择一个稍后安装,或使用空白终端继续。", + "hideAgents": "隐藏智能体", + "showMoreAgents": "显示另外 {{value0}} 个智能体→", + "yoloPermissionsLabel": "Yolo / 危险地跳过权限检查", + "yoloPermissionsInfo": "智能体权限信息", + "yoloPermissionsTooltip": "跳过智能体权限检查,减少打断" }, "FeatureSetupChecklist": { - "77f74946f5": "Agent 可互相通信、领取任务并协调交接。", - "399cf885c0": "Agent 编排", - "c5292c409d": "按你的要求,Agent 可查看应用窗口并操作本地应用。", + "77f74946f5": "智能体可互相通信、领取任务并协调交接。", + "399cf885c0": "智能体编排", + "c5292c409d": "按你的要求,智能体可查看应用窗口并操作本地应用。", "1ecfb490ac": "计算机控制", - "01426f3a23": "Agent 可浏览网站、检查页面并完成浏览器任务。", - "ea85d9e628": "Agent 浏览器操控", - "linearTicketsTitle": "Linear agent 技能", - "linearTicketsDescription": "Agent 可使用已链接的 Linear 任务,完成更了解 ticket 背景的交接。", + "01426f3a23": "智能体可浏览网站、检查页面并完成浏览器任务。", + "ea85d9e628": "智能体浏览器操控", + "linearTicketsTitle": "Linear 智能体技能", + "linearTicketsDescription": "智能体可使用已链接的 Linear 任务,完成更了解 ticket 背景的交接。", "linearTicketsSetupSummary": "推荐用于 Linear 工作区;不会影响 Linear 连接设置。" }, "FeatureSetupInlineTerminal": { @@ -9432,15 +9486,15 @@ "277ba45540": "Orca 入门引导", "97c42cda00": "安装 GitHub CLI 以:", "ae3b00ca82": "设置 GitHub 任务", - "ff92d15436": "Orca 会在 Agent 完成工作或需要帮助时通知你。", + "ff92d15436": "Orca 会在智能体完成工作或需要帮助时通知你。", "b054332836": "设置通知", "04ae28d8ca": "选择你想盯着看几个小时的主题。", "f396db9f20": "让这里有家的感觉", - "322fc50a18": "Orca 支持所有 CLI Agent。选择你最常用的一个,随时可切换。", - "198b148b3c": "选择默认 Agent", + "322fc50a18": "Orca 支持所有 CLI 智能体。选择你最常用的一个,随时可切换。", + "198b148b3c": "选择默认智能体", "a5e5da02f7": "集成", - "35bbaf5ae0": "notifications", - "984338477a": "theme", + "35bbaf5ae0": "通知", + "984338477a": "主题", "c47e1bd149": "智能体", "windowsTerminalTitle": "设置 Windows 终端默认值", "windowsTerminalSubtitle": "为新面板选择默认 Shell,以及终端中右键单击的行为。" @@ -9450,7 +9504,7 @@ "111d3f8d92": "跳至项目设置" }, "OnboardingInlineCommandTerminal": { - "4123609efd": "启动 terminal..." + "4123609efd": "启动终端..." }, "OnboardingSkipConfirmationDialog": { "9f47f345a4": "不会花很长时间的!", @@ -9469,9 +9523,9 @@ "7932e95f68": "克隆", "955134915e": "git@github.com:org/repo.git", "288d8444b7": "粘贴 HTTPS 或 SSH URL。", - "132425a3e3": "克隆一个 repo", - "6558d50c69": "想要一次导入多个 repos?选择父文件夹。", - "831524961f": "选择任何本地目录,无论是否为 git repo。", + "132425a3e3": "克隆一个仓库", + "6558d50c69": "想要一次导入多个仓库?选择父文件夹。", + "831524961f": "选择任何本地目录,无论是否为 git 仓库。", "f4e9c8dcf8": "浏览文件夹", "e8214aa632": "作为文件夹打开", "3863747c56": "添加 Git 项目", @@ -9501,8 +9555,8 @@ "7ee9234e54": "已检测 Ghostty 配置。", "78b6386140": "已从 Ghostty 导入。", "2c3aa538f8": "正在查找 Ghostty 配置…", - "94b9dc561d": "设置 → Terminal", - "dd5c16ad1b": "更多 terminal 选项(字体、光标、配色等)见", + "94b9dc561d": "设置 → 终端", + "dd5c16ad1b": "更多终端选项(字体、光标、配色等)见", "ad192706e6": "浅色", "fa7b673ea9": "深色", "827ea7b4a2": "系统", @@ -9600,18 +9654,18 @@ "workspaceName": "工作区名称" }, "ProjectHostSetupCombobox": { - "empty": "No hosts are ready for this project.", - "placeholder": "Choose host" + "empty": "此项目没有已就绪的主机。", + "placeholder": "选择主机" }, "ProjectCombobox": { - "search": "Search projects...", - "empty": "No projects match your search." + "search": "搜索项目...", + "empty": "没有匹配搜索的项目。" } } }, "mobile": { "MobileHero": { - "a8fb43cf1c": "Continue", + "a8fb43cf1c": "继续", "3f90dbd274": "完成", "b622eba64d": "返回", "65b3f2e8bc": "生成…", @@ -9645,7 +9699,7 @@ "10d27b4cba": "开始使用", "da1d5e5ed0": "可用于", "ec0607bf66": "支持的手机平台", - "b4ccce5cb7": "从您的手机控制 Orca。当您离开办公桌时,检查 Agent、查看更改并启动任务。", + "b4ccce5cb7": "从您的手机控制 Orca。当您离开办公桌时,检查智能体、查看更改并启动任务。", "cd4e5e816f": "您的工作区就在您的口袋里。", "a6cffbbb0b": "生成代码", "e59a252eca": "重新生成代码", @@ -9672,7 +9726,7 @@ "c669abcf8f": "从侧边栏隐藏" }, "PhoneCarousel": { - "96d651cb87": "Terminal 会话", + "96d651cb87": "终端会话", "93217b41c1": "工作树列表", "89c7713645": "Orca 手机主屏幕" }, @@ -9704,7 +9758,7 @@ "19c212e25e": "MacBook Pro", "2f1a1d10c4": "台式机", "156db8a68a": "已创建 PR", - "4a40af029b": "Agent 时间", + "4a40af029b": "智能体时间", "00a6903322": "特工产生", "c0e2e9dcd9": "欢迎回来", "af761a0c0d": "设置", @@ -9720,7 +9774,7 @@ "fa22927f13": "粘贴", "985373052e": "切换到手机模式", "58a9ee6003": "工具调用格式化。接下来要我添加差异吗?", - "aa64b519c6": "terminal 屏幕。Tokyonight 配色,Menlo,真实 Claude", + "aa64b519c6": "终端屏幕。Tokyonight 配色,Menlo,真实 Claude", "e75112c834": "我已经用高保真幻灯片替换了配对扫描幻灯片", "3ce3e8c892": "14 次通过,1 次跳过(1.8 秒)", "4b3666f9a9": "src/cache/worktree-cache.test.ts", @@ -9728,7 +9782,7 @@ "d39445686a": "src/transport/host-store.test.ts", "a6e7cdc688": "pnpm 测试 -- 搜索手机设备", "21b67dfc92": "Bash", - "d6d1041a1c": "⎿ 用 terminal 会话替换配对扫描幻灯片", + "d6d1041a1c": "⎿ 用终端会话替换配对扫描幻灯片", "336c0e070e": "手机/orca-mobile-sidebar-mock-v3.html", "6d4ebd5833": "编辑", "fc83e0d5ef": "⎿ 阅读2103行", @@ -9740,14 +9794,14 @@ "e4befee569": "壳", "606aa93192": "文件", "94febb0976": "源头控制", - "8d6516312d": "2 个 terminals · Claude 活跃", + "8d6516312d": "2 个终端 · Claude 活跃", "8432787c4e": "壮举/手机页面", "8fd998acd3": "返回" }, "WorktreeListSlide": { "357a519567": "当前", "79a24ff530": "已固定", - "22971156df": "Repo", + "22971156df": "仓库", "17f9e0d226": "最近的", "0e3e809a4b": "筛选", "b4271864bd": "MacBook Pro", @@ -9770,7 +9824,7 @@ "ea8ad0bae8": "的", "0a891e8935": "REST API", "953f7c6062": "此 GitLab 主机未返回速率限制标头。", - "budget_scope_prefix": "Budget scope" + "budget_scope_prefix": "预算范围" } } } @@ -9784,9 +9838,9 @@ "0ee79e0674": "移至状态栏" }, "FloatingTerminalOrchestrationDialog": { - "f726054620": "使 Agent 能够通过 Orca 传递上下文并协调工作。", + "f726054620": "使智能体能够通过 Orca 传递上下文并协调工作。", "1cd3f8af64": "编排技能", - "6f0aed26b8": "安装 Orca CLI 和编排技能,以便 Agent 可以通过 Orca 进行协调。", + "6f0aed26b8": "安装 Orca CLI 和编排技能,以便智能体可以通过 Orca 进行协调。", "05d7aabc20": "未安装", "630c0ac8c8": "已安装", "dfd021ce46": "检查中...", @@ -9797,20 +9851,20 @@ "8b07759314": "新浏览器", "88ffb502e5": "打开 Markdown 笔记", "629528690b": "新的 Markdown 笔记", - "3215fc73e9": "新 Terminal", + "3215fc73e9": "新终端", "da508bd7f5": "保存", "918c2139f3": "不保存", "e7bf09d4d4": "取消", "690b6fb98a": "未保存的更改", "bbc177f98f": "启用", "adc281394d": "关闭", - "8cf80db43b": "设置 Orca CLI 和 Agent 技能,以便 Agent 可以通过 Orca 进行协调。", + "8cf80db43b": "设置 Orca CLI 和智能体技能,以便智能体可以通过 Orca 进行协调。", "2a3c5ddf5e": "启用编排", "d6b563ae24": "正在加载编辑器...", "8b14ba6c17": "新浏览器选项卡", "b085fb58b5": "该文件有未保存的更改。", "5ddc688c52": "“{{value0}}”有未保存的更改。您想在关闭前保存吗?", - "25d7817f79": "terminal" + "25d7817f79": "终端" }, "FloatingTerminalToggleButton": { "3b04b065b5": "显示浮动工作区", @@ -9826,7 +9880,7 @@ "82da3701e7": "无法为 {{value0}} 构建启动命令。", "109870e023": "最大化", "b5686fee1e": "恢复", - "1e502f1284": "Open" + "1e502f1284": "打开" } } }, @@ -9834,12 +9888,12 @@ "wall": { "AgentCapabilitiesSetupAction": { "b8dc9dd8a2": "已安装", - "1b51644c2d": "让 Agent 控制桌面、手机光标、单击并在任何应用程序中键入。", + "1b51644c2d": "让智能体控制桌面、手机光标、单击并在任何应用程序中键入。", "362a07517d": "计算机控制", - "5e8fe5a72d": "让 agents 直接访问 Orca 的浏览器,以便测试页面、捕获屏幕截图并根据所见内容执行操作。", - "e638da007a": "Agent 浏览器使用", - "c61c91e642": "让 Agent 通过 Orca 进行协调,以确保大型、多步骤的任务顺利完成。", - "ac07f8887f": "Agent 编排", + "5e8fe5a72d": "让智能体直接访问 Orca 的浏览器,以便测试页面、捕获屏幕截图并根据所见内容执行操作。", + "e638da007a": "智能体浏览器使用", + "c61c91e642": "让智能体通过 Orca 进行协调,以确保大型、多步骤的任务顺利完成。", + "ac07f8887f": "智能体编排", "e9eb197e12": "打开计算机使用权限", "3a59452a67": "技能命令已复制并插入到下方以供评审。", "c605f51f2b": "能力设置就绪", @@ -9853,7 +9907,7 @@ "be8917699e": "模型", "4d9b6d84df": "不支持。选择 Claude、Codex 或 Custom。", "560d4feb00": "自定义", - "29d119fe95": "Agent", + "29d119fe95": "智能体", "f9382b48a1": "启用AI作者", "1c0cb4fabb": "AI 作者", "bd14e9c42a": "未配置", @@ -9872,7 +9926,7 @@ "d8856b604a": "div.pricing-grid > div.card.starter:nth-of-type(1) > a.cta", "7da6eed7bf": "本地主机:3000", "0a2bd01c02": "新浏览器选项卡", - "04096318ab": "Terminal 1", + "04096318ab": "终端 1", "eb88125c6f": "✓ 已验证 — 免费试用仍然有效。", "051c97d15a": ".pp-card[data-card=\"starter\"] .pp-cta", "4fa59ca545": "✓ 更新", @@ -9881,7 +9935,7 @@ "f39be6ca14": "/报名" }, "BrowserUseSkillSetupCard": { - "cbc45022d4": "使 Agent 能够在 Orca 浏览器中导航和验证页面。", + "cbc45022d4": "使智能体能够在 Orca 浏览器中导航和验证页面。", "d5bb1cd4ba": "浏览器使用技能" }, "ComputerUseAnimatedVisual": { @@ -9930,7 +9984,7 @@ "8279e9d95b": "运行 12 项测试", "6218a9014d": "pnpm剧作家测试", "04d54d50ec": "Orca·zsh", - "1aa8a9a24a": "可拆分 terminal", + "1aa8a9a24a": "可拆分终端", "2a7cfc82c8": "链接到 GH #1842", "3822d8d14b": "修复/worktree-picker-截断", "d54aefe09e": "林-329", @@ -9939,9 +9993,9 @@ "fc0cc0b267": "GH #1842", "0688842445": "GH#1799", "bee6b4088d": "GitHub 和 Linear 任务", - "5171768676": "协调3个 Agent", + "5171768676": "协调3个智能体", "cebc7769cd": "重新设计身份验证流程", - "e44269e97d": "Agent 编排", + "e44269e97d": "智能体编排", "ec4a73f5e6": "PR 3/3", "cfdfd4d6b4": "PR 2/3", "b1f17bcc74": "PR 1/3", @@ -9951,10 +10005,10 @@ "56a0271428": "独立的工作区", "ef737dcee1": "GitHub 和 Linear 任务", "ac51c061e2": "codex", - "47f16ecf34": "Ship several things at once. Each workspace keeps its branch, terminal, and agent activity together.", - "70aa182266": "Hand off a goal and walk away. A coordinator agent fans out and ships parallel PRs.", - "f10c14dd9d": "Skip the tab-switching. Pick from your GitHub or Linear backlog and start a workspace in one click.", - "5d6ee181b6": "Open any workspace to return to its terminal, then split panes for tests, logs, and agents." + "47f16ecf34": "同时推进多项工作。每个工作区都会把分支、终端和智能体活动放在一起。", + "70aa182266": "交给一个目标后就可以离开。协调智能体会分发工作并并行交付 PR。", + "f10c14dd9d": "跳过来回切换标签页。从 GitHub 或 Linear 待办中挑选任务,一键启动工作区。", + "5d6ee181b6": "打开任意工作区即可回到它的终端,然后拆分窗格运行测试、日志和智能体。" }, "FeatureWallBody": { "25ec5356d6": "设置" @@ -9974,7 +10028,7 @@ "1a6a7d6c80": "设置", "713cc529a5": "里程碑", "b1f1981c5e": "查看任务", - "505f4c910c": "分体 terminal", + "505f4c910c": "分体终端", "0235b268b2": "还没有完成", "13294d3405": "完成" }, @@ -10028,8 +10082,8 @@ "ab2901bce6": "检查项", "d7f80060ca": "源代码控制", "8e715588e4": "搜索", - "a6c8b9e32f": "Checks passed", - "f4d5e1a7b2": "3 checks" + "a6c8b9e32f": "检查已通过", + "f4d5e1a7b2": "3 个检查" }, "ReviewShipAnimatedVisual": { "4d99496b8c": "创建PR", @@ -10044,7 +10098,7 @@ "c30cd930ff": "创建PR", "ea0100dd15": "查看全部", "e725000cd7": "变化", - "a079083a6c": "Commit", + "a079083a6c": "提交", "7347fa5839": "信息", "d1a7f15876": "使用 AI 生成 commit 消息", "cd8a3a39d7": "提前 3 次 commits" @@ -10061,8 +10115,8 @@ "WorkbenchAnimatedVisual": { "633a91e358": "思维…", "932c4b3a97": ">", - "ca2cfbf188": "向下拆分 Terminal", - "e370fa8c2b": "向右拆分 Terminal", + "ca2cfbf188": "向下拆分终端", + "e370fa8c2b": "向右拆分终端", "b85eab49dd": "src/auth/session.ts", "99f5224f1e": "编辑", "0d93c298a7": "抛出 src/auth", @@ -10208,25 +10262,25 @@ "c2df599513": "安装 CLI 和技能" }, "ConnectIntegrationsList": { - "3dddb2d565": "connected for tasks", - "33b650af52": "Connect where your team tracks work. Orca starts workspaces with the issue title, link, and context already attached.", - "5b3577a492": "connected for review status", - "list_end": ", and ", - "list_pair": " and ", + "3dddb2d565": "已连接,可用于任务", + "33b650af52": "连接团队用于跟踪工作的地方。Orca 会在启动工作区时自动附上议题标题、链接和上下文。", + "5b3577a492": "已连接,可用于评审状态", + "list_end": ",以及", + "list_pair": " 和 ", "list_mid": ", ", - "code_host_tasks_summary": "issues available as tasks · add Linear or Jira if your team plans work there", - "code_host_tasks_caption": "Your code host's issues also work as tasks.", - "review_step_title": "See PR status while agents work", - "review_step_description": "Connect a review provider so Orca can show PR or MR status, checks, and reviews.", - "task_step_title": "Start agents on your tasks without leaving Orca" + "code_host_tasks_summary": "议题可作为任务使用 · 如果团队在 Linear 或 Jira 中规划工作,也可以添加它们", + "code_host_tasks_caption": "代码托管平台的议题也可以作为任务使用。", + "review_step_title": "在智能体工作时查看 PR 状态", + "review_step_description": "连接评审提供方,让 Orca 显示 PR 或 MR 状态、检查和评审。", + "task_step_title": "无需离开 Orca,即可让智能体处理任务" }, "connect": { "integration": { "step": { - "0f47ff17c6": "Change", - "5538eb6743": "Done", - "open_step": "Open", - "close_step": "Close" + "0f47ff17c6": "更改", + "5538eb6743": "完成", + "open_step": "打开", + "close_step": "关闭" } } }, @@ -10248,8 +10302,8 @@ "22e62f3bab": "Claude Code 会话已开始" }, "CliSkillSetupTerminal": { - "1953e90447": "按 Enter 为您的 Agent 安装 Orca CLI 编排技能。", - "43b60ec5c3": "Orca CLI 和编排技能安装 terminal", + "1953e90447": "按 Enter 为您的智能体安装 Orca CLI 编排技能。", + "43b60ec5c3": "Orca CLI 和编排技能安装终端", "84e9576dac": "技能设置", "5c3aee22c0": "复制命令", "5eca672aac": "复制技能安装命令", @@ -10272,7 +10326,7 @@ "27c567a89c": "工作树", "55846c7f95": "将此 PR 拆成两个", "4795ac2d4a": "尝试询问:", - "53905bd076": "开发预览:打开技能设置 terminal。", + "53905bd076": "开发预览:打开技能设置终端。", "1da82af45b": "Orca CLI 需要注意", "ce13a742d0": "在 PATH 中注册了 `orca`。", "d1a86c7eb5": "打开“设置”以完成 CLI 设置。" @@ -10352,10 +10406,10 @@ "c6705092ba": "修复 PATH", "7c1b6bdb1e": "启用", "51074ccb05": "无法加载 CLI 状态。", - "35dea1ae12": "agent控制已就绪。", + "35dea1ae12": "智能体控制已就绪。", "9dff3a6338": "技能已安装。启用 Orca CLI 以完成设置。", "15986a1080": "Orca CLI 已就绪。安装技能以完成设置。", - "4c26913def": "尚未设置完成。请完成两个步骤以启用agent控制。", + "4c26913def": "尚未设置完成。请完成两个步骤以启用智能体控制。", "c94ff11e91": "无法重新检查设置状态。", "2b519eed94": "已在 PATH 中注册 Orca CLI。" } @@ -10372,10 +10426,10 @@ } }, "MobileEmulatorAgentSetupGuide": { - "2fda9ff015": "设置Agent控制", - "0ac0fef514": "Agent控制已就绪。", - "2bdfff8763": "Agent控制(可选)。", - "72736b051f": "当您希望Agent控制此模拟器时,设置 Orca CLI + 技能。", + "2fda9ff015": "设置智能体控制", + "0ac0fef514": "智能体控制已就绪。", + "2bdfff8763": "智能体控制(可选)。", + "72736b051f": "当您希望智能体控制此模拟器时,设置 Orca CLI + 技能。", "d10ae98046": "完成", "3756cbeca7": "暂不", "6d950431d2": "隐藏", @@ -10384,15 +10438,15 @@ }, "MobileEmulatorAgentSetupGuideSteps": { "9b49d892e3": "启用 Orca CLI", - "3d8dc52c93": "在 agent shell 中注册用于模拟器控制的 orca 命令。", + "3d8dc52c93": "在智能体 shell 中注册用于模拟器控制的 orca 命令。", "21f5687c07": "Orca CLI 技能", - "64fb057667": "教授 agents 此工作区的 orca 模拟器命令。", - "5c59ea96ca": "Mobile emulator Orca CLI skill setup", - "bff5341ac3": "Mobile emulator Orca CLI skill install terminal" + "64fb057667": "教授智能体此工作区的 orca 模拟器命令。", + "5c59ea96ca": "移动端模拟器 Orca CLI 技能设置", + "bff5341ac3": "移动端模拟器 Orca CLI 技能安装终端" }, "MobileEmulatorTabIntroCallout": { "1924982130": "关闭", - "5789936d9a": "在 agents 控制屏幕时预览 iOS 模拟器。", + "5789936d9a": "在智能体控制屏幕时预览 iOS 模拟器。", "8014b4b80b": "保留", "6e051a40b7": "隐藏" }, @@ -10408,9 +10462,9 @@ } }, "useEmulatorScreenKeyboard": { - "pasteTooLarge": "Paste is too large for emulator keyboard input.", - "unsupportedPasteText": "Emulator keyboard paste supports US keyboard text only.", - "pasteTargetUnavailable": "Emulator keyboard paste failed because the device is not ready." + "pasteTooLarge": "模拟器键盘输入的粘贴内容过大。", + "unsupportedPasteText": "模拟器键盘粘贴仅支持美式键盘文本。", + "pasteTargetUnavailable": "模拟器键盘粘贴失败,因为设备尚未就绪。" } } }, @@ -10435,7 +10489,7 @@ "21783df79f": "折叠文件树", "481e63ca52": "文件", "d5ac717d65": "未提交", - "39b6b9e4e4": "Committed on Branch" + "39b6b9e4e4": "已提交到分支" }, "CombinedDiffViewer": { "35cc27aeb2": "在源代码控制中", @@ -10467,7 +10521,9 @@ "8368d256ec": "组合分支", "8f68ad9ca9": "显示 {{value0}} AI {{value1}}", "724a13568d": "在 {{value0}}", - "6094135eec": "与 {{value0}}" + "6094135eec": "与 {{value0}}", + "a4420ca1f7": "Wrap On", + "dde325ddfe": "Wrap Off" }, "ConflictComponents": { "f338288514": "正在加载冲突内容...", @@ -10533,7 +10589,7 @@ "3c6e71df22": "分支比较中此文件的文本差异不可用。", "d07e4b8553": "分支", "d16e037f40": "富有的", - "6c4f1a8d2e": "Check details are unavailable." + "6c4f1a8d2e": "检查详情不可用。" }, "EditorPanelHeader": { "fb8331694e": "打开侧面预览", @@ -10555,7 +10611,8 @@ "3e0ce48c24": "导出为 PDF", "561251019a": "更多操作", "8c8b7f5ff5": "显示前面的内容", - "10c39d58c1": "隐藏前面的内容" + "10c39d58c1": "隐藏前面的内容", + "1eef809708": "Word Wrap" }, "EditorPanelShell": { "e2c4dec350": "正在加载编辑器..." @@ -10619,7 +10676,7 @@ "b1bfc04034": "选定的文本", "f37b98999e": "此注", "2b2b31382c": "前言", - "bb629de58a": "复制备注给 Agent", + "bb629de58a": "复制备注给智能体", "322afab6ff": "复习笔记", "0f9969a159": "跳转到第一个评论笔记", "12052c639c": "关闭搜索", @@ -10627,7 +10684,7 @@ "1febd97f5c": "上一个结果", "ec77985138": "在 Markdown 预览中查找", "517aea303b": "在预览中查找", - "f961e94057": "复制备注给 Agent", + "f961e94057": "复制备注给智能体", "94b520a96a": "复制的注释", "13f94d760c": "添加备注", "ddf087d12e": "所有未发送的笔记", @@ -10648,7 +10705,7 @@ "27d0a9c49a": "目录", "65b036a6c8": "展开 {{value0}}", "97ad46f11f": "折叠 {{value0}}", - "8f4d2c1a9b": "Resize table of contents" + "8f4d2c1a9b": "调整目录大小" }, "MarkdownTemplatePicker": { "22cd94426f": "无标题.md", @@ -10664,7 +10721,7 @@ "MonacoEditor": { "68cb83f4a7": "在选定的文本上添加注释", "fd68ae03b3": "在文件中搜索", - "largePasteTooLarge": "Paste is too large." + "largePasteTooLarge": "粘贴内容过大。" }, "MonacoGutterContextMenu": { "7b57b1b468": "复制远程 URL", @@ -10673,7 +10730,7 @@ }, "NotesSendMenu": { "44dc5e60a6": "发送笔记", - "433928cd9f": "将 {{value0}} 发送给 Agent" + "433928cd9f": "将 {{value0}} 发送给智能体" }, "PdfFind": { "cd65b1d6b0": "关闭", @@ -10691,12 +10748,12 @@ "fa5d096b00": "缩小" }, "ReviewNotesSendMenuContent": { - "a49800405b": "新 Agent", - "e84705f223": "活动 Agent 会话", + "a49800405b": "新智能体", + "e84705f223": "活动智能体会话", "03378aea75": "发送注释至", - "f5096c6e4e": "无法向活动 Agent 发送注释。", - "bb9c69a0c9": "注释已发送至活动 Agent。", - "50f7e753ea": "正在向活动 Agent 发送注释..." + "f5096c6e4e": "无法向活动智能体发送注释。", + "bb9c69a0c9": "注释已发送至活动智能体。", + "50f7e753ea": "正在向活动智能体发送注释..." }, "RichMarkdownAnnotationOverlay": { "069b5677b8": "选定的文本", @@ -10751,12 +10808,12 @@ }, "RichMarkdownReviewNoteLayer": { "f3ef92952b": "此注", - "9cde7ad994": "复制备注给 Agent", + "9cde7ad994": "复制备注给智能体", "117432e2c6": "复制的注释", "3ababd949d": "复习笔记" }, "RichMarkdownReviewRailActions": { - "636394af72": "复制备注给 Agent", + "636394af72": "复制备注给智能体", "a807596997": "复制笔记", "8aaf2c4c69": "显示评论笔记", "af02dc2456": "隐藏评论注释" @@ -10902,32 +10959,32 @@ "CheckRunDetailsPanel": { "8f2d0f5a91": "通过", "4c8e1b2d73": "失败", - "91a4c7e2b0": "Cancelled", - "2f6d8a1c45": "Timed out", - "7b3e9d4f12": "Skipped", - "5a1c8e3d67": "Neutral", + "91a4c7e2b0": "已取消", + "2f6d8a1c45": "已超时", + "7b3e9d4f12": "已跳过", + "5a1c8e3d67": "中性", "3d9f2b8e14": "待处理", "b7f5e2c91a": "刷新", - "a54ae21c6f": "Status:", - "fd46a70f1a": "Started", + "a54ae21c6f": "状态:", + "fd46a70f1a": "已开始", "00e1c1658a": "已完成", "aa8494ae3c": "检查 #", - "2dd5ddabc4": "workflow #", - "1f2b980522": "Loading check details…", - "d098e5529a": "Output", - "f2fe8a4e8f": "Annotations", + "2dd5ddabc4": "工作流 #", + "1f2b980522": "正在加载检查详情…", + "d098e5529a": "输出", + "f2fe8a4e8f": "批注", "cdbfda4dec": "批注", - "066fedd446": "Failed jobs", - "49731703ea": "Jobs", - "ee07b33924": "unknown", - "07eccfa397": "No details are available for this check.", - "a916648574": "Open details", - "834cb3f23d": "Fix with AI", - "c8f1a2d4e7": "Choose the agent and edit the full command input before launch.", - "b3e7f9a1c2": "Check fix context unavailable", - "d5a8c2f1b9": "Start the default AI agent to fix this check", - "e2b4d7c8a1": "Choose an agent for this check", - "f1c9e3a6d4": "Choose agent to fix check" + "066fedd446": "失败的任务", + "49731703ea": "任务", + "ee07b33924": "未知", + "07eccfa397": "此检查没有可用详情。", + "a916648574": "打开详情", + "834cb3f23d": "用 AI 修复", + "c8f1a2d4e7": "启动前请选择智能体并编辑完整命令输入。", + "b3e7f9a1c2": "检查修复上下文不可用", + "d5a8c2f1b9": "启动默认 AI 智能体来修复此检查", + "e2b4d7c8a1": "为此检查选择智能体", + "f1c9e3a6d4": "选择智能体修复检查" }, "check": { "run": { @@ -10935,11 +10992,11 @@ "fix": { "with": { "ai": { - "1a8c4e2b90": "Select a workspace before launching an AI action.", - "4f2d9a8c17": "Select a repository before launching an AI action.", - "7c3e1b5d42": "Open a PR or MR before launching an AI fix.", - "9b2f6d4a81": "This check is not failing.", - "2ef90c9819": "Started an AI agent for this check." + "1a8c4e2b90": "启动 AI 操作前请选择工作区。", + "4f2d9a8c17": "启动 AI 操作前请选择仓库。", + "7c3e1b5d42": "启动 AI 修复前请打开 PR 或 MR。", + "9b2f6d4a81": "此检查没有失败。", + "2ef90c9819": "已为此检查启动 AI 智能体。" } } } @@ -10947,7 +11004,7 @@ } }, "richMarkdownLargeTextPaste": { - "tooLarge": "Paste is too large." + "tooLarge": "粘贴内容过大。" } }, "diff": { @@ -10991,8 +11048,8 @@ "a743da52ff": "展开详情", "a41fb5376e": "折叠详情", "5ae84475cc": "关闭", - "b06e13fcf7": "关闭 agent", - "0272969e28": "发送给该 Agent", + "b06e13fcf7": "关闭智能体", + "0272969e28": "发送给该智能体", "92a7017987": "发送", "019b74d93a": "有资格的" }, @@ -11014,17 +11071,17 @@ "8b8473c544": "已复制错误报告。", "b175e90213": "没有可用的错误报告。", "765591798d": "正在检查错误报告...", - "b2e36f53a1": "Failed to send crash report. Diagnostic ticket {{value0}} was uploaded but not linked.", - "ead6fc0510": "No automatic crash report was captured. You can still send details and include recent diagnostic logs when available.", - "b082f27490": "Attach recent diagnostic logs", - "e59f0b9427": "Sends a capped redacted log bundle with the report." + "b2e36f53a1": "发送崩溃报告失败。诊断票据 {{value0}} 已上传,但未关联。", + "ead6fc0510": "没有捕获到自动崩溃报告。你仍然可以发送详情,并在可用时包含最近的诊断日志。", + "b082f27490": "附加最近的诊断日志", + "e59f0b9427": "随报告发送经过删减且有大小限制的日志包。" } } }, "contextual": { "tours": { "ContextualTourControl": { - "186eecc34f": "根据第一条 Agent 消息自动命名工作区", + "186eecc34f": "根据第一条智能体消息自动命名工作区", "02e8373219": "当您将此文本框留空时,会自动生成新名称。", "731c5573df": "根据第一条消息自动命名" }, @@ -11053,14 +11110,14 @@ "j": { "quick": { "actions": { - "c884a6398e": "创建保存的 terminal 命令。", + "c884a6398e": "创建保存的终端命令。", "a43ab56fc1": "添加快捷命令", "54853d52a2": "删除当前工作树。", "9537b910fe": "删除工作树", "0b1f25f796": "启动一个新的工作树。", "52ac9da671": "创建工作树", - "f70812764a": "在活动工作区中打开 terminal 选项卡。", - "34980395d4": "新 Terminal 选项卡", + "f70812764a": "在活动工作区中打开终端选项卡。", + "34980395d4": "新终端选项卡", "f2a1b33f8d": "在活动工作区中创建一个无标题的 Markdown 文件。", "25349b66fc": "新的 Markdown 文件", "784812ca24": "在活动工作区中打开浏览器选项卡。", @@ -11075,10 +11132,10 @@ "newMark": "新标记", "newFile": "新文件", "markdownFile": "Markdown", - "newTerminal": "新 Terminal", - "newTerminalTab": "新的 terminal 选项卡", + "newTerminal": "新终端", + "newTerminalTab": "新的终端选项卡", "newShell": "新shell", - "terminalTab": "terminal 选项卡", + "terminalTab": "终端选项卡", "createWorktree": "创建工作树", "addWorktree": "添加工作树", "newWorktree": "新工作树", @@ -11094,8 +11151,8 @@ "palette": { "project": { "results": { - "repoGroup": "Repo group", - "project": "Project" + "repoGroup": "仓库组", + "project": "项目" } } } @@ -11137,7 +11194,7 @@ "f2d0c22d67": "删除注释 {{value0}}", "11c5084aa2": "清晰的注释", "734e4343ec": "清除浏览器注释", - "95af781091": "向新 Agent 发送反馈", + "95af781091": "向新智能体发送反馈", "ac39b9366b": "发送", "a3508d7e6e": "{{value0}} 注释{{value1}} 准备就绪。选择另一个元素或复制所有反馈。", "f796c774a4": "在上面输入 URL 即可开始浏览。", @@ -11172,7 +11229,7 @@ "90d021f2ad": "添加", "0cb3bd6221": "注释意图", "8f87e6c2e5": "意图", - "532bac48c5": "描述 Agent 应该在这里改变什么......", + "532bac48c5": "描述智能体应该在这里改变什么......", "d2a7092e6e": "注释评论", "b472c5fe03": "添加浏览器注释", "b5ba6085de": "疑问", @@ -11187,11 +11244,11 @@ "168350ae6a": "单击或悬停一个元素,然后按 C 进行复制或按 S 进行屏幕截图。", "e852e20cea": "已复制 — 按 S 进行屏幕截图,或选择另一个元素", "a5dcd0fd1d": "确认", - "777b5bc4ec": "单击一个元素可为 Agent 添加反馈。", + "777b5bc4ec": "单击一个元素可为智能体添加反馈。", "b733a91bd9": "为所选元素添加反馈。", "4328a0a062": "抓取失败:{{value0}}", "26615e116b": "错误", - "8aec5bc044": "idle", + "8aec5bc044": "空闲", "759f32af29": "正在下载", "c8bc7f1f9e": "要求的", "4300f38145": "从 {{value0}}{{value1}} 下载", @@ -11204,15 +11261,15 @@ "c13693fe27": "{{value0}} 条注释", "074f0ed10b": "{{value0}} 条注释已准备好。请选择另一个元素或复制所有反馈。", "a2164a6e5a": "{{value0}} 条注释已准备好。请选择另一个元素或复制所有反馈。", - "9f6f2e8c19": "The downloaded file path is unavailable.", - "0c79b7634d": "Could not open the downloaded file. It may have been moved or deleted.", - "397d9dc923": "Could not show the downloaded file. It may have been moved or deleted.", - "39c04fed61": "Downloading paused", - "5c3d530a68": "Downloaded", - "4bb7424d6b": "Canceled", - "6e776f9ef9": "Download failed", - "756bfc25c9": "Open", - "09a9489aa5": "Show" + "9f6f2e8c19": "下载文件路径不可用。", + "0c79b7634d": "无法打开下载的文件。它可能已被移动或删除。", + "397d9dc923": "无法显示下载的文件。它可能已被移动或删除。", + "39c04fed61": "下载已暂停", + "5c3d530a68": "已下载", + "4bb7424d6b": "已取消", + "6e776f9ef9": "下载失败", + "756bfc25c9": "打开", + "09a9489aa5": "显示" }, "BrowserToolbarMenu": { "429ef481f9": "取消", @@ -11276,10 +11333,10 @@ "AutomationDetail": { "007c8ad874": "迅速的", "a1d52c2189": "使用范围", - "449fc83bf7": "Token", + "449fc83bf7": "令牌", "401f40ae79": "预计。花费", "a7c312430d": "最后一次运行", - "2df8970cd5": "Agent", + "2df8970cd5": "智能体", "e353ab9516": "预检查", "620b22145e": "优雅", "15ea446b93": "会话", @@ -11293,16 +11350,16 @@ "91a4155e95": "暂停自动化", "4b1ea02d2e": "编辑自动化", "2fb1605beb": "立即运行", - "221916d93c": "创建自动化来开始安排 Agent 工作。", + "221916d93c": "创建自动化来开始安排智能体工作。", "de0fedac06": "每次运行新的", "51a470b966": "SSH", "b09b2384fd": "已暂停", "eaa02014f8": "启用", - "29baf8f4c2": "Source" + "29baf8f4c2": "来源" }, "AutomationEditorDialog": { "fb1896a5e7": "取消", - "57b722cbba": "Agent", + "57b722cbba": "智能体", "6ff66f9012": "新运行", "a2e688226d": "工作树", "6f9610e667": "工作树在选定的工作区中运行。新运行每次都会从所选分支创建一个新的工作区。", @@ -11325,7 +11382,7 @@ "7e35393632": "Hermes", "6f309eef8d": "Orca", "58f56b73d9": "自动化名称", - "1d9826933e": "工作日 repo 审计", + "1d9826933e": "工作日仓库审计", "4133d33862": "创建自动化", "0a75e5e2fa": "创建爱马仕自动化", "03142e7721": "编辑 Hermes 自动化", @@ -11356,7 +11413,7 @@ "AutomationRunHistory": { "402651bfb6": "还没有运行。", "9974a2b429": "状态", - "13988187b3": "Token", + "13988187b3": "令牌", "86a248187e": "花费", "149c0b49c7": "工作区", "8faaa00726": "跑步", @@ -11435,7 +11492,7 @@ "08efc3ae12": "Hermes 自动化已更新。", "e431bb85d4": "选择与此 Hermes 自动化位于同一主机上的工作区。", "32534e7c9c": "保存前选择一个可用的工作区。", - "2360ffc956": "保存前选择已启用的 Agent。", + "2360ffc956": "保存前选择已启用的智能体。", "6e91dab317": "保存前输入有效的高级计划。", "64bdb2304f": "保存前选择支持的计划。", "2430fecf53": "选择运行位置并在保存前输入提示。", @@ -11454,13 +11511,13 @@ "7b2e285552": "SSH 连接在此客户端中不可用。", "d441032f7e": "暂停", "5918020edc": "跑步", - "a21f6c33ad": "Automation source refreshed.", - "53f06f0ad5": "Retry source", + "a21f6c33ad": "自动化来源已刷新。", + "53f06f0ad5": "重试来源", "pendingAutomationMissing": "自动化已不可用。", "pendingAutomationRunMissing": "运行历史已不可用。" }, "CreateFromPicker": { - "f061f49e3f": "搜索 repo 分支...", + "f061f49e3f": "搜索仓库分支...", "dd3841b442": "分支来自", "ef6d762538": "项目默认", "e53d306056": "{{value0}}(默认)", @@ -11527,10 +11584,10 @@ "513401db93": "根据当前项目状态准备每周发布风险摘要。", "39ed39280a": "发布准备情况", "a7fbd32ddb": "每个工作日检查依赖关系、失败的测试和有风险的开放变更。", - "b84757677d": "工作日 repo 审计", + "b84757677d": "工作日仓库审计", "repoHealth": { "category": "Repo 健康", - "name": "工作日 repo 审计", + "name": "工作日仓库审计", "prompt": "检查存储库的运行状况。检查依赖项更新、失败的测试、lint/类型检查状态以及有风险的开放更改。总结调查结果并建议下一步行动。" }, "releasePrep": { @@ -11560,59 +11617,59 @@ } }, "AutomationProjectCombobox": { - "search": "Search projects/folders...", - "empty": "No projects/folders match your search.", - "chooseHost": "Choose automation host", - "adding": "Adding project…", - "addProject": "Add project" + "search": "搜索项目/文件夹...", + "empty": "没有匹配搜索的项目/文件夹。", + "chooseHost": "选择自动化主机", + "adding": "正在添加项目…", + "addProject": "添加项目" } }, "agent": { "AgentCombobox": { - "19522e25ee": "管理 Agent", - "986f946354": "空白 Terminal", - "579c768bde": "没有 Agent 符合您的搜索。", - "48c6a5a9b4": "搜索 Agent...", + "19522e25ee": "管理智能体", + "986f946354": "空白终端", + "579c768bde": "没有智能体符合您的搜索。", + "48c6a5a9b4": "搜索智能体...", "9c6b59fe58": "设置为默认值", "1b0d6965fa": "当前默认值" }, "AgentSettingsDialog": { - "50cdb57c03": "管理 AI Agent、设置默认值并自定义命令。", - "fc0268e4ed": "Agents" + "50cdb57c03": "管理 AI 智能体、设置默认值并自定义命令。", + "fc0268e4ed": "智能体" } }, "activity": { "ActivityPrototypePage": { - "cf780197a1": "选择一个 Agent 以查看其活动", + "cf780197a1": "选择一个智能体以查看其活动", "e3db9892f6": "还没有活动。", - "1b633f5c1e": "连接 terminal...", - "8de7c5beaa": "Terminal 不可用", + "1b633f5c1e": "连接终端...", + "8de7c5beaa": "终端不可用", "866083500b": "拖动以调整大小", "443690186e": "调整活动线程列表的大小", - "7cd632006b": "没有 Agent 活动与这些搜索器匹配。", + "7cd632006b": "没有智能体活动与这些搜索器匹配。", "a2b4437bfb": "{{value0}} 活动", "023ff75afe": "标记全部已读", "f70e4bec47": "紧凑模式", "a472a14700": "更多选择", "db8a1878b5": "线程列表选项", "d1a88df9a8": "仅显示未读主题", - "f6396e1f85": "Agent", + "f6396e1f85": "智能体", "b29191b3e0": "工作树", "8c3b621ddf": "项目", "4a3986b200": "状态", - "770d458144": "对 Agent 活动进行分组", + "770d458144": "对智能体活动进行分组", "795cbf26e2": "筛选...", "4616ea39fd": "跳转到工作区", "59b131fbd9": "将话题标记为未读", "beb2c19173": "未读", "5651b216c6": "未知项目", - "22b22034bc": "独立 terminal 在活动中不可用。", - "afdc2139a8": "Agent terminal 关闭。在此工作区中打开一个新 terminal 以继续。" + "22b22034bc": "独立终端在活动中不可用。", + "afdc2139a8": "智能体终端关闭。在此工作区中打开一个新终端以继续。" }, "ActivityTitlebarControls": { "f915168c8e": "未读", - "d6a8de3934": "agents", - "dc708f3eff": "紧密 Agent" + "d6a8de3934": "智能体", + "dc708f3eff": "紧密智能体" } }, "confirmation": { @@ -11625,18 +11682,18 @@ "connect": { "dialog": { "63ce735809": "连接", - "4a2ab52781": "Verifying…", + "4a2ab52781": "正在验证…", "79e7aaed39": "取消", - "fdd26d81cc": "Atlassian account settings", - "8090504a3e": "Create a token in", - "7b3967c12f": "Atlassian API token", - "3d81bf3ab3": "API token", + "fdd26d81cc": "Atlassian 账户设置", + "8090504a3e": "在此创建令牌:", + "7b3967c12f": "Atlassian API 令牌", + "3d81bf3ab3": "API 令牌", "e91b9a4073": "you@example.com", - "2849ddb295": "Atlassian email", + "2849ddb295": "Atlassian 邮箱", "70fcd360c4": "https://example.atlassian.net", - "e176f9d0c5": "Jira Cloud site URL", - "d785c42b8b": "Use a Jira Cloud site URL, Atlassian email, and API token to browse issues.", - "8388bdea2b": "Connect Jira site" + "e176f9d0c5": "Jira Cloud 站点 URL", + "d785c42b8b": "使用 Jira Cloud 站点 URL、Atlassian 邮箱和 API 令牌来浏览议题。", + "8388bdea2b": "连接 Jira 站点" } } }, @@ -11742,49 +11799,49 @@ "project": { "source": { "combobox": { - "noProjects": "No projects", - "allProjects": "All projects", - "hostCount": "{{value0}} hosts", - "searchProjects": "Search projects...", - "noMatches": "No projects match your search.", + "noProjects": "没有项目", + "allProjects": "所有项目", + "hostCount": "{{value0}} 个主机", + "searchProjects": "搜索项目...", + "noMatches": "没有匹配搜索的项目。", "chooseSource": "选择任务来源" } } } }, "taskPageEmptyState": { - "noProjectSourcesTitle": "No project sources selected", - "noProjectSourcesDescription": "Select at least one project source so Orca knows which host/account to fetch tasks from.", - "noMatchingGitHubWorkTitle": "No matching GitHub work", - "changeQueryDescription": "Change the query or clear it.", - "noGitLabIssuesTitle": "No GitLab issues", - "noGitLabIssuesDescription": "No GitLab issues match this filter.", - "noGitLabMrsTitle": "No GitLab merge requests", - "noGitLabMrsDescription": "No GitLab MRs match this filter.", - "noGitLabWorkTitle": "No GitLab work", - "noGitLabWorkDescription": "No GitLab work matches this filter." + "noProjectSourcesTitle": "未选择项目来源", + "noProjectSourcesDescription": "请至少选择一个项目来源,让 Orca 知道要从哪个主机/账户获取任务。", + "noMatchingGitHubWorkTitle": "没有匹配的 GitHub 工作", + "changeQueryDescription": "更改查询或清除它。", + "noGitLabIssuesTitle": "没有 GitLab 议题", + "noGitLabIssuesDescription": "没有 GitLab 议题匹配此筛选器。", + "noGitLabMrsTitle": "没有 GitLab 合并请求", + "noGitLabMrsDescription": "没有 GitLab MR 匹配此筛选器。", + "noGitLabWorkTitle": "没有 GitLab 工作", + "noGitLabWorkDescription": "没有 GitLab 工作匹配此筛选器。" }, "taskSourceContextSummary": { - "sourceUnavailable": "{{value0}} source unavailable: {{value1}}", - "someSourceHostsUnavailable": "Some {{value0}} source hosts unavailable: {{value1}}", - "reconnectOrUpdateTitle": "Reconnect or update {{value0}} to load this source." + "sourceUnavailable": "{{value0}} 来源不可用:{{value1}}", + "someSourceHostsUnavailable": "部分 {{value0}} 来源主机不可用:{{value1}}", + "reconnectOrUpdateTitle": "重新连接或更新 {{value0}} 以加载此来源。" }, "ShortcutKeyCombo": { - "07eb4985a1": "Double-tap {{value0}}" + "07eb4985a1": "双击 {{value0}}" }, "star": { "nag": { "StarNagToastHost": { - "starredThanks": "Starred — thank you!", - "githubOpened": "GitHub opened", - "opening": "Opening…", - "starring": "Starring…", - "openGithub": "Open GitHub", - "starOnGithub": "Star on GitHub", - "onboardingCompleted": "Onboarding completed!", - "body": "If you’re enjoying Orca so far, a GitHub star helps other developers discover it.", - "dismiss": "Dismiss", - "later": "Later" + "starredThanks": "已加星,谢谢!", + "githubOpened": "已打开 GitHub", + "opening": "正在打开…", + "starring": "正在加星…", + "openGithub": "打开 GitHub", + "starOnGithub": "在 GitHub 上加星", + "onboardingCompleted": "入门流程已完成!", + "body": "如果你目前喜欢 Orca,在 GitHub 上加星可以帮助其他开发者发现它。", + "dismiss": "关闭", + "later": "稍后" } } } diff --git a/src/renderer/src/lib/left-sidebar-appearance.ts b/src/renderer/src/lib/left-sidebar-appearance.ts index e9e13f6984e..0097057d452 100644 --- a/src/renderer/src/lib/left-sidebar-appearance.ts +++ b/src/renderer/src/lib/left-sidebar-appearance.ts @@ -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 } diff --git a/src/renderer/src/lib/pane-manager/pane-dom-creation.ts b/src/renderer/src/lib/pane-manager/pane-dom-creation.ts new file mode 100644 index 00000000000..1a8d12c6ff4 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-dom-creation.ts @@ -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 +} diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts index f2fc5846add..eb296a1d675 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts @@ -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 diff --git a/src/renderer/src/lib/pane-manager/pane-manager-types.ts b/src/renderer/src/lib/pane-manager/pane-manager-types.ts index fb95e66b524..85c45366d0b 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-types.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-types.ts @@ -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 diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts index 9d260cd36fc..6295c37f37a 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts @@ -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; diff --git a/src/renderer/src/lib/pane-manager/pane-tree-ops.ts b/src/renderer/src/lib/pane-manager/pane-tree-ops.ts index 2aed9fcaac5..3725f1f4f50 100644 --- a/src/renderer/src/lib/pane-manager/pane-tree-ops.ts +++ b/src/renderer/src/lib/pane-manager/pane-tree-ops.ts @@ -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 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. diff --git a/src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts b/src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts new file mode 100644 index 00000000000..f132e786daf --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts @@ -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) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts b/src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts new file mode 100644 index 00000000000..35537c11f24 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts @@ -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() + +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) + } + } +} diff --git a/src/renderer/src/lib/project-host-workspace-target.test.ts b/src/renderer/src/lib/project-host-workspace-target.test.ts index a6fd417de25..e26eb4d4108 100644 --- a/src/renderer/src/lib/project-host-workspace-target.test.ts +++ b/src/renderer/src/lib/project-host-workspace-target.test.ts @@ -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' + }) + }) + }) }) diff --git a/src/renderer/src/lib/resume-sleeping-agent-session.test.ts b/src/renderer/src/lib/resume-sleeping-agent-session.test.ts index 969339c816e..f0cbbfacdfb 100644 --- a/src/renderer/src/lib/resume-sleeping-agent-session.test.ts +++ b/src/renderer/src/lib/resume-sleeping-agent-session.test.ts @@ -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({ diff --git a/src/renderer/src/lib/resume-sleeping-agent-session.ts b/src/renderer/src/lib/resume-sleeping-agent-session.ts index c136b01e263..64c8c1388a5 100644 --- a/src/renderer/src/lib/resume-sleeping-agent-session.ts +++ b/src/renderer/src/lib/resume-sleeping-agent-session.ts @@ -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['terminalLayoutsByTabId'] + terminalLayoutsByTabId: Record ): 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( diff --git a/src/renderer/src/store/slices/editor.test.ts b/src/renderer/src/store/slices/editor.test.ts index 315f4740310..a3ec3f471f9 100644 --- a/src/renderer/src/store/slices/editor.test.ts +++ b/src/renderer/src/store/slices/editor.test.ts @@ -579,6 +579,46 @@ describe('createEditorSlice openDiff', () => { expect(store.getState().activeGroupIdByWorktree['wt-1']).toBe(targetGroupId) }) + it('keeps a diff tab selectable after opening its target file tab', () => { + const store = createEditorTabsStore() + + store.getState().openDiff('wt-1', '/repo/file.ts', 'file.ts', 'typescript', false) + const diffFileId = 'wt-1::diff::unstaged::file.ts' + const diffTab = store + .getState() + .unifiedTabsByWorktree['wt-1']?.find((tab) => tab.contentType === 'diff') + if (!diffTab) { + throw new Error('expected diff tab') + } + + store.getState().openFile({ + filePath: '/repo/file.ts', + relativePath: 'file.ts', + worktreeId: 'wt-1', + language: 'typescript', + mode: 'edit' + }) + + const stateAfterOpen = store.getState() + const editFile = stateAfterOpen.openFiles.find((file) => file.mode === 'edit') + expect(stateAfterOpen.openFiles.find((file) => file.id === diffFileId)).toEqual( + expect.objectContaining({ mode: 'diff' }) + ) + expect(editFile).toEqual(expect.objectContaining({ id: '/repo/file.ts', mode: 'edit' })) + expect( + stateAfterOpen.unifiedTabsByWorktree['wt-1']?.find((tab) => tab.contentType === 'editor') + ?.entityId + ).toBe('/repo/file.ts') + + store.getState().activateTab(diffTab.id) + store.getState().setActiveFile(diffFileId) + + const stateAfterReselect = store.getState() + expect(stateAfterReselect.groupsByWorktree['wt-1']?.[0]?.activeTabId).toBe(diffTab.id) + expect(stateAfterReselect.activeFileId).toBe(diffFileId) + expect(stateAfterReselect.openFiles.find((file) => file.id === diffFileId)?.mode).toBe('diff') + }) + it('reuses a preview editor tab when opening a preview diff', () => { const store = createEditorTabsStore() diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index 213f575e25e..eabed3d30c5 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -978,6 +978,12 @@ function matchesEditorMode( return !modes || modes.includes(file.mode) } +function getReusableOpenFileModes(mode: OpenFile['mode']): readonly OpenFile['mode'][] { + // Why: the same path can be open as both a diff and an editable file; matching + // by path alone collapses those distinct visible tabs onto one OpenFile. + return [mode] +} + function resolveEditorFileIdForOwner( state: Pick, filePath: string, @@ -1507,11 +1513,20 @@ export const createEditorSlice: StateCreator = (s (options?.suppressActiveRuntimeFallback ? null : (s.settings?.activeRuntimeEnvironmentId?.trim() ?? undefined))) + const reusableOpenFileModes = getReusableOpenFileModes(file.mode) const existing = s.openFiles.find( (f) => - f.filePath === file.filePath && isSameEditorOwner(f, worktreeId, runtimeEnvironmentId) + f.filePath === file.filePath && + matchesEditorMode(f, reusableOpenFileModes) && + isSameEditorOwner(f, worktreeId, runtimeEnvironmentId) + ) + const id = resolveEditorFileIdForOwner( + s, + file.filePath, + worktreeId, + runtimeEnvironmentId, + reusableOpenFileModes ) - const id = resolveEditorFileIdForOwner(s, file.filePath, worktreeId, runtimeEnvironmentId) editorItemFileId = id const isPreview = options?.preview ?? false const recordReplacedPreview = options?.recordReplacedPreview ?? false diff --git a/src/shared/ai-vault-types.ts b/src/shared/ai-vault-types.ts index 8df6d611d19..d0567462743 100644 --- a/src/shared/ai-vault-types.ts +++ b/src/shared/ai-vault-types.ts @@ -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 = { diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 5c82398ff9a..ca2e5deb364 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -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, diff --git a/src/shared/git-remote-identity.test.ts b/src/shared/git-remote-identity.test.ts new file mode 100644 index 00000000000..c145526e253 --- /dev/null +++ b/src/shared/git-remote-identity.test.ts @@ -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' + }) + }) +}) diff --git a/src/shared/git-remote-identity.ts b/src/shared/git-remote-identity.ts new file mode 100644 index 00000000000..7b457f68b0b --- /dev/null +++ b/src/shared/git-remote-identity.ts @@ -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 +} diff --git a/src/shared/project-host-setup-projection.test.ts b/src/shared/project-host-setup-projection.test.ts index f0bdeea1b47..f32f49fd109 100644 --- a/src/shared/project-host-setup-projection.test.ts +++ b/src/shared/project-host-setup-projection.test.ts @@ -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({ diff --git a/src/shared/project-host-setup-projection.ts b/src/shared/project-host-setup-projection.ts index 91c0c4d427e..5c832f48b6c 100644 --- a/src/shared/project-host-setup-projection.ts +++ b/src/shared/project-host-setup-projection.ts @@ -41,6 +41,17 @@ function getProjectProviderIdentity( : null } +function getProjectGitRemoteIdentity( + repo: Pick +): NonNullable | 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): b return getProjectProviderIdentity(repo) !== null } -export function getProjectIdentityKey(repo: Pick): string { +export function getProjectIdentityKey( + repo: Pick +): 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): string { +function getProjectId( + repo: Pick +): 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 diff --git a/src/shared/types.ts b/src/shared/types.ts index 9d904f1c6a7..ff4992dee31 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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 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, diff --git a/tests/e2e/terminal-long-table-fixtures.ts b/tests/e2e/terminal-long-table-fixtures.ts new file mode 100644 index 00000000000..0ebbab7ea2a --- /dev/null +++ b/tests/e2e/terminal-long-table-fixtures.ts @@ -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') +` +} diff --git a/tests/e2e/terminal-long-table-scroll-restore.spec.ts b/tests/e2e/terminal-long-table-scroll-restore.spec.ts index 3292ea27938..1af4b1ecf41 100644 --- a/tests/e2e/terminal-long-table-scroll-restore.spec.ts +++ b/tests/e2e/terminal-long-table-scroll-restore.spec.ts @@ -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 { await page.setViewportSize({ width: 900, height: 820 }) await page.waitForTimeout(250) diff --git a/tests/e2e/terminal-pinned-viewport-worktree-switch.spec.ts b/tests/e2e/terminal-pinned-viewport-worktree-switch.spec.ts new file mode 100644 index 00000000000..bef0852dbff --- /dev/null +++ b/tests/e2e/terminal-pinned-viewport-worktree-switch.spec.ts @@ -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 { + 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('.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('.xterm-viewport') + ?.dispatchEvent(new Event('scroll', { bubbles: true })) + return { tabId, targetViewportY, baseY: buffer.baseY } + }) +} + +async function sampleTerminalViewportDuringReturn( + page: Page, + tabId: string, + durationMs: number +): Promise { + return page.evaluate( + ({ tabId, durationMs }) => + new Promise((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 }) + } + }) +}) diff --git a/tests/playwright.config.ts b/tests/playwright.config.ts index 23daa5ac4d8..c583b8fd222 100644 --- a/tests/playwright.config.ts +++ b/tests/playwright.config.ts @@ -26,10 +26,10 @@ export default defineConfig({ // substantially. The few visible-window tests that still rely on real // pointer interaction are marked serial in their spec file instead. fullyParallel: true, - // Why: each CI worker launches real Electron/Chromium process trees. Ubuntu - // runners have 4 vCPUs, but 4 parallel apps can exhaust Chromium GPU/zygote - // subprocess startup under Xvfb, so keep headless E2E below that ceiling. - workers: process.env.CI ? 2 : undefined, + // Why: each CI worker launches a real Electron/Chromium process tree against + // a mutable seeded repo. Release runners showed two apps per VM can contend + // on Xvfb/git enough to create false E2E failures, so CI scales by shards. + workers: process.env.CI ? 1 : undefined, forbidOnly: !!process.env.CI, retries: 0, reporter: 'list',