* feat(shortcuts): cycle Cmd/Ctrl+Shift+]/[ within current tab type by default
Extract a shared getNextTabWithinActiveType selector into a new
tab-type-cycle.ts helper and route both useTerminalShortcuts.ts and
ipc-tab-switch.ts through it. Cmd/Ctrl+Shift+]/[ now cycles within the
active tab's type (terminal cycles only terminals, editor cycles only
editor tabs, browser cycles only browser tabs). Ctrl+PageDown/Up
remains the dedicated terminal-only chord (PR #1094) regardless of
focus.
useTerminalShortcuts gains optional activeBrowserTabId and
onActivateBrowserTab parameters so browser tabs can dispatch through
the same path. The default behavior matches VS Code's
TerminalContextKeys.focus-gated cycling and Superset's per-scope
chord registry referenced in the issue.
Adds tab-type-cycle.test.ts covering single-type, mixed-type, and
single-tab no-op cases. ipc-tab-switch.test.ts updated to assert
same-type cycling under the new selector.
Closes#1100
* fix(shortcuts): use direction-aware fallback when active tab is missing
When the active tab id is not present in the same-type subset (e.g.
during hydration when an editor is active and Cmd+Shift+]/[ targets
terminals), findIndex returns -1 and the previous modulo math made
backward navigation land on the second-to-last tab instead of the
last. Branch on the -1 case to return the last tab for direction=-1
and the first tab for direction=+1, so both directions are
predictable. Adds a regression test covering 3 terminal tabs with no
active match.
* feat(shortcuts): add Cmd/Ctrl+Alt+[/] to cycle across all tab types
Adds an "all types" variant of the tab cycle chord so users can page
through every tab in the active group regardless of type (terminal /
editor / browser), complementing the type-scoped Cmd/Ctrl+Shift+[/].
- Wires the chord through the main-window keydown handler, the browser
guest shortcut forwarder, and a new ui:switchTabAcrossAllTypes IPC
channel
- Factors getNextTabAcrossAllTypes alongside getNextTabWithinActiveType
and shares the ipc-tab-switch dispatch logic via resolveCycleContext
and applyNextTab helpers to avoid drift between the two chords
- Narrows useTerminalShortcuts activeTabType to 'terminal' | 'editor'
(the hook's unifiedTabs never contains browsers) and drops the
unsound TypeCyclableTab cast
- Updates ShortcutsPane to list the new chord and expands tests
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Co-authored-by: Orca <help@stably.ai>
Typing a path like `Documents/orca-internal` in the browse filter now resolves the path on the remote instead of producing "No matches". Supports `~`, absolute, and relative paths with live preview and Enter to navigate.
Co-authored-by: Orca <help@stably.ai>
* fix(cmd-j): rank empty-query worktrees by focus recency
Persist a per-worktree focus-recency timestamp and use it as the primary
ordering signal for Cmd+J's empty-query Worktrees section, so SSH and
other quiet worktrees surface based on user focus rather than background
activity. See docs/cmd-j-empty-query-ordering.md.
Co-authored-by: Orca <help@stably.ai>
* fix(tests): guard lastVisitedAtByWorktreeId and mock markWorktreeVisited
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
Deleting an unselected, scrolled-past worktree snapped the sidebar back
toward the selected item. The virtualizer's viewport key includes row
count so it can remount cleanly when rows change (avoiding stale height
measurements that caused card overlap) — but the fresh mount started at
scrollTop 0.
Persist the scroll offset in a parent-owned ref, seed the new
virtualizer via initialOffset, and re-apply scrollTop in a
useLayoutEffect with a retry loop that tolerates the transient window
where the browser clamps scrollTop while the virtualizer is still
measuring rows.
Co-authored-by: Orca <help@stably.ai>
* fix(runtime): kill all PTYs for a worktree on removal (design §4.3)
Worktree deletion only shut down renderer-tracked terminals, so PTYs owned
by background tabs, split panes, or pre-reload sessions survived the
removal and kept leaking memory. Introduce killAllProcessesForWorktree
with three sweeps (runtime leaves, provider-prefix scan of daemon session
ids, pty-registry by worktreeId) and wire it into both teardown paths:
the CLI-initiated removeManagedWorktree and the renderer-initiated
worktrees:remove IPC handler. OrcaRuntimeService gets a lazy
getLocalProvider thunk so construction order stays robust.
Co-authored-by: Orca <help@stably.ai>
* fix(renderer): purge worktree-scoped state on removal + hydration (design §4.4)
When a worktree is deleted, ~25 worktree-scoped maps (tabsByWorktree,
git caches, browser state, split-tab models, per-file editor drafts,
etc.) kept references to the gone worktree, so SessionsStatusSegment
kept mis-classifying orphaned PTYs as bound and dropdowns rendered stale
ids. Add purgeWorktreeTerminalState as a single atomic action that
wipes every scoped map plus cascades top-level actives. Fire it from
the worktrees:changed listener on the set-diff of removed ids, and once
more at hydration via fetchAllWorktrees to clean up persisted entries
from pre-fix sessions. The hydration-time purge is gated behind a
per-repo success check: a single transient IPC error or an all-empty
fetch defers the purge so a degraded launch cannot wipe legitimate
persisted state.
Co-authored-by: Orca <help@stably.ai>
* test(zombie-worktree): regression coverage for design §4.5
Adds tests for every layer of the zombie-worktree fix:
- worktree-teardown: unit coverage of the three-sweep helper including
best-effort error swallowing across provider/registry.
- orca-runtime: RPC-initiated removeManagedWorktree kills PTYs before
any git mutation + verifies the lazy getLocalProvider thunk resolves
on each call.
- worktrees IPC: renderer-initiated remove kills PTYs before git and
skips the kill helper for SSH-backed repos.
- renderer slice: fetchAllWorktrees defers the purge when any sibling
repo fetch fails or every repo returns empty (F1 regression);
happy-path fires the purge once and does not re-run on subsequent
calls. Direct purgeWorktreeTerminalState coverage pins the cascade
across worktree-keyed, tab-id-keyed, and file-id-keyed maps.
Co-authored-by: Orca <help@stably.ai>
* fix(runtime): log worktree-teardown kill counts (design §4.4 observability)
Breadcrumb lets ops distinguish a renderer-state-induced leak (diff-path
purge non-empty) from a backend-induced one (nothing to kill but memory
still pinned). Emit only when the sweep actually shut anything down so
steady-state logs stay quiet. Added at both call sites —
removeManagedWorktree (CLI path) and the worktrees:remove IPC handler.
Co-authored-by: Orca <help@stably.ai>
* test(zombie-worktree): fix ptyIdsByTabId seed shape to match production type
The purge unit test seeded ptyIdsByTabId as Record<string, string> when
the runtime type is Record<string, string[]>. The unit tests passed
because they never hit the UI renderer, but live e2e surfaced a
TypeError: (ptyIdsByTabId[tabId] ?? []).some is not a function.
Corrected to arrays; 21/21 tests still pass.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
Partially reverts #1359: drop the hideIdentityIcon flag on the inline
agent list so the Claude/Gemini/… glyph renders again. The expand
chevron stays hidden — clicking the row still jumps straight to the
agent, and the chevron was the redundant part. Identity is useful when
a worktree has more than one agent of different types.
Co-authored-by: Orca <help@stably.ai>
* fix(daemon): guard killStaleDaemon against pid recycling
The daemon's pid file carried only a bare integer, so killStaleDaemon had
no way to verify the current owner of that pid was still the process it
forked. Unix pids recycle — on macOS the default ceiling is ~2048 and
wraps in minutes under load — so blind SIGTERM/SIGKILL on a recycled pid
could hit an unrelated user process (editor, language server, background
task) silently.
Three moving pieces:
1. Pid file now carries startedAtMs. daemon-spawner exports DaemonPidFile
and serializeDaemonPidFile; daemon-init fills startedAtMs from
getProcessStartedAtMs(child.pid) right after the daemon signals ready.
parseDaemonPidFile tries JSON first and falls back to bare-integer for
backward compatibility (startedAtMs: null on legacy files).
2. isDaemonProcess takes expected startedAtMs. In addition to the cmdline
match, it consults getProcessStartedAtMs(pid) and compares with a
±1.5s tolerance. Null on either side is fail-open so the guard never
strengthens existing behavior negatively (Windows, legacy pid files,
kernel-thread /proc failures).
3. SIGKILL re-check. The SIGTERM-then-wait window is up to 3s — long
enough for the pid to be recycled if the original daemon dies during
the wait. Before escalating to SIGKILL, isDaemonProcess runs again;
on mismatch we log reason=pid_recycled and skip SIGKILL.
Carried forward from #1323 as a standalone safety fix — independent of
that PR's router/drain machinery, which is not shipping.
Co-authored-by: Orca <help@stably.ai>
* test(daemon): cover pid-recycling guard surface in daemon-health
Adds unit coverage for the new Phase 0 surface:
- parseDaemonPidFile: JSON round-trip, JSON without startedAtMs,
bare-integer fallback for legacy pid files, malformed-input rejection.
- startTimeMatches: null-expected fail-open, null-actual fail-open,
within-tolerance match, outside-tolerance rejection.
- killStaleDaemon: with a mismatched startedAtMs in the pid file,
assert that no SIGTERM/SIGKILL is sent even though the liveness probe
(process.kill(pid, 0)) runs.
startTimeMatches was promoted from module-private to exported so it can
be exercised directly — it's a pure function with no internal state.
Co-authored-by: Orca <help@stably.ai>
* feat(settings): manage sessions panel for daemon staleness UX
Add a Manage Sessions settings panel with list/kill-all/kill-one/restart
backed by a new pty:management IPC surface. Rows are hover-highlighted and
click to reveal the corresponding terminal pane, mirroring the bottom
status-bar sessions popover. Kill-all and restart-daemon are icon buttons
(Trash2, RotateCw) with tooltips so the restart action doesn't collide with
the row-refresh RefreshCw icon.
Co-authored-by: Orca <help@stably.ai>
* fix(settings): honest killAll counts + suppress post-kill spawn toast
killAll now snapshots the initial session IDs and polls listSessions every
100ms for up to 6.5s — past the daemon's 5s SIGTERM→SIGKILL ladder — so
well-behaved shells hosting long-running agents finish their SIGTERM
handlers before we classify them as "refused to exit." Shutdowns fire once
per initial session (no retry spam), and fresh session IDs that appear
mid-poll (renderer remounts) don't inflate remainingCount.
pty-transport's connect() catch now detects the adapter's
TerminalKilledError tombstone rejection ("...was explicitly killed") and
suppresses the red "file an issue" toast. After Kill All, a pane remount
would call pty:spawn on the dead session ID; surfacing the tombstone as
a scary error misrepresented an intentional user action. The pane still
renders "Process exited" via the normal lifecycle.
Co-authored-by: Orca <help@stably.ai>
* feat(daemon): foundation for pty:management IPC surface
Adds the plumbing the Manage Sessions settings panel depends on:
- daemon-init exports getDaemonProvider / replaceDaemonProvider /
restartDaemon / cleanupDaemonForProtocol so the pty:management
handlers can access the current provider and coordinate a clean
restart without importing window-services internals.
- daemon-pty-router exports getAllAdapters so the killAll / listSessions
handlers can fan across the current adapter plus any legacy-protocol
adapters still attached for in-flight sessions.
- daemon-pty-adapter gains a listSessions RPC and readonly
protocolVersion so the handlers can annotate each session with the
adapter it belongs to and route killOne back to the right adapter.
- types.ts exports DaemonSessionInfo (SessionInfo + protocolVersion)
as the shared shape the preload API surface mirrors.
- ipc/pty.ts, attach-main-window-services, TerminalPane and
terminal-search pick up the small bindings required to wire the
router through existing code paths without regressions.
Co-authored-by: Orca <help@stably.ai>
* fix(settings): remove high-session-count warning banner
The banner nagged at 20 sessions, which is well within normal use for
users with many open worktrees. Count is already visible in the header
bar, and the table supports per-row and bulk kills, so the banner added
noise without actionable value.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* feat(browser): viewport-size emulation via CDP
Adds a Viewport Size submenu in the browser toolbar "…" menu.
Presets apply width/height/deviceScaleFactor/mobile/touch via
Emulation.setDeviceMetricsOverride + setTouchEmulationEnabled, and
swap the UA to a mobile iPhone CriOS string on mobile presets.
Responsive clears the override. Selection persists per-tab and
re-applies on dom-ready so it survives navigations.
Co-authored-by: Orca <help@stably.ai>
* chore(browser): rename 'Responsive' viewport option to 'Default'
Co-authored-by: Orca <help@stably.ai>
* fix(browser): harden viewport emulation — serialize, validate, client-hints
- Chain per-tab setViewportOverride calls to prevent rapid-toggle races
- Validate viewport metrics at IPC trust boundary (reject non-finite/out-of-range)
- Emit userAgentMetadata alongside mobile UA to avoid UA/CH mismatch
- Always reapply on dom-ready (incl. null) to clear stale emulation
- Persist viewportPresetId in session schema (optional+nullable for back-compat)
- Convert preset submenu to DropdownMenuRadioGroup for a11y
- Log debugger.attach failures and cover with a unit test
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
Register a window-level keydown listener while the SSH disconnected
dialog is open so Enter triggers Reconnect regardless of which element
holds focus. Dialog-scoped handlers don't fire when focus is inside
xterm/monaco, which aggressively reclaim focus.
Co-authored-by: Orca <help@stably.ai>
* wip
* refactor(sidebar): drop agent identity icon + expand chevron from inline rows
In the per-card inline agent list, the user already knows which worktree
they're looking at and clicking a row jumps directly to the agent — the
repeated identity glyph (Claude/Gemini/…) and a separate expand chevron
are redundant. Add hideIdentityIcon/hideExpand props to DashboardAgentRow
(default false) and set both on the inline list only; full dashboard is
unchanged.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* feat: close settings on Escape key press
* fix(settings): skip Escape-to-close when focus is in editable field
Why: Escape in an input/textarea/select or contenteditable region usually
means 'cancel this edit', not 'close Settings'. Closing the entire page
would discard in-progress typing. Defer to the field's own handler; a
subsequent Escape with focus on the body will close the page.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Co-authored-by: Orca <help@stably.ai>
Allows the user to reposition the sidekick overlay by pressing and dragging. Position is clamped to the viewport (including on resize) and persisted to localStorage. Uses pointer capture so drag state can't get stuck if the pointer is released outside the window.
Co-authored-by: Orca <help@stably.ai>
Renames the experimental pet overlay to "sidekick" with themed character
names (Claude the Mage, OpenCode the Rogue, Gremlin the Trickster).
Covers IPC channels, preload API, persisted UI state, settings flag,
on-disk userData path, components, and types.
Deletes the standalone pet-overlay design mock.
Co-authored-by: Orca <help@stably.ai>
* feat: add experimental pet overlay
Adds an opt-in 3D pet overlay pinned to the bottom-right. Gated behind
an experimental flag so three.js + GLB models stay out of the renderer
bundle for users who never enable the feature. Ships with four bundled
models plus user-uploaded custom GLBs via a pet:import IPC; a status-bar
segment provides model picker + hide toggle.
See docs/design/pet-overlay.md for the full design.
Co-authored-by: Orca <help@stably.ai>
* refactor(pet): replace 3D GLB models with 2D webp images
Co-authored-by: Orca <help@stably.ai>
* chore(pet): compress pet webp images and remove unused assets
Reduce resources/claude.webp, gremlin.webp, opencode.webp sizes; drop
the obsolete pet-overlay design doc and compress-pet-glb script now
that the GLB pipeline has been replaced by 2D webp images.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* fix(tabs): align tab row bottom with sidebar header
The center column's top band was 6px drag strip + 34px tab row = 40px,
while the sibling `titlebar-left` above the sidebar is 42px. The tab row
sat 2px high, making the seam between the two columns visibly off.
Bump the drag strip from 6px to 8px so the top band totals 42px and the
tab row's bottom border lines up with the sidebar header.
Co-authored-by: Orca <help@stably.ai>
* fix(tabs): shrink tab row to 32px, pad drag strip to 10px
Keep the total top-band at 42px (matching the sidebar's titlebar-left)
while making the tabs themselves 2px shorter, so the tab chrome feels a
touch more compact without breaking alignment with the sidebar header.
Co-authored-by: Orca <help@stably.ai>
* fix(tabs): drop doubled border on leftmost split pane
The TabGroupSplitLayout wrapper paints a full-height `border-l` at the
sidebar seam, and every split pane paints its own `border-l`. On the
leftmost pane those stacked at the same x, reading as a ~2px bar just
below the drag strip where the wrapper's 1px line continues alone above.
Mirror the right-edge handling: skip the pane's `border-l` when it
touches the left edge, leaving the single wrapper border to draw the
seam — same visual weight as the right edge.
Co-authored-by: Orca <help@stably.ai>
* docs(tabs): refresh stale wrapper border-l comment
Why: `border-l` on the wrapper comment still described the pre-fix
overlap behavior. After `touchesLeftEdge`, the leftmost pane drops
its own `border-l`, so the seam is exactly 1px — the old comment
contradicted the code.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
- BrowserTab: Globe icon uses text-blue-500 on both active and inactive tabs
for a distinct, recognizable anchor in the tab strip.
- shell-icons: GenericTerminalIcon now renders a pitch-black tile with a
thick stroked >_ (chevron at 3.2px, underscore at 2.6px, taller chevron)
so mac/linux/default terminal tabs match the colored-badge treatment of
the PowerShell/CMD/WSL icons instead of a flat lucide chevron.
- SortableTab: all terminal tabs use ShellIcon. On Windows, tabs without a
per-tab shellOverride fall back to the user's configured default
Windows shell so the tab-strip icon reflects what's actually running.
Co-authored-by: Orca <help@stably.ai>
getConnectionId returns undefined during store hydration, which was
misclassified as remote by `!== null` check. Changed to `typeof === 'string'`
so unhydrated state falls through to client-OS quoting, consistent with
terminal-drop-handler.ts behavior.
Co-authored-by: Orca <help@stably.ai>