From 2f79017d1802398237f8e62f1226c502bedce3e2 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 5 May 2026 12:23:26 -0700 Subject: [PATCH 001/213] fix(source-control): hide commit input when there are no uncommitted changes (#1448) Co-authored-by: Orca --- src/renderer/src/components/right-sidebar/SourceControl.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index 788c4bdc58e..a3ffba6143f 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -1137,7 +1137,7 @@ function SourceControlInner(): React.JSX.Element { /> )} - {(scope === 'all' || scope === 'uncommitted') && ( + {(scope === 'all' || scope === 'uncommitted') && hasUncommittedEntries && ( 0} From cdd21423eb984b2cb9838c17eedf13884a9a7b1f Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 5 May 2026 12:43:47 -0700 Subject: [PATCH 002/213] fix(mobile): unstick worktree spinner once agent exits, match desktop StatusIndicator (#1449) Co-authored-by: Orca --- mobile/src/components/AgentSpinner.tsx | 43 ++++++++++++++++++------- src/main/runtime/orca-runtime.test.ts | 40 +++++++++++++++++++++++ src/main/runtime/orca-runtime.ts | 44 ++++++++++++++++++++++---- 3 files changed, 109 insertions(+), 18 deletions(-) diff --git a/mobile/src/components/AgentSpinner.tsx b/mobile/src/components/AgentSpinner.tsx index 734cb8bbc6d..673bbff049e 100644 --- a/mobile/src/components/AgentSpinner.tsx +++ b/mobile/src/components/AgentSpinner.tsx @@ -1,15 +1,20 @@ import { useEffect, useRef } from 'react' import { Animated, Easing, StyleSheet, View } from 'react-native' -import { colors } from '../theme/mobile-theme' type WorktreeStatus = 'working' | 'active' | 'permission' | 'done' | 'inactive' +// Why: colors and sizing are 1:1 with the desktop StatusIndicator +// (src/renderer/src/components/sidebar/StatusIndicator.tsx) so the mobile +// worktree list reads identically to the sidebar — same yellow spinner for +// 'working', same emerald dot for 'active'/'done', same neutral-500 @ 40% +// for 'inactive', same red for 'permission'. Diverging palettes here lose +// the design intent ('moving' vs 'alive' vs 'completed') the desktop encodes. const STATUS_COLORS: Record = { - working: colors.statusGreen, - active: colors.statusGreen, - permission: colors.statusRed, - done: '#38bdf8', - inactive: '#666666' + working: '#eab308', + active: '#10b981', + done: '#10b981', + permission: '#ef4444', + inactive: 'rgba(115,115,115,0.4)' } export function AgentSpinner({ status }: { status: WorktreeStatus }) { @@ -39,23 +44,39 @@ export function AgentSpinner({ status }: { status: WorktreeStatus }) { outputRange: ['0deg', '360deg'] }) return ( - + + + ) } - return + return ( + + + + ) } const styles = StyleSheet.create({ + // Why: 12x12 wrapper centered around an 8x8 inner glyph mirrors the + // desktop's `inline-flex h-3 w-3 ... items-center justify-center` shell + // around `size-2` indicator — keeps row height/baseline alignment stable + // across status transitions. + wrapper: { + width: 12, + height: 12, + alignItems: 'center', + justifyContent: 'center' + }, dot: { width: 8, height: 8, borderRadius: 4 }, spinner: { - width: 10, - height: 10, - borderRadius: 5, + width: 8, + height: 8, + borderRadius: 4, borderWidth: 2, borderTopColor: 'transparent' } diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 56e5c1029c5..d4d67b91721 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -793,6 +793,46 @@ describe('OrcaRuntimeService', () => { }) }) + it('clears stale working status after the agent exits and the shell takes over the title', async () => { + // Why: regression test for issue #1437 — the mobile worktree-list spinner + // kept playing forever because lastAgentStatus was sticky on 'working' + // once an agent exited without emitting an idle/agent-shaped final OSC + // title. worktree.ps must recompute from the live OSC title each call. + const runtime = new OrcaRuntimeService(store) + + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + title: 'Codex working', + activeLeafId: 'pane:1', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + leafId: 'pane:1', + paneRuntimeId: 1, + ptyId: 'pty-1' + } + ] + }) + + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) + const working = await runtime.getWorktreePs() + expect(working.worktrees[0].status).toBe('working') + + // Agent exits, shell title takes over — desktop's getWorktreeStatus would + // immediately flip back to 'active'. Mobile must do the same. + runtime.onPtyData('pty-1', '\x1b]0;bash\x07', 200) + const afterExit = await runtime.getWorktreePs() + expect(afterExit.worktrees[0].status).toBe('active') + }) + it('fails terminal stop closed while the renderer graph is reloading', async () => { const runtime = new OrcaRuntimeService(store) let killed = false diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 8c8ce9e4262..1af46a117ec 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -167,6 +167,12 @@ type RuntimeLeafRecord = RuntimeSyncedLeaf & { tailLinesTotal: number preview: string lastAgentStatus: AgentStatus | null + // Why: the most recent OSC title observed on this leaf's PTY data. Used by + // worktree.ps so daemon-hosted terminals (no renderer pushing pane titles) + // still recompute working/idle from the live title each call instead of + // serving a stale `lastAgentStatus` after the agent process exits and the + // shell takes over the title — the bug behind issue #1437. + lastOscTitle: string | null } type RuntimePtyWorktreeRecord = { @@ -632,7 +638,8 @@ export class OrcaRuntimeService { tailTruncated: existing?.ptyId === ptyId ? existing.tailTruncated : false, tailLinesTotal: existing?.ptyId === ptyId ? existing.tailLinesTotal : 0, preview: existing?.ptyId === ptyId ? existing.preview : '', - lastAgentStatus: existing?.ptyId === ptyId ? existing.lastAgentStatus : null + lastAgentStatus: existing?.ptyId === ptyId ? existing.lastAgentStatus : null, + lastOscTitle: existing?.ptyId === ptyId ? existing.lastOscTitle : null }) if (leaf.ptyId) { @@ -790,8 +797,21 @@ export class OrcaRuntimeService { leaf.tailLinesTotal += nextTail.newCompleteLines leaf.preview = buildPreview(leaf.tailBuffer, leaf.tailPartialLine) - if (agentStatus !== null) { + if (oscTitle !== null) { + // Why: keep the latest OSC title on the leaf so worktree.ps can + // recompute status from the live title each call. Without this, + // daemon-hosted terminals (no renderer pushing pane titles) had no + // way to clear a stale 'working' status after the agent exited and + // the shell took over the title — the stuck-spinner bug in #1437. + leaf.lastOscTitle = oscTitle const prevStatus = leaf.lastAgentStatus + // Why: when a new OSC title doesn't classify as an agent state (e.g. + // bare shell title after the agent exits), clear lastAgentStatus so + // it is no longer sticky. Tui-idle waiters that needed the previous + // 'idle' transition were already resolved at the moment of the + // transition below; only fresh waiters registered after the agent + // exits would observe the cleared value, and they correctly fall + // back to title-based detection / polling. leaf.lastAgentStatus = agentStatus // Why: resolve tui-idle on any transition TO idle (not just working→idle). // Claude Code may skip "working" entirely on fast tasks, going null→idle, @@ -986,12 +1006,15 @@ export class OrcaRuntimeService { return } const status = detectAgentStatusFromTitle(title) - if (status === null) { - return - } for (const leaf of this.leaves.values()) { if (leaf.ptyId === ptyId) { - leaf.lastAgentStatus = status + // Why: seed lastOscTitle even when the seeded title doesn't classify + // as an agent state, so worktree.ps recomputes status from the live + // title rather than treating the leaf as agentless. + leaf.lastOscTitle = title + if (status !== null) { + leaf.lastAgentStatus = status + } } } } @@ -5695,7 +5718,14 @@ function getLeafWorktreeStatus( leaf: RuntimeLeafRecord, tabTitle: string | null ): RuntimeWorktreeStatus { - const detected = leaf.lastAgentStatus ?? detectAgentStatusFromTitle(leaf.title ?? tabTitle ?? '') + // Why: recompute from the live title each call so worktree.ps mirrors what + // the desktop sidebar's getWorktreeStatus does (no sticky state). Prefer + // the runtime-tracked OSC title (covers daemon-hosted terminals) over the + // renderer-pushed leaf.title and the tab title. Falling back to + // lastAgentStatus only when no title is available preserves a sensible + // signal for very fresh leaves before any title has been observed. + const liveTitle = leaf.lastOscTitle ?? leaf.title ?? tabTitle ?? '' + const detected = liveTitle ? detectAgentStatusFromTitle(liveTitle) : leaf.lastAgentStatus if (detected === 'permission') { return 'permission' } From 148f33c47062562d0678ec8972ed20edf08b1ec9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 5 May 2026 12:59:15 -0700 Subject: [PATCH 003/213] fix(telemetry): refresh settings_changed whitelist; agent_kind runtime guard (#1450) Co-authored-by: Orca --- src/shared/agent-kind.test.ts | 32 ++++++++++++++++ src/shared/agent-kind.ts | 59 ++++++++++++++++++----------- src/shared/telemetry-events.test.ts | 7 ++-- src/shared/telemetry-events.ts | 53 +++++++++++++++++++------- 4 files changed, 112 insertions(+), 39 deletions(-) create mode 100644 src/shared/agent-kind.test.ts diff --git a/src/shared/agent-kind.test.ts b/src/shared/agent-kind.test.ts new file mode 100644 index 00000000000..98c56da9c1d --- /dev/null +++ b/src/shared/agent-kind.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' + +import { tuiAgentToAgentKind } from './agent-kind' +import { AGENT_KIND_VALUES, agentKindSchema } from './telemetry-events' +import { TUI_AGENT_CONFIG } from './tui-agent-config' +import type { TuiAgent } from './types' + +describe('tuiAgentToAgentKind', () => { + it('maps every shipped TuiAgent to a concrete telemetry kind', () => { + const agents = Object.keys(TUI_AGENT_CONFIG) as TuiAgent[] + + for (const agent of agents) { + const kind = tuiAgentToAgentKind(agent) + + expect(kind).not.toBe('other') + expect(agentKindSchema.safeParse(kind).success).toBe(true) + } + }) + + it('keeps concrete telemetry kinds in exact sync with shipped TuiAgents', () => { + const agents = Object.keys(TUI_AGENT_CONFIG) as TuiAgent[] + const mappedKinds = agents.map((agent) => tuiAgentToAgentKind(agent)).sort() + const concreteSchemaKinds = AGENT_KIND_VALUES.filter((kind) => kind !== 'other').sort() + + expect(mappedKinds).toEqual(concreteSchemaKinds) + }) + + it('uses the product id for Claude and the TuiAgent id for Pi', () => { + expect(tuiAgentToAgentKind('claude')).toBe('claude-code') + expect(tuiAgentToAgentKind('pi')).toBe('pi') + }) +}) diff --git a/src/shared/agent-kind.ts b/src/shared/agent-kind.ts index 04bd0aed2b1..efd1da48318 100644 --- a/src/shared/agent-kind.ts +++ b/src/shared/agent-kind.ts @@ -1,8 +1,7 @@ // Mapping from the renderer's `TuiAgent` union (every agent Orca knows how -// to launch) to the closed `agentKindSchema` enum on telemetry events. The -// telemetry enum is a deliberately smaller set — anything not enumerated -// maps to `'other'` so dashboards can spot interest in an agent before its -// slot is added rather than dropping the event. +// to launch) to the closed `agentKindSchema` enum on telemetry events. Every +// shipped agent maps to a concrete telemetry value so dashboards can +// distinguish launch interest instead of collapsing the long tail to `other`. // // Lives in `src/shared/` (not the renderer) because main-side telemetry // emission (`agent_started` from the `pty:spawn` IPC handler) needs the @@ -12,23 +11,39 @@ import type { AgentKind } from './telemetry-events' import type { TuiAgent } from './types' +type ConcreteAgentKind = Exclude + +const TUI_AGENT_KIND_BY_AGENT = { + claude: 'claude-code', + codex: 'codex', + autohand: 'autohand', + opencode: 'opencode', + pi: 'pi', + gemini: 'gemini', + aider: 'aider', + goose: 'goose', + amp: 'amp', + kilo: 'kilo', + kiro: 'kiro', + crush: 'crush', + aug: 'aug', + cline: 'cline', + codebuff: 'codebuff', + continue: 'continue', + cursor: 'cursor', + droid: 'droid', + kimi: 'kimi', + 'mistral-vibe': 'mistral-vibe', + 'qwen-code': 'qwen-code', + rovo: 'rovo', + hermes: 'hermes', + copilot: 'copilot' +} satisfies Record + +// Why: `satisfies Record` makes the lookup exhaustive at compile +// time, but stale persisted settings or unsafe IPC casts can carry a string +// outside the union at runtime — fall back to `'other'` so the event still +// emits instead of failing validation and dropping silently. export function tuiAgentToAgentKind(agent: TuiAgent): AgentKind { - switch (agent) { - case 'claude': - return 'claude-code' - case 'codex': - return 'codex' - case 'copilot': - return 'copilot' - case 'gemini': - return 'gemini' - case 'cursor': - return 'cursor' - case 'opencode': - return 'opencode' - case 'aider': - return 'aider' - default: - return 'other' - } + return TUI_AGENT_KIND_BY_AGENT[agent] ?? 'other' } diff --git a/src/shared/telemetry-events.test.ts b/src/shared/telemetry-events.test.ts index 04bc23be3e0..1a6f76a864a 100644 --- a/src/shared/telemetry-events.test.ts +++ b/src/shared/telemetry-events.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest' import { + AGENT_KIND_VALUES, agentKindSchema, commonPropsSchema, errorClassSchema, @@ -189,9 +190,9 @@ describe('commonPropsSchema', () => { describe('exported enum schemas', () => { it('agentKindSchema accepts the known product IDs', () => { - expect(agentKindSchema.safeParse('claude-code').success).toBe(true) - expect(agentKindSchema.safeParse('codex').success).toBe(true) - expect(agentKindSchema.safeParse('other').success).toBe(true) + for (const kind of AGENT_KIND_VALUES) { + expect(agentKindSchema.safeParse(kind).success).toBe(true) + } }) it('errorClassSchema rejects novel classes', () => { diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts index 91751968a90..a12d8ec0f74 100644 --- a/src/shared/telemetry-events.ts +++ b/src/shared/telemetry-events.ts @@ -14,25 +14,44 @@ import { z } from 'zod' +import type { GlobalSettings } from './types' + // ── Shared property enums ─────────────────────────────────────────────── -// Mirrors the detectable agents in `src/shared/agent-detection.ts` -// (`AGENT_NAMES`), with one deliberate shift: `claude` in AGENT_NAMES ↔ -// `claude-code` here (product, not CLI string) so dashboards read cleanly. +// Mirrors the shipped `TuiAgent` launch surface, with one deliberate shift: +// `claude` in settings/launch state ↔ `claude-code` here (product, not CLI +// string) so dashboards read cleanly. // -// Enum values are limited to agents that have a real emit path today. Adding -// a new agent is additive-safe — extend this enum when the call site that -// would emit it lands, not in anticipation. -export const agentKindSchema = z.enum([ +// `other` remains as a telemetry escape hatch, but project-owned TuiAgents +// should map to concrete values; see `tuiAgentToAgentKind`. +export const AGENT_KIND_VALUES = [ 'claude-code', 'codex', - 'gemini', - 'copilot', - 'cursor', + 'autohand', 'opencode', + 'pi', + 'gemini', 'aider', + 'goose', + 'amp', + 'kilo', + 'kiro', + 'crush', + 'aug', + 'cline', + 'codebuff', + 'continue', + 'cursor', + 'droid', + 'kimi', + 'mistral-vibe', + 'qwen-code', + 'rovo', + 'hermes', + 'copilot', 'other' -]) +] as const +export const agentKindSchema = z.enum(AGENT_KIND_VALUES) export type AgentKind = z.infer // Trimmed to the two values Orca's PTY-typed-command launch architecture can @@ -105,12 +124,18 @@ export type OptInVia = z.infer // // Kept as an `as const` tuple so the Zod enum below and any call-site usage // share one array — typo-drift is impossible. +type BooleanGlobalSettingsKey = { + [Key in keyof GlobalSettings]-?: GlobalSettings[Key] extends boolean ? Key : never +}[keyof GlobalSettings] export const SETTINGS_CHANGED_WHITELIST = [ 'editorAutoSave', 'openLinksInApp', - 'experimentalTerminalDaemon', - 'experimentalAgentDashboard' -] as const + 'experimentalAgentDashboard', + 'experimentalMobile', + 'experimentalSidekick', + 'experimentalWorktreeSymlinks', + 'geminiCliOAuthEnabled' +] as const satisfies readonly BooleanGlobalSettingsKey[] export const settingsChangedKeySchema = z.enum(SETTINGS_CHANGED_WHITELIST) export type SettingsChangedKey = z.infer From 3b0dda595dc3377dd3427c40931dd58550a3a563 Mon Sep 17 00:00:00 2001 From: "buf0-bot[bot]" <252831055+buf0-bot[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 15:09:28 -0700 Subject: [PATCH 004/213] fix: pr-bug-scan findings from #1444 (#1466) * fix: address pr-bug-scan findings from #1444 Threaded linkedPRNumber through all ChecksPanel force-refresh paths; gated useComposerState's PR-URL-from-name recovery on selected repo slug match; wrapped main-process linkedPR fallback gh pr view i * fix: satisfy react-hooks/exhaustive-deps in selectedRepoSlug effect Extract selectedRepo?.path into a const so the effect's only dep is the path string, removing the missing-dependency warning. --------- Co-authored-by: orca-bug-scan-bot Co-authored-by: Neil --- src/main/github/client.ts | 12 ++++- .../components/right-sidebar/ChecksPanel.tsx | 19 ++++--- src/renderer/src/hooks/useComposerState.ts | 50 ++++++++++++++++++- 3 files changed, 69 insertions(+), 12 deletions(-) diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 55bf9035d66..033998d85ac 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -941,8 +941,16 @@ export async function getPRForBranch( '--json', 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' ] - const { stdout } = await ghExecFileAsync(args, { cwd: repoPath }) - data = JSON.parse(stdout) + try { + const { stdout } = await ghExecFileAsync(args, { cwd: repoPath }) + data = JSON.parse(stdout) + } catch { + // Why: a stale linkedPRNumber (PR deleted, wrong repo, …) makes + // `gh pr view ` reject. Treat that as the no-PR case so + // callers see the historical `null` semantics instead of a thrown + // error every poll cycle. + data = null + } } if (!data) { diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index df4205926a8..47a86066972 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -105,8 +105,8 @@ export default function ChecksPanel(): React.JSX.Element { // them so we don't keep rendering cached branch summaries or empty file // lists from an older payload. conflictSummaryRefreshKeyRef.current = refreshKey - void fetchPRForBranch(repo.path, branch, { force: true }) - }, [repo, isFolder, branch, pr, fetchPRForBranch]) + void fetchPRForBranch(repo.path, branch, { force: true, linkedPRNumber: linkedPR }) + }, [repo, isFolder, branch, pr, linkedPR, fetchPRForBranch]) // Fetch checks via cached store method const fetchChecks = useCallback( @@ -235,7 +235,10 @@ export default function ChecksPanel(): React.JSX.Element { } setIsRefreshing(true) try { - const refreshedPR = await fetchPRForBranch(repo.path, branch, { force: true }) + const refreshedPR = await fetchPRForBranch(repo.path, branch, { + force: true, + linkedPRNumber: linkedPR + }) if (refreshedPR) { await Promise.all([ fetchChecks({ force: true, prNumberOverride: refreshedPR.number }), @@ -248,7 +251,7 @@ export default function ChecksPanel(): React.JSX.Element { } finally { setIsRefreshing(false) } - }, [repo, branch, fetchPRForBranch, fetchChecks, fetchComments]) + }, [repo, branch, linkedPR, fetchPRForBranch, fetchChecks, fetchComments]) const handleStartEdit = useCallback(() => { if (!pr) { @@ -278,13 +281,13 @@ export default function ChecksPanel(): React.JSX.Element { }) if (ok) { // Re-fetch PR to get updated title - await fetchPRForBranch(repo.path, branch, { force: true }) + await fetchPRForBranch(repo.path, branch, { force: true, linkedPRNumber: linkedPR }) } } finally { setTitleSaving(false) setEditingTitle(false) } - }, [repo, pr, titleDraft, branch, fetchPRForBranch]) + }, [repo, pr, titleDraft, branch, linkedPR, fetchPRForBranch]) const handleTitleKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -318,9 +321,9 @@ export default function ChecksPanel(): React.JSX.Element { // Refresh PR (passed to PRActions) const handleRefreshPR = useCallback(async () => { if (repo && branch) { - await fetchPRForBranch(repo.path, branch, { force: true }) + await fetchPRForBranch(repo.path, branch, { force: true, linkedPRNumber: linkedPR }) } - }, [repo, branch, fetchPRForBranch]) + }, [repo, branch, linkedPR, fetchPRForBranch]) // Open PR in browser const handleOpenPR = useCallback(() => { diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 1045da4fd0d..68dfe9c6b2c 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -367,6 +367,41 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS agentPromptRef.current = agentPrompt const selectedRepo = eligibleRepos.find((repo) => repo.id === repoId) + + // Why: resolves the selected repo's owner/repo slug so a PR URL pasted + // into the workspace name field can be matched against the current repo. + // Pasting a PR URL from a different repo would otherwise recover only the + // PR number, mislinking the worktree to an unrelated PR with the same + // number in the selected repo. + const [selectedRepoSlug, setSelectedRepoSlug] = useState<{ owner: string; repo: string } | null>( + null + ) + const selectedRepoPath = selectedRepo?.path + useEffect(() => { + if (!selectedRepoPath) { + setSelectedRepoSlug(null) + return + } + let cancelled = false + void (window.api.gh.repoSlug({ repoPath: selectedRepoPath }) as Promise<{ + owner: string + repo: string + } | null>) + .then((result) => { + if (cancelled) { + return + } + setSelectedRepoSlug(result) + }) + .catch(() => { + if (!cancelled) { + setSelectedRepoSlug(null) + } + }) + return () => { + cancelled = true + } + }, [selectedRepoPath]) const sparsePresetsForRepo = sparsePresetsByRepo[repoId] const sparsePresets = sparsePresetsForRepo ?? EMPTY_SPARSE_PRESETS const normalizedSparseDirectories = useMemo( @@ -421,10 +456,21 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS } const fromName = parseGitHubIssueOrPRLink(name) if (fromName && fromName.type === 'pr') { - return fromName.number + // Why: only adopt a number when the URL's owner/repo matches the + // selected repo. Pasting `github.com/other/repo/pull/1234` must not + // mislink the worktree to an unrelated PR #1234 in the current repo. + // If the slug hasn't resolved yet, suppress recovery rather than + // risking a cross-repo mislink. + if ( + selectedRepoSlug && + fromName.slug.owner.toLowerCase() === selectedRepoSlug.owner.toLowerCase() && + fromName.slug.repo.toLowerCase() === selectedRepoSlug.repo.toLowerCase() + ) { + return fromName.number + } } return null - }, [linkedPR, name]) + }, [linkedPR, name, selectedRepoSlug]) const setupConfig = useMemo( () => getSetupConfig(selectedRepo, yamlHooks), [selectedRepo, yamlHooks] From 7268e3f55c6b033b682bd172f1a4db0b4756e5bd Mon Sep 17 00:00:00 2001 From: "buf0-bot[bot]" <252831055+buf0-bot[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 15:21:40 -0700 Subject: [PATCH 005/213] fix: address pr-bug-scan findings from #1442 (#1468) Fixed two TaskPage resume-state regressions: the debounced GitHub search now persists even when activeTaskPreset is non-null (drops the guard, persists current preset+query), and re-added the mid-sess Co-authored-by: orca-bug-scan-bot --- src/renderer/src/components/TaskPage.tsx | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 67d653070b0..04bd6348374 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -785,6 +785,17 @@ export default function TaskPage(): React.JSX.Element { } }, [pageData.taskSource]) + // Why: settings.defaultTaskSource can change while Tasks is open (Settings + // panel is reachable without unmounting Tasks). The one-shot resume effect + // only seeds taskSource on first mount, so without this sync a mid-session + // change to the default would not propagate when no explicit page-level + // taskSource was passed. + useEffect(() => { + if (!pageData.taskSource && settings?.defaultTaskSource) { + setTaskSource(settings.defaultTaskSource) + } + }, [settings?.defaultTaskSource, pageData.taskSource]) + // Why: Project mode is a sub-tab within the GitHub source. Visible whenever // the user is on the GitHub task source — actual entry into Project mode is // gated on a non-null `activeProject` once they pick one. @@ -1246,11 +1257,14 @@ export default function TaskPage(): React.JSX.Element { githubSearchPersistReadyRef.current = true return } - if (activeTaskPreset !== null) { - return - } + // Why: persist the debounced applied query regardless of the active + // preset. The preset-click handler writes the canonical query for that + // preset, so persisting again here is at worst idempotent. When the + // user types into the search box `handleTaskSearchChange` clears the + // preset, but persisting unconditionally also covers paths that change + // appliedTaskSearch without going through that handler. setTaskResumeState({ - githubItemsPreset: null, + githubItemsPreset: activeTaskPreset, githubItemsQuery: appliedTaskSearch.trim() }) }, [activeTaskPreset, appliedTaskSearch, setTaskResumeState, taskResumeApplied]) From 5de1a2c69cd191b54477b55be9847b24baa2774c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 22:24:52 +0000 Subject: [PATCH 006/213] release: v1.3.35-rc.5 [rc-slot:2026-05-05-15] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 834e1841b12..c41492e688e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.3.35-rc.4", + "version": "1.3.35-rc.5", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From a32b120c11de7bc24af221ba86a33bea77fcf3d7 Mon Sep 17 00:00:00 2001 From: "buf0-bot[bot]" <252831055+buf0-bot[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 15:25:56 -0700 Subject: [PATCH 007/213] fix: address pr-bug-scan findings from #1385 (#1462) Wrapped FirstLaunchBanner handlers' bodies in try/finally so inFlight resets even if fetchSettings rejects, preventing the banner from going permanently inert. Co-authored-by: orca-bug-scan-bot --- .../src/components/FirstLaunchBanner.tsx | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/components/FirstLaunchBanner.tsx b/src/renderer/src/components/FirstLaunchBanner.tsx index 44fa4123d61..8a03379786e 100644 --- a/src/renderer/src/components/FirstLaunchBanner.tsx +++ b/src/renderer/src/components/FirstLaunchBanner.tsx @@ -71,9 +71,17 @@ export function FirstLaunchBanner({ // banner helper text until the next full relaunch. Mirror // PrivacyPane's handleToggle pattern which refetches for the same // reason before surfacing UI changes. - await acknowledgeBanner() - await fetchSettings() - onResolve() + try { + await acknowledgeBanner() + await fetchSettings() + onResolve() + } finally { + // Why: if `fetchSettings` rejects (IPC error during shutdown, + // settings file lock, etc.), `onResolve` never runs and the banner + // stays mounted. Without resetting `inFlight`, every button stays + // permanently disabled for the rest of the session. + setInFlight(false) + } } const handleTurnOff = async (): Promise => { @@ -86,9 +94,13 @@ export function FirstLaunchBanner({ // `setOptIn` (client.ts). The renderer just needs to route through // `telemetrySetOptIn(false)` so the IPC handler derives the correct // `via` and fires the event. - await telemetrySetOptIn(false) - await fetchSettings() - onResolve() + try { + await telemetrySetOptIn(false) + await fetchSettings() + onResolve() + } finally { + setInFlight(false) + } } return ( From dd1c940fefeeacf1d0503e495b947ef512639b22 Mon Sep 17 00:00:00 2001 From: "buf0-bot[bot]" <252831055+buf0-bot[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 15:46:21 -0700 Subject: [PATCH 008/213] fix: address pr-bug-scan findings from #1424 (#1465) Gate gh retries on idempotency (auto-detected from argv/-X/mutation), key parent-field probe by (owner,ownerType), accept 'to an' in not_found classifier, and replace bulk label PUT with parallel sing Co-authored-by: orca-bug-scan-bot --- src/main/git/runner.ts | 73 +++++++++++++++++++++- src/main/github/project-view.ts | Bin 54201 -> 54436 bytes src/main/github/project-view/internals.ts | 6 +- 3 files changed, 76 insertions(+), 3 deletions(-) diff --git a/src/main/git/runner.ts b/src/main/git/runner.ts index fa18e591838..c43058504b4 100644 --- a/src/main/git/runner.ts +++ b/src/main/git/runner.ts @@ -205,7 +205,69 @@ export function gitSpawn(args: string[], options: SpawnOptions & { cwd: string } // resolveCommand routes the spawn through `wsl.exe -d -- gh ...` // even without a UNC cwd to parse a distro from. Repo-scoped callers should // keep using cwd — the distro derives from the path automatically there. -type GhExecOptions = Omit & { cwd?: string; wslDistro?: string } +// Why: `idempotent` gates the transient-error retry. When undefined we +// auto-detect from argv (writes are detected by `-X POST/PATCH/PUT/DELETE` +// or a `query=mutation …` arg); callers can also pass an explicit override. +// A 5xx/socket reset after the request reaches GitHub but before the +// response returns is the canonical case where the server-side write +// succeeded; retrying would create a duplicate comment/issue/label addition. +// See bug-scan finding 1. +type GhExecOptions = Omit & { + cwd?: string + wslDistro?: string + idempotent?: boolean +} + +const NON_IDEMPOTENT_METHODS = new Set(['POST', 'PATCH', 'PUT', 'DELETE']) +// `gh ` write subcommands. Reads (view/list/status/checks) +// are absent on purpose so the default of "retry" stays for them. +const NON_IDEMPOTENT_GH_VERBS = new Set([ + 'create', + 'edit', + 'delete', + 'close', + 'reopen', + 'merge', + 'comment', + 'review', + 'ready', + 'lock', + 'unlock', + 'pin', + 'unpin', + 'transfer', + 'develop' +]) + +function argsLookIdempotent(args: string[]): boolean { + for (let i = 0; i < args.length; i++) { + const a = args[i] + if (a === '-X' || a === '--method') { + const next = args[i + 1] + if (typeof next === 'string' && NON_IDEMPOTENT_METHODS.has(next.toUpperCase())) { + return false + } + } + // `gh api graphql -f query=mutation(...){ ... }` — detect mutation queries + // so writes via the GraphQL endpoint also fail fast on transient errors. + if (a.startsWith('query=')) { + const trimmed = a.slice('query='.length).trimStart().toLowerCase() + if (trimmed.startsWith('mutation')) { + return false + } + } + } + // `gh issue close`, `gh pr edit`, `gh pr merge`, etc. The first arg is the + // noun (issue/pr/repo/label/...) and the second is the verb. Defaulting + // `gh api` calls without an explicit -X to GET-equivalent (idempotent) is + // intentional: callers that POST through `gh api` set `-X POST`. + if (args.length >= 2 && args[0] !== 'api') { + if (NON_IDEMPOTENT_GH_VERBS.has(args[1])) { + return false + } + } + return true +} /** * Extract stderr from an execFile rejection. @@ -344,7 +406,14 @@ export async function ghExecFileAsync( lastError = err const { stderr } = extractExecError(err) const isLastAttempt = attempt >= GH_RETRY_DELAYS_MS.length - if (!isLastAttempt && isTransientGhError(stderr)) { + // Why: only retry idempotent calls. A 5xx/socket reset can arrive + // after the server already applied a POST/PATCH/PUT/DELETE; retrying + // would duplicate the write (e.g. double-post a comment, double-add + // a label). When the caller doesn't say, we auto-detect from argv — + // explicit `-X ` and GraphQL `query=mutation …` are treated + // as non-idempotent. See bug-scan finding 1. + const idempotent = options.idempotent ?? argsLookIdempotent(args) + if (idempotent && !isLastAttempt && isTransientGhError(stderr)) { // Why: when the upstream surfaced a Retry-After (e.g. on a transient // 5xx that GitHub explicitly recommends backing off for), honor it // instead of using our default backoff — sleeping less than the diff --git a/src/main/github/project-view.ts b/src/main/github/project-view.ts index 32eabb523e1764cdda3b90d88b070f74d210a03d..6f6f2b73a363a3d64bfd8c5ca156ccbf26687cc1 100644 GIT binary patch delta 3023 zcmaJ@eQaCR6`!4cWNGsSHEX__+q8*oVmq)EDw6agO&nM|6G9$35qF&iS2p_T{RDA632jdhKja4;jzod&v~bvlN-kljGZGjA@wr2V`S1%qSkB@ zvq`IZ3PUDejnyO#-Li?NHmou&HOg$Rvedr(5djL>MRYce=61h@_qwt>6u!}BoN^sy z2MtB}|CT*H@L6r=pvVESB^J#VQ6p5-h=Hl{91|jegOqmYh8@i_JIcMdI*4Us<`KBN z?$#>Qf)jP*R)RUHdNi%6N(PhRMpm^LF)@6J57r5h*78FAwYtbH<+nMr9L@GnfW6t~ z1I2s~QL~tDgp7FZ`g4Yw+AY$rNjrMdRz>XuL}`R6HMM}* zw$^oTI=y}a;^uIj=#Hkj4>foADlVRCdC>>GZJqPY4QP>ADT6<@J-cy*aRDwovKbb( zT)W}Y{Z0F`zWSA_m<4=vZj(E=^?PgJ_QuHihvoZy@IY6qd$Q}Cudb*a+@DAsjR)c0 z&{jAR3c#rl*;En@jSdWtJycY<6pA#K1f%hxfx)r(P|&RjpKfc)QEtU(+Lp~qxl;U5 zsDT$Qw8LMA)taIq7TbOB?s-3aynn(yc;MYND+I9n$mR7Iw)lgMBjL63?A|f5xvDNL z#=?r3j+F`NVNeo32q@fe%zb$|Fl9;m*pI)A^JJ^K!wPUEu?7A!7J$u1cft#AZF8rO ze&R0y98^<5cx2+95^6!+$xbpJg!d+P)RywpaXNx-*Zp@@LyCso?^Cq~r#G2lR++rK zri*x;D0nL@n*9bw(r9vu7->uj=V~Q0tmLW+o?4xzW|w(NdMYxNBqoe%HELQxJ=ZZ0 zAs+WgCdB!{(W{&%Sxb2pA;ohw&YR2<{xQ|oP@<8`*BwG0jhFWDicqZFgw^h}Xnv-H9 zlEvY-*}k14@lkSQ{4oB<Xda8HX^eY?sN`2@(YK18Mh5KHw{;L|S*oJT z{B9slPwg>%`!*s{2@`BNY#*w7cv#|2w3(;3q;H2$^^T(RX5)jpSY;xg89O$3I7yIU z*gOP-+08c#KP8wVUIE-EOz<_vQcPnuTNZ}hF8(Xu>>Bynh`2U`HE8k8htTk3wPIxyyGPdYxcP z$!o=X(!2naUxSHUCp6Bqz;n4;_b0iiAI{IUffpA??);cYAYbhh5TIQ!t55~axnv*WS1g|r3! zefcKXutd?U8=hbKI($Ieq5ADUxA5J7Unp!eslsB6b4;)B;-yo*rw|&NTT7;5*#mzJKOsKYY5- z4WXA7%Fh4x(mTGQqbo0ybSfZnam2@tll3xPyzn^Ox7Y;_FWv_Cz10gJE(YB7zwY$=LJ+yw)K2!0 zZw4LwJPRHpy?8{NsIH!*;xj0CEbLkrpI$2~5q`75?SB(|!kPQ|@BUPEeLJ|%FI|;A mQoFJR3h?JsVYqx{PZ{aXt6zAC0v3OrYzPeXj%HZ+ec-<-97*K> delta 2571 zcmZ`*YfKbZ6wWLlc6qKY0$b3FNS8%ewV|m+Q4~-t)gmB9jp(p@7bfn`Ec0Ncx)JRo z#ZaprQsa+mOlyfGZ3ts)sF6l|q_I||HH}S7(_d}0)sn^^tv}kHxidVr$o|=x^Szn-eHK>ehg1o)sr?S>k5MY3j(OTe2}SjY#1 zL~2o}+(Dn=T+UpJ?Ms%x$+RN4o<;~h6J8{OSP}>dt!caF_Y^n}8T+&pG&g?4XfXxLKiMiJcdt%#{Lneix!LhUyM*3}4e zrh4Hl4fCOz<2FbdPJ^wruR(M;Q9oDvL%dn+L>Em{EVjGu7Bp}6=slaW;#@2kAprxS ziZ`q&ZH1zT31P7DM4qK?l_f2_wv*xbe^$*~`kj`QPZCu@)Trs6-c*`sDWIz9W#bG4 zZr@y_pK5wyo69ugjTHQ>iyv;Q$&8VUTBwD}O!s74X-2FLN5)$9()L$oTdfjXJVx{N z6D@+hQV8H)NQiow_;9Q6Pf&j@v>1yz7YVsSj4r4SCPSt%Z}550(~!|ioJhg1$;c$MkSq85tLaS&H0^ANSGCGT#!HB@uMtytOAq@&K?Lyc@ z4^j-wYhPqEAA42Sz7Y8KpW$`6)F>5>$Q$((rH7l~hkkXKYidB&3rT(rn-d7~tp-aO zQ4yRfkpk>&7FXebd{j}0NIP(L*fL{cR3RFIEJ$iL))UDPW20pi#yYZ$JgTfw$c=1= zo(SRG@M)w3evd52r-HrialOv(&ETMP14$1EvZ8vaEMuT3rq)k&me}CV$p!G!z6vBI z$L8#1wGq9ljBepv{pmK(epjT-4}bQwhVW$RC)q76ps z7Q&ldS>QgHrl0LP!ZE?7L+K`v_2xs(@$lH0tB<~Ooz)8D&K$-oSKsB0U{KMW&Y)h` zea(!vv?tkg{%Bj1mPa`4DH9}KSNr4x6+W!x&TlWoR~Mvf?Df@On1;_xG0tGwF2C#TRVmT@m95 z6<3^a$(0Dj`WV536@X4iYj%*PGnL&4?446QdV zCqd6ANv!KvYBJ%k%PCAwMoLW}>Ww1>w)H+9`2)Xt%FBrxo6n2F8@wtYTTTpcHQC|T z@QcU2b3#;xq8Q_y`ry?M5*WO3^92C diff --git a/src/main/github/project-view/internals.ts b/src/main/github/project-view/internals.ts index fd69717a68c..52509c30652 100644 --- a/src/main/github/project-view/internals.ts +++ b/src/main/github/project-view/internals.ts @@ -194,7 +194,11 @@ export function classifyProjectError(stderr: string, stdout: string): GitHubProj if ( s.includes('http 404') || errors.some((e) => (e.type ?? '').toUpperCase() === 'NOT_FOUND') || - s.includes('could not resolve to a ') + // Why: GitHub uses "to an" for vowel-leading types ("to an Issue", "to + // an Organization") and "to a" otherwise. The previous singular-only + // check missed the "an" variants when gh emits only the stderr summary + // without a structured GraphQL error array. See bug-scan finding 3. + /could not resolve to an? /.test(s) ) { const firstNotFound = errors.find((e) => (e.type ?? '').toUpperCase() === 'NOT_FOUND') return { From ddb732cf344d86ec745fb72bb837e0f36d27e3a5 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 5 May 2026 15:56:54 -0700 Subject: [PATCH 009/213] Support spritesheet pet imports for Sidekick (#1472) * WIP: Changes before auto-review fixes Co-authored-by: Orca * auto-review-fix iteration 1 Co-authored-by: Orca * fix: plumb manifest.fps for detected-frame bundles Co-authored-by: Orca * fix: address lint errors Co-authored-by: Orca --------- Co-authored-by: Orca --- src/main/ipc/sidekick.ts | 377 ++++++++++++++++-- src/preload/api-types.ts | 9 +- src/preload/index.ts | 10 +- .../components/sidekick/SidekickOverlay.tsx | 208 ++++++++-- .../sidekick/sidekick-blob-cache.ts | 133 +++++- .../sidekick/sprite-frame-detection.ts | 104 +++++ .../src/components/sidekick/useSidekickUrl.ts | 61 ++- .../status-bar/SidekickStatusSegment.tsx | 59 ++- src/renderer/src/store/slices/ui.ts | 55 ++- src/shared/types.ts | 37 ++ 10 files changed, 965 insertions(+), 88 deletions(-) create mode 100644 src/renderer/src/components/sidekick/sprite-frame-detection.ts diff --git a/src/main/ipc/sidekick.ts b/src/main/ipc/sidekick.ts index ee8e97b6852..1812eb9a9ef 100644 --- a/src/main/ipc/sidekick.ts +++ b/src/main/ipc/sidekick.ts @@ -1,7 +1,9 @@ -import { app, BrowserWindow, dialog, ipcMain } from 'electron' -import { copyFile, mkdir, readFile, rm, stat } from 'node:fs/promises' +/* eslint-disable max-lines */ +import { app, BrowserWindow, dialog, ipcMain, nativeImage } from 'electron' +import { copyFile, mkdir, readFile, rename, rm, stat, lstat } from 'node:fs/promises' import { randomUUID } from 'node:crypto' -import { basename, extname, join, normalize, sep } from 'node:path' +import { basename, dirname, extname, isAbsolute, join, normalize, resolve, sep } from 'node:path' +import { z } from 'zod' import type { CustomSidekick } from '../../shared/types' // Why: image-only sidekick uploads. Static + animated variants render natively @@ -35,36 +37,121 @@ function getSidekicksDir(): string { } const MAX_BYTES = 64 * 1024 * 1024 // 64 MB — generous but bounded so a user can't point at a multi-GB file and OOM the renderer when it builds a Blob URL. +const MAX_MANIFEST_BYTES = 64 * 1024 // pet.json is tiny by spec; cap to defend against a malicious bundle stuffing megabytes into the manifest. function isSafeId(id: string): boolean { - // UUIDs only; blocks path traversal and unexpected characters. + // UUIDs only; blocks path traversal and unexpected characters. Storage ids + // are always generated in main (never sourced from the bundle's manifest.id), + // so this regex is the canonical gate for any fs path that includes the id. return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id) } -function resolveSidekickFile(id: string, fileName: string): string | null { +function resolveSidekickFile(id: string, fileName: string, kind: 'image' | 'bundle'): string | null { if (!isSafeId(id)) { return null } - // Why: the renderer hands back the persisted fileName (which includes the - // original extension so Blob MIME detection works). We still normalize and - // prefix-check against the sidekicks dir to defend against any edge case that - // slipped the id regex. const safeName = basename(fileName) + const root = normalize(getSidekicksDir()) + let filePath: string + if (kind === 'bundle') { + // Bundle layout: custom//. fileName is just the spritesheet + // basename (e.g. "spritesheet.webp"); pet.json is read by main only and + // never served to the renderer. + filePath = normalize(join(root, id, safeName)) + const bundleDir = normalize(join(root, id)) + sep + if (!filePath.startsWith(bundleDir)) { + return null + } + return filePath + } + // Legacy image layout: custom/.. Filename must start with the id + // so the prefix check catches any edge case that slipped the regex. if (!safeName.startsWith(`${id}.`)) { return null } - const filePath = normalize(join(getSidekicksDir(), safeName)) - if (!filePath.startsWith(normalize(getSidekicksDir()) + sep)) { + filePath = normalize(join(root, safeName)) + if (!filePath.startsWith(root + sep)) { return null } return filePath } +const PetManifestSchema = z + .object({ + id: z.string().min(1).max(128).optional(), + displayName: z.string().min(1).max(120).optional(), + description: z.string().max(2000).optional(), + spritesheetPath: z + .string() + .min(1) + .max(255) + // Why: belt-and-suspenders against malicious manifests — downstream + // resolve+prefix check still runs as defense in depth. + .refine( + (p) => !p.includes('\0') && !p.startsWith('/') && !p.startsWith('\\') && !p.includes('..'), + 'invalid spritesheetPath' + ), + frame: z + .object({ + width: z.number().int().positive().max(1024), + height: z.number().int().positive().max(1024) + }) + .optional(), + fps: z.number().positive().max(60).optional(), + defaultAnimation: z.string().min(1).max(64).optional(), + animations: z + .record( + z.string().min(1).max(64), + z.object({ + row: z.number().int().min(0).max(256), + frames: z.number().int().positive().max(512) + }) + ) + .optional() + }) + // Why: tolerate unknown top-level fields. Pet-bundle generators may emit + // descriptive metadata (e.g. "kind": "person") we don't consume yet, and + // strict mode would reject those imports with a confusing "unrecognized + // key" error instead of just ignoring the extras. + .loose() + +type PetManifest = z.infer + +// Why: renderer-supplied IPC inputs are untrusted — validate shape before any +// path resolution. resolveSidekickFile still gates the actual filesystem path. +const SidekickFileRequestSchema = z.object({ + id: z.string(), + fileName: z.string(), + kind: z.enum(['image', 'bundle']).optional() +}) + +async function readSheetDimensions( + buffer: Buffer +): Promise<{ width: number; height: number } | null> { + // Why: nativeImage decodes PNG/JPEG/GIF/WebP/BMP. SVG isn't supported here + // (vector → no integer pixel grid), so pet bundles must use a raster sheet. + const image = nativeImage.createFromBuffer(buffer) + if (image.isEmpty()) { + return null + } + const size = image.getSize() + if (size.width <= 0 || size.height <= 0) { + return null + } + return { width: size.width, height: size.height } +} + +async function isSymlink(path: string): Promise { + try { + const s = await lstat(path) + return s.isSymbolicLink() + } catch { + return false + } +} + export function registerSidekickHandlers(): void { ipcMain.handle('sidekick:import', async (event): Promise => { - // Why: parent the file picker to the sender window so the dialog opens as - // a sheet attached to the main window. Without a parent, on macOS the - // dialog can land behind the main window. const senderWindow = BrowserWindow.fromWebContents(event.sender) ?? BrowserWindow.getFocusedWindow() const options: Electron.OpenDialogOptions = { @@ -129,14 +216,216 @@ export function registerSidekickHandlers(): void { id, label, fileName, - mimeType: classified.mimeType + mimeType: classified.mimeType, + kind: 'image' + } + }) + + ipcMain.handle('sidekick:importPetBundle', async (event): Promise => { + const senderWindow = + BrowserWindow.fromWebContents(event.sender) ?? BrowserWindow.getFocusedWindow() + // Why: the bundle is a folder. macOS users may also pick `pet.json` itself + // when Finder is set to show package contents — the post-pick logic walks + // up to the parent directory in that case. + const options: Electron.OpenDialogOptions = { + title: 'Pick a .codex-pet bundle', + properties: ['openFile', 'openDirectory', 'treatPackageAsDirectory'] + } + const result = senderWindow + ? await dialog.showOpenDialog(senderWindow, options) + : await dialog.showOpenDialog(options) + if (result.canceled || result.filePaths.length === 0) { + return null + } + const picked = result.filePaths[0] + let bundleDir: string + try { + const pickedStat = await stat(picked) + bundleDir = pickedStat.isDirectory() ? picked : dirname(picked) + } catch { + throw new Error('Could not read the selected path.') + } + + const manifestPath = join(bundleDir, 'pet.json') + let manifestStat: Awaited> + try { + manifestStat = await stat(manifestPath) + } catch { + throw new Error('Bundle is missing pet.json.') + } + if (!manifestStat.isFile() || manifestStat.size > MAX_MANIFEST_BYTES) { + throw new Error('pet.json is invalid.') + } + if (await isSymlink(manifestPath)) { + throw new Error('pet.json must not be a symlink.') + } + + let manifest: PetManifest + try { + const raw = await readFile(manifestPath, 'utf8') + // Why: defend against TOCTOU between stat and read — the file could have + // grown after the stat check. + if (Buffer.byteLength(raw, 'utf8') > MAX_MANIFEST_BYTES) { + throw new Error('pet.json exceeded the manifest size limit.') + } + manifest = PetManifestSchema.parse(JSON.parse(raw)) + } catch (error) { + throw new Error(`Invalid pet.json: ${error instanceof Error ? error.message : 'parse error'}`) + } + + // Why: spritesheetPath is bundle-relative. Reject absolute paths and any + // resolved path that escapes the bundle directory. Also reject symlinks so + // a malicious bundle can't reach outside via a sibling link. + if (isAbsolute(manifest.spritesheetPath)) { + throw new Error('spritesheetPath must be relative to the bundle.') + } + const sheetSrc = resolve(bundleDir, manifest.spritesheetPath) + const bundleResolved = resolve(bundleDir) + if (sheetSrc === bundleResolved) { + throw new Error('spritesheetPath must point to a file, not the bundle root.') + } + const bundleRoot = bundleResolved + sep + // Why: NTFS/macOS HFS+ default volumes are case-insensitive — a path like + // `BUNDLE\sheet.png` is still inside `bundle\`. Compare lowercased on + // Windows so the prefix check isn't bypassed by case differences. + const cmp = + process.platform === 'win32' ? (s: string) => s.toLowerCase() : (s: string) => s + if (!cmp(sheetSrc + sep).startsWith(cmp(bundleRoot))) { + throw new Error('spritesheetPath escapes the bundle.') + } + if (await isSymlink(sheetSrc)) { + throw new Error('spritesheet must not be a symlink.') + } + const sheetClass = classifyFile(sheetSrc) + if (!sheetClass || sheetClass.ext === '.svg') { + // SVG can't be used as a sprite sheet (no pixel grid). + throw new Error('Spritesheet must be a PNG, APNG, JPG, GIF, or WebP.') + } + let sheetStat: Awaited> + try { + sheetStat = await stat(sheetSrc) + } catch { + throw new Error('Spritesheet file not found.') + } + if (!sheetStat.isFile()) { + throw new Error('Spritesheet path is not a file.') + } + if (sheetStat.size > MAX_BYTES) { + throw new Error( + `Spritesheet is too large (${(sheetStat.size / (1024 * 1024)).toFixed(1)} MB).` + ) + } + + let sprite: NonNullable | undefined + if (manifest.frame) { + // Why: only decode the sheet when we need to validate frame layout. + // nativeImage may fail on some WebP variants in headless contexts, and + // bundles without `frame` render as a static image where dimensions + // don't matter. + const sheetBuf = await readFile(sheetSrc) + // Why: defend against TOCTOU — file may have grown between stat and read. + if (sheetBuf.byteLength > MAX_BYTES) { + throw new Error('Spritesheet exceeded the size limit.') + } + const dims = await readSheetDimensions(sheetBuf) + if (!dims) { + throw new Error('Could not decode the spritesheet image.') + } + const { width: fw, height: fh } = manifest.frame + if (dims.width % fw !== 0 || dims.height % fh !== 0) { + throw new Error( + `Spritesheet ${dims.width}×${dims.height} is not a clean multiple of frame ${fw}×${fh}.` + ) + } + const columns = dims.width / fw + const rows = dims.height / fh + if (manifest.animations) { + for (const [name, anim] of Object.entries(manifest.animations)) { + if (anim.row >= rows) { + throw new Error(`Animation "${name}" references row ${anim.row} but sheet has ${rows}.`) + } + if (anim.frames > columns) { + throw new Error( + `Animation "${name}" has ${anim.frames} frames but sheet only has ${columns} columns.` + ) + } + } + if (manifest.defaultAnimation && !manifest.animations[manifest.defaultAnimation]) { + throw new Error(`defaultAnimation "${manifest.defaultAnimation}" not in animations.`) + } + } + sprite = { + frameWidth: fw, + frameHeight: fh, + columns, + rows, + sheetWidth: dims.width, + sheetHeight: dims.height, + fps: manifest.fps ?? 8, + defaultAnimation: manifest.defaultAnimation, + animations: manifest.animations + } + } + + // Why: storage id is always a fresh UUID — the manifest's `id` is purely + // a display hint. This guards against (a) collisions with bundled + // sidekick ids, (b) failing isSafeId, and (c) re-import clobbering an + // earlier copy of the same bundle. + const id = randomUUID() + const root = getSidekicksDir() + await mkdir(root, { recursive: true }) + const destDir = join(root, id) + const sheetExt = sheetClass.ext + const sheetFileName = `spritesheet${sheetExt}` + // Why: stage the bundle into a sibling .tmp directory and atomically rename + // into place so destDir only appears once both files are written. Avoids + // half-imported bundles if a copy fails midway. + const tmpDir = `${destDir}.tmp` + try { + await rm(tmpDir, { recursive: true, force: true }).catch(() => {}) + await mkdir(tmpDir, { recursive: true }) + await copyFile(sheetSrc, join(tmpDir, sheetFileName)) + await copyFile(manifestPath, join(tmpDir, 'pet.json')) + await rename(tmpDir, destDir) + } catch { + await rm(tmpDir, { recursive: true, force: true }).catch(() => {}) + throw new Error('Could not save the pet bundle.') + } + + const rawLabel = (manifest.displayName ?? manifest.id ?? basename(bundleDir)).trim() + const label = rawLabel.length > 0 ? rawLabel.slice(0, 40) : 'Pet bundle' + return { + id, + label, + fileName: sheetFileName, + mimeType: sheetClass.mimeType, + kind: 'bundle', + sprite, + // Why: carries manifest.fps for detected-frame bundles where `sprite` + // is undefined — renderer falls back to this when sprite is absent. + ...(manifest.fps !== undefined ? { spriteFps: manifest.fps } : {}) } }) ipcMain.handle( 'sidekick:read', - async (_event, id: string, fileName: string): Promise => { - const filePath = resolveSidekickFile(id, fileName) + async ( + _event, + id: string, + fileName: string, + kind?: 'image' | 'bundle' + ): Promise => { + // Why: validate IPC inputs before any path logic — renderer is not + // trusted to send strings of the right shape. + let parsed: z.infer + try { + parsed = SidekickFileRequestSchema.parse({ id, fileName, kind }) + } catch { + throw new Error('Invalid sidekick:read arguments') + } + // Why: missing kind defaults to 'image' for backwards compatibility with + // pre-bundle persisted state. + const filePath = resolveSidekickFile(parsed.id, parsed.fileName, parsed.kind ?? 'image') if (!filePath) { return null } @@ -150,15 +439,49 @@ export function registerSidekickHandlers(): void { } ) - ipcMain.handle('sidekick:delete', async (_event, id: string, fileName: string): Promise => { - const filePath = resolveSidekickFile(id, fileName) - if (!filePath) { - return + ipcMain.handle( + 'sidekick:delete', + async ( + _event, + id: string, + fileName: string, + kind?: 'image' | 'bundle' + ): Promise => { + // Why: validate IPC inputs before any path logic. + let parsed: z.infer + try { + parsed = SidekickFileRequestSchema.parse({ id, fileName, kind }) + } catch { + throw new Error('Invalid sidekick:delete arguments') + } + if (!isSafeId(parsed.id)) { + return + } + if ((parsed.kind ?? 'image') === 'bundle') { + // Why: bundle imports own a whole directory. isSafeId already gates id; + // we still build the path under the sidekicks root and verify the + // prefix before recursive removal as defense in depth. + const root = normalize(getSidekicksDir()) + const target = normalize(join(root, parsed.id)) + if (!target.startsWith(root + sep)) { + return + } + try { + await rm(target, { recursive: true, force: true }) + } catch (error) { + console.warn('[sidekick-overlay] sidekick:delete (bundle) failed', error) + } + return + } + const filePath = resolveSidekickFile(parsed.id, parsed.fileName, 'image') + if (!filePath) { + return + } + try { + await rm(filePath, { force: true }) + } catch (error) { + console.warn('[sidekick-overlay] sidekick:delete failed', error) + } } - try { - await rm(filePath, { force: true }) - } catch (error) { - console.warn('[sidekick-overlay] sidekick:delete failed', error) - } - }) + ) } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index c099c4afc1a..d34889c47c0 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -758,8 +758,13 @@ export type PreloadApi = { } sidekick: { import: () => Promise - read: (id: string, fileName: string) => Promise - delete: (id: string, fileName: string) => Promise + importPetBundle: () => Promise + read: ( + id: string, + fileName: string, + kind?: 'image' | 'bundle' + ) => Promise + delete: (id: string, fileName: string, kind?: 'image' | 'bundle') => Promise } browser: BrowserApi hooks: { diff --git a/src/preload/index.ts b/src/preload/index.ts index 8075d3daf16..e95b70a6872 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -986,10 +986,12 @@ const api = { sidekick: { import: (): Promise => ipcRenderer.invoke('sidekick:import'), - read: (id: string, fileName: string): Promise => - ipcRenderer.invoke('sidekick:read', id, fileName), - delete: (id: string, fileName: string): Promise => - ipcRenderer.invoke('sidekick:delete', id, fileName) + importPetBundle: (): Promise => + ipcRenderer.invoke('sidekick:importPetBundle'), + read: (id: string, fileName: string, kind?: 'image' | 'bundle'): Promise => + ipcRenderer.invoke('sidekick:read', id, fileName, kind), + delete: (id: string, fileName: string, kind?: 'image' | 'bundle'): Promise => + ipcRenderer.invoke('sidekick:delete', id, fileName, kind) }, browser: { diff --git a/src/renderer/src/components/sidekick/SidekickOverlay.tsx b/src/renderer/src/components/sidekick/SidekickOverlay.tsx index 22c2f74ad1a..0dd7c31a3b4 100644 --- a/src/renderer/src/components/sidekick/SidekickOverlay.tsx +++ b/src/renderer/src/components/sidekick/SidekickOverlay.tsx @@ -1,5 +1,137 @@ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useId, useRef, useState } from 'react' import { useSidekickUrl } from './useSidekickUrl' +import type { DetectedSpriteCacheEntry } from './sidekick-blob-cache' +import type { CustomSidekick } from '../../../../shared/types' +import { useAppStore } from '../../store' + +type Sprite = NonNullable + +// Why: pet bundles ship a sprite sheet — animate by stepping a CSS background +// across the cells of one row. We pick which row + how many frames from the +// manifest's defaultAnimation, falling back to the first row if the manifest +// only declared frame size. imageRendering: 'pixelated' keeps edges crisp even +// when scale is fractional (needed when frames exceed maxSize). +function SpriteFrame({ + url, + sprite, + animate, + maxSize +}: { + url: string + sprite: Sprite + animate: boolean + maxSize: number +}): React.JSX.Element { + const animKeyframesId = useId().replace(/[^a-zA-Z0-9_-]/g, '') + const anim = + (sprite.defaultAnimation && sprite.animations?.[sprite.defaultAnimation]) || + (sprite.animations ? Object.values(sprite.animations)[0] : undefined) + const row = anim?.row ?? 0 + // Why: clamp to >=1 so an empty/invalid manifest can't produce steps(0), + // which is rejected as invalid CSS and freezes the animation. + const frames = Math.max(1, anim?.frames ?? sprite.columns ?? 1) + // Why: allow fractional downscaling so frames larger than maxSize shrink to + // fit instead of overflowing the overlay; mirrors DetectedSpriteFrame's math. + const scale = Math.min(maxSize / sprite.frameWidth, maxSize / sprite.frameHeight) + const renderedW = sprite.frameWidth * scale + const renderedH = sprite.frameHeight * scale + const bgW = sprite.sheetWidth * scale + const bgH = sprite.sheetHeight * scale + const startX = 0 + const startY = -(row * sprite.frameHeight * scale) + const endX = -(frames * sprite.frameWidth * scale) + const duration = Math.max(0.1, frames / Math.max(0.1, sprite.fps)) + return ( + <> + +
+ + ) +} + +// Why: when the manifest doesn't declare frame size, we auto-detect frames +// from the keyed sheet. Render via canvas because the frames may be different +// sizes; we scale each one to fit the overlay box and step through them at a +// fixed fps. requestAnimationFrame is paused when `animate` is false so the +// overlay respects reduced motion / hidden window. +function DetectedSpriteFrame({ + detected, + animate, + maxSize +}: { + detected: DetectedSpriteCacheEntry + animate: boolean + maxSize: number +}): React.JSX.Element { + const canvasRef = useRef(null) + const frameIndexRef = useRef(0) + const lastTimeRef = useRef(0) + // Why: honor manifest fps captured at import time so bundles play at their + // intended speed; default to 8 only when the manifest didn't declare one. + const fps = detected.fps > 0 ? detected.fps : 8 + + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) {return} + const ctx = canvas.getContext('2d') + if (!ctx) {return} + canvas.width = maxSize + canvas.height = maxSize + // Why: reset playback when the underlying sprite changes so the new + // animation starts from frame 0 rather than wherever the prior one stopped. + frameIndexRef.current = 0 + lastTimeRef.current = 0 + let raf = 0 + const draw = (): void => { + const f = detected.frames[frameIndexRef.current % detected.frames.length] + const bmp = detected.bitmaps[frameIndexRef.current % detected.bitmaps.length] + ctx.imageSmoothingEnabled = false + ctx.clearRect(0, 0, canvas.width, canvas.height) + const scale = Math.min(maxSize / f.w, maxSize / f.h) + const w = f.w * scale + const h = f.h * scale + ctx.drawImage(bmp, (maxSize - w) / 2, (maxSize - h) / 2, w, h) + } + const tick = (now: number): void => { + const dt = now - lastTimeRef.current + if (dt >= 1000 / fps) { + lastTimeRef.current = now + frameIndexRef.current = (frameIndexRef.current + 1) % detected.frames.length + draw() + } + if (animate) { + raf = requestAnimationFrame(tick) + } + } + draw() + if (animate) { + lastTimeRef.current = performance.now() + raf = requestAnimationFrame(tick) + } + return () => { + if (raf) {cancelAnimationFrame(raf)} + } + }, [detected, animate, maxSize]) + + return ( + + ) +} function useDocumentVisible(): boolean { const [visible, setVisible] = useState(() => @@ -34,24 +166,26 @@ function usePrefersReducedMotion(): boolean { return reduced } +// Why: keep a default for the cached helpers below; the live size now comes +// from the store so the user can resize from the status-bar menu. const SIZE = 180 const POSITION_STORAGE_KEY = 'sidekick-overlay-position' type Position = { x: number; y: number } -function clampToViewport(pos: Position): Position { +function clampToViewport(pos: Position, size: number = SIZE): Position { if (typeof window === 'undefined') { return pos } - const maxX = Math.max(0, window.innerWidth - SIZE) - const maxY = Math.max(0, window.innerHeight - SIZE) + const maxX = Math.max(0, window.innerWidth - size) + const maxY = Math.max(0, window.innerHeight - size) return { x: Math.min(Math.max(0, pos.x), maxX), y: Math.min(Math.max(0, pos.y), maxY) } } -function loadStoredPosition(): Position | null { +function loadStoredPosition(size: number = SIZE): Position | null { if (typeof window === 'undefined') { return null } @@ -64,39 +198,56 @@ function loadStoredPosition(): Position | null { if (typeof parsed.x !== 'number' || typeof parsed.y !== 'number') { return null } - return clampToViewport({ x: parsed.x, y: parsed.y }) + // Why: clamp using the live overlay size so a persisted position from a + // larger overlay doesn't slip off the bottom/right edge after a shrink. + return clampToViewport({ x: parsed.x, y: parsed.y }, size) } catch { return null } } -function defaultPosition(): Position { +function defaultPosition(size: number = SIZE): Position { if (typeof window === 'undefined') { return { x: 0, y: 0 } } // Matches previous bottom-4 right-16 (right: 4rem, bottom: 1rem). - return clampToViewport({ - x: window.innerWidth - SIZE - 64, - y: window.innerHeight - SIZE - 16 - }) + return clampToViewport( + { + x: window.innerWidth - size - 64, + y: window.innerHeight - size - 16 + }, + size + ) } export function SidekickOverlay(): React.JSX.Element { const documentVisible = useDocumentVisible() const reducedMotion = usePrefersReducedMotion() - const { url } = useSidekickUrl() + const { url, sprite, detected } = useSidekickUrl() + const size = useAppStore((s) => s.sidekickSize) - const [position, setPosition] = useState( - () => loadStoredPosition() ?? defaultPosition() - ) + const [position, setPosition] = useState(() => { + // Why: read the persisted size eagerly via getState so the initial clamp + // uses the user's last sidekick size — useState's lazy initializer runs + // before the `size` prop binding settles, and `loadStoredPosition` would + // otherwise default to SIZE and clip a previously-saved position. + const currentSize = useAppStore.getState().sidekickSize ?? SIZE + return loadStoredPosition(currentSize) ?? defaultPosition(currentSize) + }) const [dragging, setDragging] = useState(false) const dragOffsetRef = useRef({ x: 0, y: 0 }) useEffect(() => { - const onResize = (): void => setPosition((prev) => clampToViewport(prev)) + const onResize = (): void => setPosition((prev) => clampToViewport(prev, size)) window.addEventListener('resize', onResize) return () => window.removeEventListener('resize', onResize) - }, []) + }, [size]) + + // Why: when the user shrinks/grows the overlay, re-clamp so the box never + // overflows the viewport edges (which would otherwise leave it un-draggable). + useEffect(() => { + setPosition((prev) => clampToViewport(prev, size)) + }, [size]) useEffect(() => { if (dragging) { @@ -132,10 +283,13 @@ export function SidekickOverlay(): React.JSX.Element { return } setPosition( - clampToViewport({ - x: event.clientX - dragOffsetRef.current.x, - y: event.clientY - dragOffsetRef.current.y - }) + clampToViewport( + { + x: event.clientX - dragOffsetRef.current.x, + y: event.clientY - dragOffsetRef.current.y + }, + size + ) ) } @@ -156,8 +310,8 @@ export function SidekickOverlay(): React.JSX.Element { style={{ left: position.x, top: position.y, - width: SIZE, - height: SIZE + width: size, + height: size }} >
- + {sprite ? ( + + ) : detected ? ( + + ) : ( + + )}
) diff --git a/src/renderer/src/components/sidekick/sidekick-blob-cache.ts b/src/renderer/src/components/sidekick/sidekick-blob-cache.ts index 3482aa07d8d..33dfe9f93ad 100644 --- a/src/renderer/src/components/sidekick/sidekick-blob-cache.ts +++ b/src/renderer/src/components/sidekick/sidekick-blob-cache.ts @@ -2,6 +2,8 @@ // without importing useSidekickUrl (which itself imports the store). Keeps // the dependency graph acyclic. +import { detectFramesFromImageData, type DetectedFrame } from './sprite-frame-detection' + // Why: sandbox=true + webSecurity=true block the renderer from reading user // files directly. For custom sidekick images we fetch the bytes over IPC and // turn them into a `blob:` URL that an tag can load. A small in-memory @@ -9,16 +11,33 @@ // doesn't re-fetch from main. export const blobUrlCache = new Map() +export type DetectedSpriteCacheEntry = { + frames: DetectedFrame[] + /** Per-frame image bitmaps drawn from the keyed canvas. The overlay paints + * these onto its own canvas one at a time, so we can crop irregular sheets + * without forcing the manifest to declare a uniform grid. */ + bitmaps: ImageBitmap[] + /** Manifest-declared playback speed; the overlay falls back to 8 fps when + * the bundle didn't declare one. */ + fps: number +} +export const detectedSpriteCache = new Map() + export async function loadCustomBlobUrl( id: string, fileName: string, - mimeType: string + mimeType: string, + kind?: 'image' | 'bundle', + spriteFps?: number ): Promise { const cached = blobUrlCache.get(id) if (cached) { return cached } - const buffer = await window.api.sidekick.read(id, fileName) + // Why: defensively clear any stale entry so we don't leak a prior blob URL + // or ImageBitmap[] when re-populating after a cache miss. + revokeCustomSidekickBlobUrl(id) + const buffer = await window.api.sidekick.read(id, fileName, kind) if (!buffer) { return null } @@ -26,11 +45,114 @@ export async function loadCustomBlobUrl( // SVG, which browsers refuse to render from a blob URL with the wrong // Content-Type. const blob = new Blob([buffer], { type: mimeType }) - const url = URL.createObjectURL(blob) + let url = URL.createObjectURL(blob) + // Why: pet bundles often ship spritesheets with a magenta chroma-key as + // the background instead of true alpha (common in pixel-art tooling). + // Strip it once at load and replace the cached URL with a transparent PNG + // so the overlay just sees a normal blob URL. + if (kind === 'bundle' && mimeType !== 'image/svg+xml') { + const processed = await processBundleSheet(url, spriteFps) + if (processed) { + URL.revokeObjectURL(url) + url = processed.url + if (processed.detected) { + detectedSpriteCache.set(id, processed.detected) + } + } + } blobUrlCache.set(id, url) return url } +async function processBundleSheet( + srcUrl: string, + spriteFps?: number +): Promise<{ url: string; detected: DetectedSpriteCacheEntry | null } | null> { + try { + const img = await loadImage(srcUrl) + const canvas = document.createElement('canvas') + canvas.width = img.naturalWidth + canvas.height = img.naturalHeight + const ctx = canvas.getContext('2d') + if (!ctx) { + return null + } + ctx.drawImage(img, 0, 0) + const data = ctx.getImageData(0, 0, canvas.width, canvas.height) + keyMagenta(data.data) + ctx.putImageData(data, 0, 0) + // Why: detect frames *after* keying so transparent gutters between + // sprites are visible to the band/column scanner. Without this the whole + // sheet collapses into one giant frame. + let detected: DetectedSpriteCacheEntry | null = null + const sprite = detectFramesFromImageData(data) + if (sprite && sprite.frames.length >= 1) { + // Why: allSettled so a single failed crop doesn't leak the bitmaps that + // did succeed — close fulfilled ones before bailing out. + const results = await Promise.allSettled( + sprite.frames.map((f) => createImageBitmap(canvas, f.x, f.y, f.w, f.h)) + ) + const rejected = results.some((r) => r.status === 'rejected') + if (rejected) { + for (const r of results) { + if (r.status === 'fulfilled') {r.value.close()} + } + return null + } + const bitmaps = results.map((r) => (r as PromiseFulfilledResult).value) + detected = { frames: sprite.frames, bitmaps, fps: spriteFps ?? 8 } + } + const out = await new Promise((res) => canvas.toBlob(res, 'image/png')) + if (!out) { + return null + } + return { url: URL.createObjectURL(out), detected } + } catch { + return null + } +} + +// Why: WebP/JPEG-compressed magenta keys leave wide gradient halos around +// each sprite, so a tight RGB-distance check leaves ugly fringing. We use a +// hue-style test instead: the magenta family has R and B much greater than +// G. Anything matching gets fully cleared; anything close gets proportional +// alpha so antialiased edges fade smoothly. +function magentaScore(r: number, g: number, b: number): number { + // 0 = not magenta, 1 = pure magenta key. Requires both red and blue to + // dominate green (the signature of any magenta shade), and rejects greys + // / dark pixels which can otherwise have low-but-equal channels. + if (r < 100 || b < 100) {return 0} + const minRB = Math.min(r, b) + if (g >= minRB) {return 0} + const dom = (minRB - g) / 255 // how much R and B dominate green + return Math.max(0, Math.min(1, dom * 1.4)) +} + +function keyMagenta(px: Uint8ClampedArray): void { + for (let i = 0; i < px.length; i += 4) { + const score = magentaScore(px[i], px[i + 1], px[i + 2]) + if (score <= 0) {continue} + if (score >= 0.5) { + px[i + 3] = 0 + px[i] = 0 + px[i + 1] = 0 + px[i + 2] = 0 + } else { + const keep = 1 - score * 2 + px[i + 3] = Math.round(px[i + 3] * Math.max(0, keep)) + } + } +} + +function loadImage(url: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image() + img.onload = () => resolve(img) + img.onerror = () => reject(new Error('image load failed')) + img.src = url + }) +} + // Why: the store invokes this on removeCustomSidekick so the underlying Blob // is released; otherwise the blob: URL keeps it alive for the rest of the // session, wasting memory per imported image. @@ -40,4 +162,9 @@ export function revokeCustomSidekickBlobUrl(id: string): void { URL.revokeObjectURL(url) blobUrlCache.delete(id) } + const detected = detectedSpriteCache.get(id) + if (detected) { + for (const bmp of detected.bitmaps) {bmp.close()} + detectedSpriteCache.delete(id) + } } diff --git a/src/renderer/src/components/sidekick/sprite-frame-detection.ts b/src/renderer/src/components/sidekick/sprite-frame-detection.ts new file mode 100644 index 00000000000..45a73fa041d --- /dev/null +++ b/src/renderer/src/components/sidekick/sprite-frame-detection.ts @@ -0,0 +1,104 @@ +// Why: pet bundles often ship a sprite sheet without manifest frame data, so +// we can't grid-step it. The sheets we see in the wild leave magenta (chroma +// keyed in sidekick-blob-cache) or transparent gutters between sprites. +// After keying, find connected non-empty rows → bands; within the best band, +// split on empty columns to get one frame per sprite. The result drives the +// canvas-based animation in the overlay. + +export type DetectedFrame = { x: number; y: number; w: number; h: number } +export type DetectedSprite = { frames: DetectedFrame[] } + +const ALPHA_EMPTY = 16 +const MIN_DIM = 8 + +function isPixelEmpty(data: Uint8ClampedArray, idx: number): boolean { + return data[idx + 3] < ALPHA_EMPTY +} + +function computeRowEmpty(data: Uint8ClampedArray, width: number, height: number): Uint8Array { + const rowEmpty = new Uint8Array(height) + for (let y = 0; y < height; y++) { + let empty = 1 + const rowStart = y * width * 4 + for (let x = 0; x < width; x++) { + if (!isPixelEmpty(data, rowStart + x * 4)) { + empty = 0 + break + } + } + rowEmpty[y] = empty + } + return rowEmpty +} + +function findBands(rowEmpty: Uint8Array): { y0: number; y1: number }[] { + const bands: { y0: number; y1: number }[] = [] + let start = -1 + for (let y = 0; y < rowEmpty.length; y++) { + if (!rowEmpty[y] && start < 0) { + start = y + } else if (rowEmpty[y] && start >= 0) { + bands.push({ y0: start, y1: y - 1 }) + start = -1 + } + } + if (start >= 0) { + bands.push({ y0: start, y1: rowEmpty.length - 1 }) + } + return bands +} + +function framesInBand( + data: Uint8ClampedArray, + width: number, + band: { y0: number; y1: number } +): DetectedFrame[] { + const colEmpty = new Uint8Array(width) + for (let x = 0; x < width; x++) { + let empty = 1 + for (let y = band.y0; y <= band.y1; y++) { + if (!isPixelEmpty(data, (y * width + x) * 4)) { + empty = 0 + break + } + } + colEmpty[x] = empty + } + const frames: DetectedFrame[] = [] + let start = -1 + for (let x = 0; x < width; x++) { + if (!colEmpty[x] && start < 0) { + start = x + } else if (colEmpty[x] && start >= 0) { + frames.push({ x: start, y: band.y0, w: x - start, h: band.y1 - band.y0 + 1 }) + start = -1 + } + } + if (start >= 0) { + frames.push({ x: start, y: band.y0, w: width - start, h: band.y1 - band.y0 + 1 }) + } + return frames.filter((f) => f.w >= MIN_DIM && f.h >= MIN_DIM) +} + +export function detectFramesFromImageData(image: ImageData): DetectedSprite | null { + const { data, width, height } = image + const rowEmpty = computeRowEmpty(data, width, height) + const bands = findBands(rowEmpty) + if (bands.length === 0) { + return null + } + // Why: sheets are usually grids with the largest animation as the most- + // populated band. Pick the band yielding the most frames so the overlay + // shows a real walk/idle cycle rather than a single pose. + let best: DetectedFrame[] = [] + for (const band of bands) { + const candidate = framesInBand(data, width, band) + if (candidate.length > best.length) { + best = candidate + } + } + if (best.length === 0) { + return null + } + return { frames: best } +} diff --git a/src/renderer/src/components/sidekick/useSidekickUrl.ts b/src/renderer/src/components/sidekick/useSidekickUrl.ts index b2439a93fb8..dbe01513ab6 100644 --- a/src/renderer/src/components/sidekick/useSidekickUrl.ts +++ b/src/renderer/src/components/sidekick/useSidekickUrl.ts @@ -1,19 +1,35 @@ import { useEffect, useRef, useState } from 'react' +import type { CustomSidekick } from '../../../../shared/types' import { useAppStore } from '../../store' import { BUNDLED_SIDEKICK, findBundledSidekick, isBundledSidekickId } from './sidekick-models' -import { blobUrlCache, loadCustomBlobUrl } from './sidekick-blob-cache' +import { + blobUrlCache, + detectedSpriteCache, + loadCustomBlobUrl, + type DetectedSpriteCacheEntry +} from './sidekick-blob-cache' // Re-export so existing callers (the store slice) that point at this module // keep working without knowing about the cache module split. export { revokeCustomSidekickBlobUrl } from './sidekick-blob-cache' +export type ResolvedSidekick = + | { url: string; ready: boolean; sprite: null; detected: null } + | { + url: string + ready: boolean + sprite: NonNullable + detected: null + } + | { url: string; ready: boolean; sprite: null; detected: DetectedSpriteCacheEntry } + /** Resolve the active sidekick to a URL the overlay can render. * * For bundled sidekicks this is synchronous. For custom ones we issue an * IPC read and build a blob: URL with the correct MIME; until that resolves, * we fall back to the bundled default so the overlay is never empty. */ -export function useSidekickUrl(): { url: string; ready: boolean } { +export function useSidekickUrl(): ResolvedSidekick { const sidekickId = useAppStore((s) => s.sidekickId) const customSidekicks = useAppStore((s) => s.customSidekicks) const bundled = isBundledSidekickId(sidekickId) @@ -30,6 +46,10 @@ export function useSidekickUrl(): { url: string; ready: boolean } { const customId = customMeta?.id ?? null const customFileName = customMeta?.fileName ?? null const customMime = customMeta?.mimeType ?? 'image/png' + const customKind = customMeta?.kind ?? 'image' + // Why: prefer manifest fps captured at import time; sprite-with-frame entries + // store fps on `sprite`, frame-less bundles carry it on `spriteFps`. + const customSpriteFps = customMeta?.sprite?.fps ?? customMeta?.spriteFps useEffect(() => { if (!customId || !customFileName) { setCustomUrl(null) @@ -45,26 +65,39 @@ export function useSidekickUrl(): { url: string; ready: boolean } { setCustomUrl(null) pendingRef.current = customId let cancelled = false - void loadCustomBlobUrl(customId, customFileName, customMime).then((url) => { - if (cancelled || pendingRef.current !== customId) { - return + void loadCustomBlobUrl(customId, customFileName, customMime, customKind, customSpriteFps).then( + (url) => { + if (cancelled || pendingRef.current !== customId) { + return + } + setCustomUrl(url) } - setCustomUrl(url) - }) + ) return () => { cancelled = true } - }, [customId, customFileName, customMime]) + }, [customId, customFileName, customMime, customKind, customSpriteFps]) if (bundled) { const sidekick = findBundledSidekick(sidekickId) ?? BUNDLED_SIDEKICK - return { url: sidekick.url, ready: true } + return { url: sidekick.url, ready: true, sprite: null, detected: null } } if (customMeta && customUrl) { - return { url: customUrl, ready: true } + // Why: guard against manifest entries with zero/negative dims or fps — + // those would break the overlay's frame math, so fall through to detection. + if ( + customMeta.sprite && + customMeta.sprite.frameWidth > 0 && + customMeta.sprite.frameHeight > 0 && + customMeta.sprite.fps > 0 + ) { + return { url: customUrl, ready: true, sprite: customMeta.sprite, detected: null } + } + const detected = detectedSpriteCache.get(customMeta.id) + if (detected) { + return { url: customUrl, ready: true, sprite: null, detected } + } + return { url: customUrl, ready: true, sprite: null, detected: null } } - // Fallback: while a custom blob URL is loading (or if the custom sidekick is - // missing entirely), render the bundled default so the overlay doesn't - // flash empty. - return { url: BUNDLED_SIDEKICK.url, ready: false } + return { url: BUNDLED_SIDEKICK.url, ready: false, sprite: null, detected: null } } diff --git a/src/renderer/src/components/status-bar/SidekickStatusSegment.tsx b/src/renderer/src/components/status-bar/SidekickStatusSegment.tsx index ac66fea4fd4..692533ce486 100644 --- a/src/renderer/src/components/status-bar/SidekickStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/SidekickStatusSegment.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { Check, Trash2, Upload } from 'lucide-react' +import { Check, PackageOpen, Trash2, Upload } from 'lucide-react' import { toast } from 'sonner' import { DropdownMenu, @@ -20,6 +20,7 @@ import { findBundledSidekick, isBundledSidekickId } from '../sidekick/sidekick-models' +import { SIDEKICK_SIZE_MAX, SIDEKICK_SIZE_MIN } from '../../../../shared/types' // Why: cluster sidekick-related controls (show/hide, character picker, custom // upload + removal, jump-to-settings) behind a single status-bar segment. Only @@ -34,6 +35,8 @@ function SidekickStatusSegmentInner(): React.JSX.Element { const customSidekicks = useAppStore((s) => s.customSidekicks) const addCustomSidekick = useAppStore((s) => s.addCustomSidekick) const removeCustomSidekick = useAppStore((s) => s.removeCustomSidekick) + const sidekickSize = useAppStore((s) => s.sidekickSize) + const setSidekickSize = useAppStore((s) => s.setSidekickSize) const openSettingsPage = useAppStore((s) => s.openSettingsPage) const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) @@ -67,6 +70,27 @@ function SidekickStatusSegmentInner(): React.JSX.Element { } } + const handleImportPetBundle = async (): Promise => { + if (!window.api?.sidekick?.importPetBundle) { + toast.error('Pet bundle import needs a full app restart (not just reload).') + return + } + try { + const model = await window.api.sidekick.importPetBundle() + if (!model) { + return + } + addCustomSidekick(model) + if (!sidekickVisible) { + setSidekickVisible(true) + } + setSidekickId(model.id) + } catch (error) { + console.error('[sidekick-overlay] pet bundle: error', error) + toast.error(error instanceof Error ? error.message : 'Failed to import pet bundle') + } + } + return ( @@ -92,6 +116,31 @@ function SidekickStatusSegmentInner(): React.JSX.Element { > {sidekickVisible ? 'Hide sidekick' : 'Show sidekick'} + {/* Why: in-menu range so users can resize the overlay without leaving + the dropdown — pet sprites can import larger than the default 180px + box and visually overwhelm the viewport. preventDefault on pointer + events stops Radix from closing the menu while the user drags. */} +
e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > +
+ Size + {sidekickSize}px +
+ setSidekickSize(Number(e.target.value))} + className="w-full" + aria-label="Sidekick size" + /> +
Choose sidekick {/* Why: portal so the submenu escapes the parent Content's overflow @@ -165,6 +214,14 @@ function SidekickStatusSegmentInner(): React.JSX.Element { Upload your own… + { + void handleImportPetBundle() + }} + > + + Import .codex-pet bundle… + diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 934d0b7e48a..0acce050518 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -14,9 +14,26 @@ import type { UpdateStatus, WorktreeCardProperty } from '../../../../shared/types' +import { + SIDEKICK_SIZE_DEFAULT, + SIDEKICK_SIZE_MAX, + SIDEKICK_SIZE_MIN +} from '../../../../shared/types' import { PER_REPO_FETCH_LIMIT } from '../../../../shared/work-items' +import { + DEFAULT_STATUS_BAR_ITEMS, + DEFAULT_WORKTREE_CARD_PROPERTIES +} from '../../../../shared/constants' +import type { OrcaHookScriptKind } from '../../lib/orca-hook-trust' +import { DEFAULT_SIDEKICK_ID, isBundledSidekickId } from '../../components/sidekick/sidekick-models' +import { revokeCustomSidekickBlobUrl } from '../../components/sidekick/sidekick-blob-cache' import { isGitRepoKind } from '../../../../shared/repo-kind' +function clampSidekickSize(size: number): number { + if (!Number.isFinite(size)) {return SIDEKICK_SIZE_DEFAULT} + return Math.max(SIDEKICK_SIZE_MIN, Math.min(SIDEKICK_SIZE_MAX, Math.round(size))) +} + // Why: mirrors the preset→query mapping used by TaskPage's preset buttons. // Keeping a local copy here avoids a store ↔ lib circular import while letting // openTaskPage warm exactly the cache key the page will read on mount. @@ -36,11 +53,6 @@ function presetToQuery(presetId: TaskViewPresetId | null): string { return 'is:open' } } -import { - DEFAULT_STATUS_BAR_ITEMS, - DEFAULT_WORKTREE_CARD_PROPERTIES -} from '../../../../shared/constants' - // Why: persisted UI state pre-dated the consolidation of `memory` + `sessions` // into a single `resource-usage` entry. Rewrite legacy ids in place and // de-duplicate. We leave unknown ids alone so a downgrade→upgrade cycle @@ -56,9 +68,6 @@ function migrateStatusBarItems(items: readonly string[] | undefined): StatusBarI } return out as StatusBarItem[] } -import type { OrcaHookScriptKind } from '../../lib/orca-hook-trust' -import { DEFAULT_SIDEKICK_ID, isBundledSidekickId } from '../../components/sidekick/sidekick-models' -import { revokeCustomSidekickBlobUrl } from '../../components/sidekick/sidekick-blob-cache' const MIN_SIDEBAR_WIDTH = 220 const MAX_LEFT_SIDEBAR_WIDTH = 500 @@ -265,6 +274,11 @@ export type UISlice = { customSidekicks: CustomSidekick[] addCustomSidekick: (model: CustomSidekick) => void removeCustomSidekick: (id: string) => void + /** Sidekick overlay size in CSS pixels (square). User-adjustable from the + * status-bar menu so a too-big imported sprite isn't a stuck-on-screen + * problem. */ + sidekickSize: number + setSidekickSize: (size: number) => void pendingRevealWorktreeId: string | null revealWorktreeInSidebar: (worktreeId: string) => void clearPendingRevealWorktreeId: () => void @@ -583,6 +597,13 @@ export const createUISlice: StateCreator = (set, get) set({ sidekickId: id }) }, + sidekickSize: SIDEKICK_SIZE_DEFAULT, + setSidekickSize: (size) => { + const clamped = clampSidekickSize(size) + window.api.ui.set({ sidekickSize: clamped }).catch(console.error) + set({ sidekickSize: clamped }) + }, + customSidekicks: [], addCustomSidekick: (model) => set((s) => { @@ -597,13 +618,18 @@ export const createUISlice: StateCreator = (set, get) return s } const next = s.customSidekicks.filter((m) => m.id !== id) - window.api.ui.set({ customSidekicks: next }).catch(console.error) // Why: if the user removes the currently-active custom sidekick, fall // back to the bundled default so the overlay doesn't render nothing. const fallback = s.sidekickId === id ? DEFAULT_SIDEKICK_ID : s.sidekickId - if (fallback !== s.sidekickId) { - window.api.ui.set({ sidekickId: fallback }).catch(console.error) + // Why: send a single combined IPC update so customSidekicks and + // sidekickId persist atomically when both change. + const ipcPayload: { customSidekicks: CustomSidekick[]; sidekickId?: string } = { + customSidekicks: next } + if (fallback !== s.sidekickId) { + ipcPayload.sidekickId = fallback + } + window.api.ui.set(ipcPayload).catch(console.error) // Why: revoke the cached blob: URL so the underlying Blob is released; // otherwise it stays in memory for the rest of the session. revokeCustomSidekickBlobUrl(id) @@ -611,8 +637,10 @@ export const createUISlice: StateCreator = (set, get) // fails, the orphaned image stays in userData; each import uses a fresh // UUID so the file won't be hit again, and the renderer's metadata // index no longer references it. - window.api.sidekick.delete(id, target.fileName).catch(console.error) - return { customSidekicks: next, sidekickId: fallback } + window.api.sidekick.delete(id, target.fileName, target.kind).catch(console.error) + const partial: Partial = { customSidekicks: next } + if (fallback !== s.sidekickId) {partial.sidekickId = fallback} + return partial }), pendingRevealWorktreeId: null, @@ -670,6 +698,7 @@ export const createUISlice: StateCreator = (set, get) // they enable the experimental flag. Only an explicit Hide sidekick // dismissal persists a `false` value. sidekickVisible: ui.sidekickVisible ?? true, + sidekickSize: clampSidekickSize(ui.sidekickSize ?? SIDEKICK_SIZE_DEFAULT), customSidekicks: Array.isArray(ui.customSidekicks) ? ui.customSidekicks : [], // Why: accept the persisted id if it matches a bundled sidekick or a // known custom one; otherwise fall back so the overlay never renders diff --git a/src/shared/types.ts b/src/shared/types.ts index 4fea0483f66..01a63fa0d14 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1419,12 +1419,19 @@ export type PersistedUIState = { * this field is the metadata index so custom sidekicks ride the existing * PersistedUIState save pipeline. */ customSidekicks?: CustomSidekick[] + /** On-screen size of the sidekick overlay in CSS pixels (square box). + * Clamped to [SIDEKICK_SIZE_MIN, SIDEKICK_SIZE_MAX] when read. */ + sidekickSize?: number /** Page-position state for Tasks. Source/repo/team/project selections keep * using their existing settings paths; this only restores transient tabs * and applied searches. */ taskResumeState?: TaskResumeState } +export const SIDEKICK_SIZE_MIN = 60 +export const SIDEKICK_SIZE_MAX = 360 +export const SIDEKICK_SIZE_DEFAULT = 180 + /** Metadata for a user-uploaded sidekick image. `id` is the stable identifier; * the on-disk filename (preserving the original extension) lives in `fileName`. * The renderer never learns the absolute path — it asks main for the bytes @@ -1437,6 +1444,36 @@ export type CustomSidekick = { * Content-Type — especially image/svg+xml, which browsers won't render * from a misdeclared blob URL. */ mimeType: string + /** Storage layout. `image` = legacy flat file at `custom/.`. + * `bundle` = `.codex-pet` import expanded into `custom//`. Absent = + * legacy `image` for backwards compatibility with persisted state. */ + kind?: 'image' | 'bundle' + /** Sprite-sheet metadata captured at import time. Present iff this entry + * came from a `.codex-pet` bundle and the manifest declared frame layout. + * `columns`/`rows`/`sheetWidth`/`sheetHeight` are derived in main from + * the decoded sheet so the renderer doesn't need to probe the image. */ + sprite?: { + frameWidth: number + frameHeight: number + columns: number + rows: number + sheetWidth: number + sheetHeight: number + fps: number + defaultAnimation?: string + animations?: Record + } + /** Manifest-declared fps captured even when the manifest omits `frame` and + * the renderer falls back to auto-detected frames. Lets DetectedSpriteFrame + * honor the bundle's intended playback speed instead of a hardcoded 8 fps. */ + spriteFps?: number +} + +/** One animation strip within a sprite sheet: `row` is the y-index (0-based) + * and `frames` is the number of consecutive cells played left-to-right. */ +export type SpriteAnimation = { + row: number + frames: number } export type PersistedTrustedOrcaHookEntry = { From f938ff185f8f0097563d43dcf2789cad6e02f321 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 5 May 2026 16:09:16 -0700 Subject: [PATCH 010/213] feat(mobile): account switcher and rate-limit usage on mobile (#1467) Co-authored-by: Orca --- mobile/app/h/[hostId]/accounts.tsx | 423 ++++++++++++++++++ mobile/app/h/[hostId]/index.tsx | 14 +- mobile/app/h/_layout.tsx | 1 + mobile/app/index.tsx | 318 +++++++++++-- mobile/src/cache/home-snapshot-cache.ts | 66 +++ mobile/src/components/AccountUsage.tsx | 155 +++++++ mobile/src/components/AgentIcons.tsx | 30 ++ mobile/src/components/NewWorktreeModal.tsx | 27 +- src/main/index.ts | 1 + src/main/rate-limits/service.ts | 18 +- src/main/runtime/orca-runtime.ts | 136 +++++- src/main/runtime/rpc/core.ts | 6 + src/main/runtime/rpc/dispatcher.ts | 17 +- src/main/runtime/rpc/methods/accounts.ts | 111 +++++ src/main/runtime/rpc/methods/index.ts | 4 +- src/main/runtime/rpc/methods/notifications.ts | 26 +- src/main/runtime/rpc/ws-transport.ts | 22 +- src/main/runtime/runtime-rpc.ts | 42 +- 18 files changed, 1335 insertions(+), 82 deletions(-) create mode 100644 mobile/app/h/[hostId]/accounts.tsx create mode 100644 mobile/src/cache/home-snapshot-cache.ts create mode 100644 mobile/src/components/AccountUsage.tsx create mode 100644 mobile/src/components/AgentIcons.tsx create mode 100644 src/main/runtime/rpc/methods/accounts.ts diff --git a/mobile/app/h/[hostId]/accounts.tsx b/mobile/app/h/[hostId]/accounts.tsx new file mode 100644 index 00000000000..9319b616e21 --- /dev/null +++ b/mobile/app/h/[hostId]/accounts.tsx @@ -0,0 +1,423 @@ +import { useEffect, useState, useCallback, useRef } from 'react' +import { + View, + Text, + StyleSheet, + Pressable, + ScrollView, + ActivityIndicator, + RefreshControl, + Alert +} from 'react-native' +import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context' +import { useLocalSearchParams, useRouter } from 'expo-router' +import { ChevronLeft, Check, RefreshCw, User } from 'lucide-react-native' +import { connect, type RpcClient } from '../../../src/transport/rpc-client' +import { loadHosts } from '../../../src/transport/host-store' +import type { ConnectionState, RpcSuccess } from '../../../src/transport/types' +import { colors, spacing, typography, radii } from '../../../src/theme/mobile-theme' +import { ClaudeIcon, OpenAIIcon } from '../../../src/components/AgentIcons' +import { + type AccountsSnapshot, + type ProviderKey, + getActiveProviderRateLimits, + getInactiveProviderUsage, + UsageBar +} from '../../../src/components/AccountUsage' + +export default function AccountsScreen() { + const router = useRouter() + const insets = useSafeAreaInsets() + const { hostId } = useLocalSearchParams<{ hostId: string }>() + + const [client, setClient] = useState(null) + const [connState, setConnState] = useState('connecting') + const [hostName, setHostName] = useState('') + const [snapshot, setSnapshot] = useState(null) + const [error, setError] = useState(null) + const [refreshing, setRefreshing] = useState(false) + const [busyAccountId, setBusyAccountId] = useState(null) + const clientRef = useRef(null) + + // Why: connect to the host's WebSocket on mount and tear down on + // unmount. Mirrors the connection lifecycle used in + // /h/[hostId]/index.tsx so reconnect/auth-failed states are handled + // identically. + useEffect(() => { + if (!hostId) return + let cancelled = false + let rpcClient: RpcClient | null = null + + void (async () => { + const hosts = await loadHosts() + const host = hosts.find((h) => h.id === hostId) + if (!host) { + if (!cancelled) setError('Host not found') + return + } + if (!cancelled) setHostName(host.name) + rpcClient = connect(host.endpoint, host.deviceToken, host.publicKeyB64, (state) => { + if (!cancelled) setConnState(state) + }) + clientRef.current = rpcClient + if (!cancelled) setClient(rpcClient) + })() + + return () => { + cancelled = true + if (rpcClient) rpcClient.close() + clientRef.current = null + } + }, [hostId]) + + // Why: subscribe to streaming snapshot updates so usage bars refresh in + // place when the desktop's rate-limit poll completes (every 5 min) or + // when the user switches accounts. Falls back to a one-shot accounts.list + // if the subscription stream errors. + useEffect(() => { + if (!client || connState !== 'connected') return + const unsubscribe = client.subscribe('accounts.subscribe', null, (payload) => { + if (!payload || typeof payload !== 'object') return + const evt = payload as { type?: string; snapshot?: AccountsSnapshot } + if ((evt.type === 'ready' || evt.type === 'snapshot') && evt.snapshot) { + setSnapshot(evt.snapshot) + setError(null) + } + }) + return unsubscribe + }, [client, connState]) + + const refresh = useCallback(async () => { + if (!client) return + setRefreshing(true) + try { + const res = await client.sendRequest('accounts.list') + if (res.ok) { + setSnapshot((res as RpcSuccess).result as AccountsSnapshot) + setError(null) + } else { + setError(res.error.message) + } + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setRefreshing(false) + } + }, [client]) + + const selectAccount = useCallback( + async (provider: ProviderKey, accountId: string | null) => { + if (!client) return + setBusyAccountId(accountId ?? `${provider}:default`) + const method = provider === 'claude' ? 'accounts.selectClaude' : 'accounts.selectCodex' + try { + const res = await client.sendRequest(method, { accountId }) + if (!res.ok) { + Alert.alert('Could not switch account', res.error.message) + } else { + // Why: optimistic refresh — the streaming subscription will also + // emit, but a one-shot keeps the UI responsive even if the stream + // is temporarily disconnected. + await refresh() + } + } catch (e) { + Alert.alert('Could not switch account', e instanceof Error ? e.message : String(e)) + } finally { + setBusyAccountId(null) + } + }, + [client, refresh] + ) + + const renderProviderSection = (provider: ProviderKey, title: string) => { + if (!snapshot) return null + const state = provider === 'claude' ? snapshot.claude : snapshot.codex + const activeUsage = getActiveProviderRateLimits(snapshot, provider) + const Icon = provider === 'claude' ? ClaudeIcon : OpenAIIcon + return ( + + + + {title} + + + {/* System default row */} + [styles.row, pressed && styles.rowPressed]} + onPress={() => selectAccount(provider, null)} + disabled={busyAccountId !== null || connState !== 'connected'} + > + + System default + Use the agent's own login + + + {state.activeAccountId === null ? ( + + ) : busyAccountId === `${provider}:default` ? ( + + ) : null} + + + + {state.accounts.map((account) => { + const isActive = state.activeAccountId === account.id + const inactiveEntry = !isActive + ? getInactiveProviderUsage(snapshot, provider, account.id) + : null + const usage = isActive ? activeUsage : (inactiveEntry?.claude ?? null) + const isFetching = + (isActive && usage?.status === 'fetching') || + (!isActive && inactiveEntry?.isFetching === true) + const session = usage?.session + const weekly = usage?.weekly + return ( + + + [styles.row, pressed && styles.rowPressed]} + onPress={() => selectAccount(provider, account.id)} + disabled={busyAccountId !== null || connState !== 'connected' || isActive} + > + + + {account.email} + + + + + + {usage?.error ? ( + + {usage.error} + + ) : null} + + + {isActive ? ( + + ) : busyAccountId === account.id ? ( + + ) : null} + + + + ) + })} + + + ) + } + + return ( + + + router.back()}> + + + + Accounts + {hostName ? ( + + {hostName} + + ) : null} + + + {refreshing ? ( + + ) : ( + + )} + + + + + } + > + {connState !== 'connected' && !snapshot ? ( + + + Connecting to {hostName || 'host'}… + + ) : error && !snapshot ? ( + + {error} + + ) : !snapshot ? ( + + + Loading accounts… + + ) : ( + <> + {renderProviderSection('claude', 'Claude')} + {renderProviderSection('codex', 'Codex')} + + + + Add or re-authenticate accounts from desktop Settings → Accounts. + + + + )} + + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.bgBase + }, + topRow: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing.md, + paddingTop: spacing.sm, + paddingBottom: spacing.sm, + gap: spacing.sm + }, + backButton: { + width: 36, + height: 36, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center' + }, + iconButton: { + width: 36, + height: 36, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center' + }, + titleWrap: { + flex: 1 + }, + heading: { + fontSize: 20, + fontWeight: '700', + color: colors.textPrimary + }, + subheading: { + fontSize: typography.metaSize, + color: colors.textSecondary, + marginTop: 1 + }, + scroll: { + paddingHorizontal: spacing.lg, + paddingTop: spacing.sm + }, + section: { + marginBottom: spacing.xl + }, + sectionHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + marginBottom: spacing.sm + }, + sectionHeading: { + fontSize: typography.metaSize, + fontWeight: '600', + color: colors.textSecondary, + textTransform: 'uppercase', + letterSpacing: 0.5 + }, + card: { + backgroundColor: colors.bgPanel, + borderRadius: radii.card, + overflow: 'hidden' + }, + row: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: spacing.md, + paddingHorizontal: spacing.md + 2 + }, + rowPressed: { + backgroundColor: colors.bgRaised + }, + rowMain: { + flex: 1, + gap: 4 + }, + // Why: fixed-width trailing slot so the usage bars in `rowMain` keep the + // same width whether or not the row is currently selected (otherwise the + // checkmark on the active account squeezes the bars narrower than the + // inactive rows above/below it). + rowTrailing: { + width: 24, + alignItems: 'flex-end', + justifyContent: 'center', + marginLeft: spacing.sm + }, + rowTitle: { + fontSize: typography.bodySize, + fontWeight: '500', + color: colors.textPrimary + }, + rowSubtitle: { + fontSize: typography.metaSize, + color: colors.textSecondary + }, + separator: { + height: StyleSheet.hairlineWidth, + backgroundColor: colors.borderSubtle, + marginHorizontal: spacing.md + }, + usageRow: { + flexDirection: 'row', + gap: spacing.md, + marginTop: 4 + }, + errorText: { + fontSize: typography.metaSize, + color: colors.statusRed + }, + placeholder: { + paddingVertical: spacing.xl * 2, + alignItems: 'center', + gap: spacing.sm + }, + placeholderText: { + fontSize: typography.bodySize, + color: colors.textSecondary + }, + footerHint: { + flexDirection: 'row', + alignItems: 'flex-start', + gap: spacing.sm, + paddingHorizontal: spacing.sm, + paddingTop: spacing.sm + }, + footerHintText: { + flex: 1, + fontSize: typography.metaSize, + color: colors.textMuted, + lineHeight: 18 + } +}) diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index e428d70e511..e5c9c318212 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -24,7 +24,8 @@ import { Plus, Moon, Filter, - Check + Check, + UserCircle } from 'lucide-react-native' import { connect, type RpcClient } from '../../../src/transport/rpc-client' import { loadHosts, updateLastConnected, removeHost } from '../../../src/transport/host-store' @@ -682,6 +683,17 @@ export default function HostScreen() { + router.push(`/h/${hostId}/accounts`)} + disabled={connState !== 'connected'} + > + + + setShowNewWorktree(true)} diff --git a/mobile/app/h/_layout.tsx b/mobile/app/h/_layout.tsx index 59bbf7eeaf4..1bd3fec4185 100644 --- a/mobile/app/h/_layout.tsx +++ b/mobile/app/h/_layout.tsx @@ -10,6 +10,7 @@ export default function HostGroupLayout() { }} > + ) diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index 7f01cecb679..19f25eefd82 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -13,6 +13,13 @@ import { Terminal, Plus } from 'lucide-react-native' +import { ClaudeIcon, OpenAIIcon } from '../src/components/AgentIcons' +import { + type AccountsSnapshot, + type ProviderKey, + getActiveProviderRateLimits, + UsageBar +} from '../src/components/AccountUsage' import AsyncStorage from '@react-native-async-storage/async-storage' import { loadHosts, removeHost, renameHost } from '../src/transport/host-store' import { connect, type RpcClient } from '../src/transport/rpc-client' @@ -24,6 +31,7 @@ import { TextInputModal } from '../src/components/TextInputModal' import { ActionSheetModal } from '../src/components/ActionSheetModal' import { ConfirmModal } from '../src/components/ConfirmModal' import { setCachedWorktrees, getCachedWorktrees } from '../src/cache/worktree-cache' +import { loadHomeSnapshot, saveHomeSnapshot } from '../src/cache/home-snapshot-cache' import { colors, spacing, radii } from '../src/theme/mobile-theme' function endpointLabel(endpoint: string): string { @@ -102,16 +110,24 @@ function fetchWorktreeInfo( ) => void, disposed: () => boolean ) { - const markLoaded = () => { - setInfo((prev) => ({ - ...prev, - [hostId]: { - hostId, - totalWorktrees: 0, - activeCount: 0, - lastActiveWorktree: null + // Why: only seed an empty zeroed entry when this host has no prior info + // at all (e.g., first ever load before any cache hydration). On a + // transient failure for a host that already has cached data, leave the + // cached entry alone so the Resume card and host-meta line don't + // momentarily flip to "0 worktrees" / disappear during reconnects. + const markLoadedIfMissing = () => { + setInfo((prev) => { + if (prev[hostId]) return prev + return { + ...prev, + [hostId]: { + hostId, + totalWorktrees: 0, + activeCount: 0, + lastActiveWorktree: null + } } - })) + }) } client @@ -135,14 +151,34 @@ function fetchWorktreeInfo( } })) } else { - markLoaded() + markLoadedIfMissing() } }) .catch(() => { - if (!disposed()) markLoaded() + if (!disposed()) markLoadedIfMissing() }) } +function fetchAccountsSnapshot( + client: RpcClient, + hostId: string, + setSnapshots: ( + updater: (prev: Record) => Record + ) => void, + disposed: () => boolean +) { + client + .sendRequest('accounts.list') + .then((response) => { + if (disposed()) return + if (response.ok) { + const snapshot = response.result as AccountsSnapshot + setSnapshots((prev) => ({ ...prev, [hostId]: snapshot })) + } + }) + .catch(() => {}) +} + // Why: repo names get a stable color derived from hashing, matching the // host detail page's colored dots for visual consistency. const REPO_COLORS = ['#8b5cf6', '#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#ec4899', '#06b6d4'] @@ -164,11 +200,53 @@ export default function HomeScreen() { const [hostStates, setHostStates] = useState>({}) const [stats, setStats] = useState(null) const [worktreeInfo, setWorktreeInfo] = useState>({}) + const [accountsByHost, setAccountsByHost] = useState>({}) const [lastVisited, setLastVisited] = useState<{ hostId: string; worktreeId: string } | null>( null ) const clientsRef = useRef>([]) + // Why: hydrate the home page from a persisted snapshot on cold-start so + // Resume + Account-usage cards paint immediately with last-known data + // instead of flashing empty for ~1s while the WebSocket reconnects. + // Stream/list responses overwrite this seed in place when they arrive. + const hydratedRef = useRef(false) + useEffect(() => { + if (hydratedRef.current) return + hydratedRef.current = true + let cancelled = false + void loadHomeSnapshot().then((snap) => { + if (cancelled || !snap) return + setWorktreeInfo((prev) => (Object.keys(prev).length > 0 ? prev : snap.worktreeInfo)) + setAccountsByHost((prev) => (Object.keys(prev).length > 0 ? prev : snap.accountsByHost)) + for (const [hostId, info] of Object.entries(snap.worktreeInfo)) { + const wt = info.lastActiveWorktree + if (wt) { + // Why: also seed the in-memory worktree cache so resumeWorktree's + // lastVisited fast-path can find the cached worktree object. + setCachedWorktrees(hostId, [wt]) + } + } + }) + return () => { + cancelled = true + } + }, []) + + // Why: persist the merged snapshot whenever either piece updates so the + // next cold-start has fresh seed data. The cache module debounces writes + // internally so a flurry of streamed updates doesn't hammer disk. + useEffect(() => { + if (Object.keys(worktreeInfo).length === 0 && Object.keys(accountsByHost).length === 0) { + return + } + saveHomeSnapshot({ + worktreeInfo, + accountsByHost, + savedAt: Date.now() + }) + }, [worktreeInfo, accountsByHost]) + useFocusEffect( useCallback(() => { let stale = false @@ -185,6 +263,7 @@ export default function HomeScreen() { if (entry.client.getState() === 'connected') { fetchStats(entry.client, setStats, () => stale) fetchWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => stale) + fetchAccountsSnapshot(entry.client, entry.hostId, setAccountsByHost, () => stale) } } return () => { @@ -222,25 +301,43 @@ export default function HomeScreen() { } let unsubNotif: (() => void) | null = null + let unsubAccounts: (() => void) | null = null let statsFetched = false const unsubState = client.onStateChange((state) => { if (state === 'connected') { if (!unsubNotif) { unsubNotif = subscribeToDesktopNotifications(client) } + if (!unsubAccounts) { + unsubAccounts = client.subscribe('accounts.subscribe', null, (payload) => { + if (disposed || !payload || typeof payload !== 'object') return + const evt = payload as { type?: string; snapshot?: AccountsSnapshot } + if ((evt.type === 'ready' || evt.type === 'snapshot') && evt.snapshot) { + const snap = evt.snapshot + setAccountsByHost((prev) => ({ ...prev, [host.id]: snap })) + } + }) + } if (!statsFetched) { statsFetched = true fetchStats(client, setStats, () => disposed) fetchWorktreeInfo(client, host.id, setWorktreeInfo, () => disposed) } - } else if (unsubNotif) { - unsubNotif() - unsubNotif = null + } else { + if (unsubNotif) { + unsubNotif() + unsubNotif = null + } + if (unsubAccounts) { + unsubAccounts() + unsubAccounts = null + } } }) notifCleanups.push(() => { unsubState() unsubNotif?.() + unsubAccounts?.() }) return [{ hostId: host.id, client }] @@ -259,12 +356,20 @@ export default function HomeScreen() { // Why: prefer the worktree the user last opened on this device so the // "Resume" card reflects their mobile session history, not just the // desktop's most-recently-outputting worktree. + // Why: rendering used to be gated on hostStates === 'connected', which + // caused the Resume card to vanish for ~1s on every cold-start / + // resume-from-background while the WebSocket reconnected, even though we + // had perfectly good cached worktree data. Now the card stays visible as + // long as we have a cached lastActiveWorktree for any known host; the + // tap target is still the same and a fresher snapshot from the live RPC + // overwrites the card's contents in place when it lands. const resumeWorktree = useMemo(() => { - if (lastVisited && hostStates[lastVisited.hostId] === 'connected') { + if (lastVisited && sortedHosts.some((h) => h.id === lastVisited.hostId)) { const cached = getCachedWorktrees(lastVisited.hostId) as WorktreeSummary[] | null const match = cached?.find((w) => w.worktreeId === lastVisited.worktreeId) if (match) return { hostId: lastVisited.hostId, worktree: match } } + // Prefer a currently-connected host's data when we have it. for (const host of sortedHosts) { if (hostStates[host.id] !== 'connected') continue const info = worktreeInfo[host.id] @@ -272,6 +377,14 @@ export default function HomeScreen() { return { hostId: host.id, worktree: info.lastActiveWorktree } } } + // Fall back to whichever known host has cached data, regardless of + // current connection state. + for (const host of sortedHosts) { + const info = worktreeInfo[host.id] + if (info?.lastActiveWorktree) { + return { hostId: host.id, worktree: info.lastActiveWorktree } + } + } return null }, [sortedHosts, hostStates, worktreeInfo, lastVisited]) @@ -289,6 +402,23 @@ export default function HomeScreen() { [sortedHosts, hostStates, worktreeInfo] ) + // Why: only show the Account usage section for hosts that have at least + // one Claude or Codex account configured. Render whenever cached data + // exists, regardless of current connection state, so the cards don't + // disappear for ~1s on resume while the WebSocket reconnects. Streamed + // updates from the live RPC overwrite the snapshot in place when ready. + const accountsHosts = useMemo(() => { + const items: Array<{ host: HostProfile; snapshot: AccountsSnapshot }> = [] + for (const host of sortedHosts) { + const snap = accountsByHost[host.id] + if (!snap) continue + const hasClaude = snap.claude.accounts.length > 0 + const hasCodex = snap.codex.accounts.length > 0 + if (hasClaude || hasCodex) items.push({ host, snapshot: snap }) + } + return items + }, [sortedHosts, hostStates, accountsByHost]) + async function handleRename(newName: string) { if (!renameTarget) return try { @@ -503,6 +633,85 @@ export default function HomeScreen() { ) : null} + {/* ─── Account usage ─── */} + {accountsHosts.length > 0 ? ( + <> + + Account usage + + {accountsHosts.map(({ host, snapshot }) => { + const claudeActiveId = snapshot.claude.activeAccountId + const claudeActive = + snapshot.claude.accounts.find((a) => a.id === claudeActiveId) ?? null + const codexActiveId = snapshot.codex.activeAccountId + const codexActive = + snapshot.codex.accounts.find((a) => a.id === codexActiveId) ?? null + const showHostName = accountsHosts.length > 1 + return ( + [ + styles.accountsCard, + pressed && styles.hostCardPressed + ]} + onPress={() => router.push(`/h/${host.id}/accounts`)} + > + {showHostName ? ( + + {host.name} + + ) : null} + {(['claude', 'codex'] as ProviderKey[]).map((provider) => { + const active = provider === 'claude' ? claudeActive : codexActive + const accounts = + provider === 'claude' + ? snapshot.claude.accounts + : snapshot.codex.accounts + if (accounts.length === 0) return null + const limits = getActiveProviderRateLimits(snapshot, provider) + const isFetching = + limits?.status === 'fetching' || limits?.status === 'idle' + const unavailable = + limits == null || + limits.status === 'unavailable' || + limits.status === 'error' + return ( + + + {provider === 'claude' ? ( + + ) : ( + + )} + + + + {active?.email ?? 'System default'} + + + + + + + + ) + })} + + ) + })} + + ) : null} + {/* ─── Quick actions ─── */} Quick Actions @@ -511,7 +720,7 @@ export default function HomeScreen() { onPress={() => router.push('/pair-scan')} > - + Pair Desktop @@ -525,7 +734,7 @@ export default function HomeScreen() { }} > - + New Worktree @@ -667,20 +876,21 @@ const styles = StyleSheet.create({ borderWidth: 1, borderColor: colors.borderSubtle, borderRadius: 10, - padding: spacing.md + paddingVertical: 10, + paddingHorizontal: spacing.md }, statIcon: { - width: 30, - height: 30, - borderRadius: 7, + width: 26, + height: 26, + borderRadius: 6, backgroundColor: 'rgba(255,255,255,0.04)', alignItems: 'center', justifyContent: 'center', - marginBottom: 10 + marginBottom: 6 }, statValue: { color: colors.textPrimary, - fontSize: 20, + fontSize: 18, fontWeight: '700', letterSpacing: -0.3 }, @@ -688,7 +898,7 @@ const styles = StyleSheet.create({ color: colors.textMuted, fontSize: 11, fontWeight: '500', - marginTop: 3 + marginTop: 2 }, /* ─── Section heading ─── */ @@ -817,6 +1027,53 @@ const styles = StyleSheet.create({ flex: 1 }, + /* ─── Account usage ─── */ + accountsCard: { + backgroundColor: colors.bgPanel, + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.card, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm + 2, + gap: spacing.sm, + marginBottom: spacing.sm + }, + accountsHostLabel: { + fontSize: 11, + color: colors.textMuted, + fontWeight: '500', + textTransform: 'uppercase', + letterSpacing: 0.4 + }, + accountsRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + 2 + }, + accountsIcon: { + width: 32, + height: 32, + borderRadius: 9, + backgroundColor: colors.bgRaised, + alignItems: 'center', + justifyContent: 'center' + }, + accountsInfo: { + flex: 1, + minWidth: 0, + gap: 2 + }, + accountsEmail: { + fontSize: 13, + fontWeight: '600', + color: colors.textPrimary + }, + accountsBars: { + flexDirection: 'row', + gap: spacing.md, + marginTop: 4 + }, + /* ─── Skeleton ─── */ skeletonBlock: { backgroundColor: colors.bgRaised, @@ -836,18 +1093,20 @@ const styles = StyleSheet.create({ }, quickAction: { flex: 1, + flexDirection: 'row', backgroundColor: colors.bgPanel, borderWidth: 1, borderColor: colors.borderSubtle, borderRadius: radii.card, - padding: spacing.lg, + paddingVertical: 10, + paddingHorizontal: 12, alignItems: 'center', gap: 10 }, quickActionIcon: { - width: 40, - height: 40, - borderRadius: 12, + width: 28, + height: 28, + borderRadius: 9, backgroundColor: 'rgba(255,255,255,0.04)', alignItems: 'center', justifyContent: 'center' @@ -855,8 +1114,7 @@ const styles = StyleSheet.create({ quickActionLabel: { fontSize: 12, fontWeight: '600', - color: colors.textSecondary, - textAlign: 'center' + color: colors.textSecondary }, /* ─── Empty state ─── */ diff --git a/mobile/src/cache/home-snapshot-cache.ts b/mobile/src/cache/home-snapshot-cache.ts new file mode 100644 index 00000000000..57256be8ea0 --- /dev/null +++ b/mobile/src/cache/home-snapshot-cache.ts @@ -0,0 +1,66 @@ +// Why: persist the data needed to render the home page so cold-start / +// resume-from-background paints instantly with the last known good +// values, then updates in place when fresh RPC data arrives. Without +// this, Resume and Account-usage cards flash empty for ~1s while the +// WebSocket reconnects and the first responses come back. +import AsyncStorage from '@react-native-async-storage/async-storage' +import type { AccountsSnapshot } from '../components/AccountUsage' + +const STORAGE_KEY = 'orca:home-snapshot:v1' + +type WorktreeSummary = { + worktreeId: string + repo: string + branch: string + displayName: string + liveTerminalCount: number + status?: 'working' | 'active' | 'permission' | 'done' | 'inactive' +} + +type HostWorktreeInfo = { + hostId: string + totalWorktrees: number + activeCount: number + lastActiveWorktree: WorktreeSummary | null +} + +export type HomeSnapshot = { + worktreeInfo: Record + accountsByHost: Record + savedAt: number +} + +let memoryCache: HomeSnapshot | null = null +let writeTimer: ReturnType | null = null + +export async function loadHomeSnapshot(): Promise { + if (memoryCache) return memoryCache + try { + const raw = await AsyncStorage.getItem(STORAGE_KEY) + if (!raw) return null + const parsed = JSON.parse(raw) as HomeSnapshot + if ( + typeof parsed !== 'object' || + parsed === null || + typeof parsed.worktreeInfo !== 'object' || + typeof parsed.accountsByHost !== 'object' + ) { + return null + } + memoryCache = parsed + return parsed + } catch { + return null + } +} + +// Why: throttle writes so a flurry of streamed account-snapshot updates +// (one per provider fetch finishing) doesn't hammer AsyncStorage. +export function saveHomeSnapshot(snapshot: HomeSnapshot): void { + memoryCache = snapshot + if (writeTimer) clearTimeout(writeTimer) + writeTimer = setTimeout(() => { + writeTimer = null + void AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot)).catch(() => {}) + }, 250) +} diff --git a/mobile/src/components/AccountUsage.tsx b/mobile/src/components/AccountUsage.tsx new file mode 100644 index 00000000000..0ff706638b9 --- /dev/null +++ b/mobile/src/components/AccountUsage.tsx @@ -0,0 +1,155 @@ +import { View, Text, StyleSheet, ActivityIndicator } from 'react-native' +import { colors, spacing, typography } from '../theme/mobile-theme' + +// Why: keep these shapes in lockstep with src/shared/types.ts and +// src/shared/rate-limit-types.ts. We don't import from desktop here because +// the mobile bundle must not pull in Electron-coupled type files. +export type RateLimitWindow = { + usedPercent: number + windowMinutes: number + resetsAt: number | null + resetDescription: string | null +} + +export type ProviderRateLimits = { + provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' + session: RateLimitWindow | null + weekly: RateLimitWindow | null + monthly?: RateLimitWindow | null + updatedAt: number + error: string | null + status: 'idle' | 'fetching' | 'ok' | 'error' | 'unavailable' +} + +export type InactiveAccountUsage = { + accountId: string + claude: ProviderRateLimits | null + updatedAt: number + isFetching: boolean +} + +export type ClaudeAccountSummary = { + id: string + email: string + organizationName?: string | null +} + +export type CodexAccountSummary = { + id: string + email: string + workspaceLabel?: string | null +} + +export type AccountsSnapshot = { + claude: { accounts: ClaudeAccountSummary[]; activeAccountId: string | null } + codex: { accounts: CodexAccountSummary[]; activeAccountId: string | null } + rateLimits: { + claude: ProviderRateLimits | null + codex: ProviderRateLimits | null + inactiveClaudeAccounts: InactiveAccountUsage[] + inactiveCodexAccounts: InactiveAccountUsage[] + } +} + +export type ProviderKey = 'claude' | 'codex' + +export function getActiveProviderRateLimits( + snapshot: AccountsSnapshot, + provider: ProviderKey +): ProviderRateLimits | null { + return provider === 'claude' ? snapshot.rateLimits.claude : snapshot.rateLimits.codex +} + +export function getInactiveProviderUsage( + snapshot: AccountsSnapshot, + provider: ProviderKey, + accountId: string +): InactiveAccountUsage | null { + const list = + provider === 'claude' + ? snapshot.rateLimits.inactiveClaudeAccounts + : snapshot.rateLimits.inactiveCodexAccounts + return list.find((u) => u.accountId === accountId) ?? null +} + +// Why: matches desktop StatusBar convention — bars show percent remaining +// (so a fresh account renders full, a depleted one renders empty), not +// percent used. Color thresholds invert accordingly. +export function UsageBar({ + label, + usedPercent, + unavailable, + loading +}: { + label: string + usedPercent: number | null + unavailable: boolean + loading?: boolean +}) { + const remaining = usedPercent == null ? null : Math.max(0, Math.min(100, 100 - usedPercent)) + const barColor = + remaining == null + ? colors.textMuted + : remaining <= 10 + ? colors.statusRed + : remaining <= 30 + ? colors.statusAmber + : colors.statusGreen + return ( + + {label} + + + + {loading ? ( + + ) : ( + + {unavailable || remaining == null ? '—' : `${Math.round(remaining)}%`} + + )} + + ) +} + +const styles = StyleSheet.create({ + usageBar: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + flex: 1 + }, + usageLabel: { + fontSize: typography.metaSize, + color: colors.textMuted, + width: 22 + }, + usageTrack: { + flex: 1, + height: 6, + borderRadius: 3, + backgroundColor: colors.bgRaised, + overflow: 'hidden' + }, + usageFill: { + height: '100%', + borderRadius: 3 + }, + usageValue: { + fontSize: typography.metaSize, + color: colors.textSecondary, + width: 36, + textAlign: 'right' + }, + usageSpinner: { + width: 36 + } +}) diff --git a/mobile/src/components/AgentIcons.tsx b/mobile/src/components/AgentIcons.tsx new file mode 100644 index 00000000000..714fe76e0f5 --- /dev/null +++ b/mobile/src/components/AgentIcons.tsx @@ -0,0 +1,30 @@ +import Svg, { Path } from 'react-native-svg' +import { colors } from '../theme/mobile-theme' + +// Why: SVG paths sourced from the desktop codebase +// (src/renderer/src/components/status-bar/icons.tsx) so mobile and desktop +// stay visually identical for Claude/Codex branding. + +export function ClaudeIcon({ size = 16 }: { size?: number }) { + return ( + + + + ) +} + +export function OpenAIIcon({ size = 16, color }: { size?: number; color?: string }) { + return ( + + + + ) +} diff --git a/mobile/src/components/NewWorktreeModal.tsx b/mobile/src/components/NewWorktreeModal.tsx index f0841054739..a7e7d2579b3 100644 --- a/mobile/src/components/NewWorktreeModal.tsx +++ b/mobile/src/components/NewWorktreeModal.tsx @@ -16,6 +16,7 @@ import type { RpcClient } from '../transport/rpc-client' import type { RpcSuccess } from '../transport/types' import { colors, spacing, radii, typography } from '../theme/mobile-theme' import { BottomDrawer } from './BottomDrawer' +import { ClaudeIcon, OpenAIIcon } from './AgentIcons' import { getSuggestedCreatureName } from './worktree-name-suggestion' type Repo = { @@ -91,34 +92,10 @@ const AGENT_COMMANDS: Record = { // ── Agent icons ───────────────────────────────────────────────────── // SVG paths sourced from the desktop codebase: -// Claude & OpenAI: src/renderer/src/components/status-bar/icons.tsx +// Claude & OpenAI: shared in ./AgentIcons.tsx // Pi & Aider: src/renderer/src/lib/agent-catalog.tsx // Agents with a faviconDomain use Google's favicon service (same as desktop). -function ClaudeIcon({ size = 16 }: { size?: number }) { - return ( - - - - ) -} - -function OpenAIIcon({ size = 16 }: { size?: number }) { - return ( - - - - ) -} - function PiIcon({ size = 16 }: { size?: number }) { return ( diff --git a/src/main/index.ts b/src/main/index.ts index 790f4cb2123..84553104e93 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -439,6 +439,7 @@ app.whenReady().then(async () => { // and defeat the teardown helper's prefix sweep (design §4.3 wire-up). getLocalProvider: () => getLocalPtyProvider() }) + runtime.setAccountServices({ claudeAccounts, codexAccounts, rateLimits }) starNag = new StarNagService(store, stats) starNag.start() starNag.registerIpcHandlers() diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index ef827a820b4..b90568567ff 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -74,9 +74,17 @@ export class RateLimitService { private inactiveCodexFetching = new Set() private lastInactiveClaudeFetchAt = 0 private lastInactiveCodexFetchAt = 0 + private stateListeners = new Set<(state: RateLimitState) => void>() constructor() {} + onStateChange(listener: (state: RateLimitState) => void): () => void { + this.stateListeners.add(listener) + return () => { + this.stateListeners.delete(listener) + } + } + setCodexHomePathResolver(resolver: () => string | null): void { this.codexHomePathResolver = resolver } @@ -757,9 +765,17 @@ export class RateLimitService { } private pushToRenderer(): void { + const state = this.getState() + for (const listener of this.stateListeners) { + try { + listener(state) + } catch { + // ignore — one bad listener must not break the others + } + } if (!this.mainWindow || this.mainWindow.isDestroyed()) { return } - this.mainWindow.webContents.send('rateLimits:update', this.getState()) + this.mainWindow.webContents.send('rateLimits:update', state) } } diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 1af46a117ec..8ea72e381a3 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -134,6 +134,23 @@ import { HeadlessEmulator } from '../daemon/headless-emulator' import { killAllProcessesForWorktree } from './worktree-teardown' import { MOBILE_SUBSCRIBE_SCROLLBACK_ROWS } from './scrollback-limits' import type { IPtyProvider } from '../providers/types' +import type { ClaudeAccountService } from '../claude-accounts/service' +import type { CodexAccountService } from '../codex-accounts/service' +import type { RateLimitService } from '../rate-limits/service' +import type { ClaudeRateLimitAccountsState, CodexRateLimitAccountsState } from '../../shared/types' +import type { RateLimitState } from '../../shared/rate-limit-types' + +type RuntimeAccountServices = { + claudeAccounts: ClaudeAccountService + codexAccounts: CodexAccountService + rateLimits: RateLimitService +} + +export type AccountsSnapshot = { + claude: ClaudeRateLimitAccountsState + codex: CodexRateLimitAccountsState + rateLimits: RateLimitState +} type RuntimeStore = { getRepos: Store['getRepos'] @@ -357,6 +374,12 @@ export class OrcaRuntimeService { Set<(event: { mode: 'mobile-fit' | 'desktop-fit'; cols: number; rows: number }) => void> >() private subscriptionCleanups = new Map void>() + // Why: index of subscriptionIds by per-WebSocket connectionId so the + // server can sweep all subscriptions for a closing socket without + // touching subscriptions on other live sockets that share the same + // deviceToken (multi-screen mobile). + private subscriptionsByConnection = new Map>() + private subscriptionConnectionByEntry = new Map() // Why: mobile clients subscribe to desktop notifications via // notifications.subscribe. This set enables fan-out — each connected // mobile client gets its own listener, and dispatchMobileNotification @@ -511,6 +534,7 @@ export class OrcaRuntimeService { private fetchInflight = new Map>() private fetchLastCompletedAt = new Map() private readonly getLocalProviderFn: (() => IPtyProvider) | null + private accountServices: RuntimeAccountServices | null = null constructor( store: RuntimeStore | null = null, @@ -1130,7 +1154,11 @@ export class OrcaRuntimeService { return { ptyId: leaf.ptyId } } - registerSubscriptionCleanup(subscriptionId: string, cleanup: () => void): void { + registerSubscriptionCleanup( + subscriptionId: string, + cleanup: () => void, + connectionId?: string + ): void { // Why: mobile clients reconnect frequently (phone lock, network switch). // The RPC client re-sends terminal.subscribe on reconnect, creating a new // handler before the old one is cleaned up. Without this, the old data @@ -1138,18 +1166,57 @@ export class OrcaRuntimeService { const existing = this.subscriptionCleanups.get(subscriptionId) if (existing) { existing() + // Why: existing() already evicts itself from the per-connection index + // via cleanupSubscription, so no extra bookkeeping is needed here. } this.subscriptionCleanups.set(subscriptionId, cleanup) + if (connectionId) { + let set = this.subscriptionsByConnection.get(connectionId) + if (!set) { + set = new Set() + this.subscriptionsByConnection.set(connectionId, set) + } + set.add(subscriptionId) + this.subscriptionConnectionByEntry.set(subscriptionId, connectionId) + } } cleanupSubscription(subscriptionId: string): void { const cleanup = this.subscriptionCleanups.get(subscriptionId) if (cleanup) { this.subscriptionCleanups.delete(subscriptionId) + const connectionId = this.subscriptionConnectionByEntry.get(subscriptionId) + if (connectionId) { + this.subscriptionConnectionByEntry.delete(subscriptionId) + const set = this.subscriptionsByConnection.get(connectionId) + if (set) { + set.delete(subscriptionId) + if (set.size === 0) { + this.subscriptionsByConnection.delete(connectionId) + } + } + } cleanup() } } + // Why: invoked from the WebSocket transport's on-close hook so streaming + // listeners registered for this exact socket get torn down even when other + // sockets sharing the same deviceToken are still alive (multi-screen + // mobile). Without this sweep, listeners leak across every reconnect. + cleanupSubscriptionsForConnection(connectionId: string): void { + const set = this.subscriptionsByConnection.get(connectionId) + if (!set) { + return + } + // Why: snapshot the ids before iterating because cleanupSubscription + // mutates both the set and the index map. + const ids = Array.from(set) + for (const id of ids) { + this.cleanupSubscription(id) + } + } + // Why: mobile clients subscribe via notifications.subscribe streaming RPC. // Each subscriber gets its own listener. Returns an unsubscribe function // that the subscription cleanup mechanism calls on disconnect. @@ -1170,6 +1237,73 @@ export class OrcaRuntimeService { } } + // ─── Account Services (mobile RPC bridge) ───────────────────── + + setAccountServices(services: RuntimeAccountServices): void { + this.accountServices = services + } + + private requireAccountServices(): RuntimeAccountServices { + if (!this.accountServices) { + throw new Error('Account services are not configured on this runtime') + } + return this.accountServices + } + + getAccountsSnapshot(): AccountsSnapshot { + const { claudeAccounts, codexAccounts, rateLimits } = this.requireAccountServices() + return { + claude: claudeAccounts.listAccounts(), + codex: codexAccounts.listAccounts(), + rateLimits: rateLimits.getState() + } + } + + // Why: RateLimitService polls only when the Electron window is visible AND + // focused, and the inactive-account caches fill lazily when the user opens + // the desktop AccountsPane. Mobile has neither trigger, so without this the + // phone shows 0% / "—" against a backgrounded desktop. Errors swallowed + // because partial usage is still useful for the rest of the snapshot. + async refreshAccountsForMobile(): Promise { + const { rateLimits } = this.requireAccountServices() + await Promise.allSettled([ + rateLimits.refresh(), + rateLimits.fetchInactiveClaudeAccountsOnOpen(), + rateLimits.fetchInactiveCodexAccountsOnOpen() + ]) + } + + selectClaudeAccount(accountId: string | null): Promise { + return this.requireAccountServices().claudeAccounts.selectAccount(accountId) + } + + selectCodexAccount(accountId: string | null): Promise { + return this.requireAccountServices().codexAccounts.selectAccount(accountId) + } + + removeClaudeAccount(accountId: string): Promise { + return this.requireAccountServices().claudeAccounts.removeAccount(accountId) + } + + removeCodexAccount(accountId: string): Promise { + return this.requireAccountServices().codexAccounts.removeAccount(accountId) + } + + // Why: rate-limit polling fires every 5 minutes and on account switch. + // Mobile clients subscribe to receive a fresh AccountsSnapshot whenever + // RateLimitService pushes new usage data, mirroring the existing + // `rateLimits:update` IPC channel desktop already uses. + onAccountsChanged(listener: (snapshot: AccountsSnapshot) => void): () => void { + const services = this.requireAccountServices() + return services.rateLimits.onStateChange(() => { + listener({ + claude: services.claudeAccounts.listAccounts(), + codex: services.codexAccounts.listAccounts(), + rateLimits: services.rateLimits.getState() + }) + }) + } + // ─── Mobile Fit Override Management ───────────────────────── resizeForClient( diff --git a/src/main/runtime/rpc/core.ts b/src/main/runtime/rpc/core.ts index 9c6eab7212a..0340561938e 100644 --- a/src/main/runtime/rpc/core.ts +++ b/src/main/runtime/rpc/core.ts @@ -46,6 +46,12 @@ export type RpcContext = { // runtime-rpc transport (direct in-process callers don't need it). // See design doc §3.1 counter-lifecycle. signal?: AbortSignal + // Why: streaming handlers (notifications/accounts/terminal subscribe) + // register cleanup callbacks against the runtime so reconnects don't leak + // listeners. Keying those cleanups by per-WebSocket connectionId lets the + // server reap all subscriptions for a closing socket, even when other + // sockets for the same deviceToken stay alive (multi-screen mobile). + connectionId?: string } export type RpcHandler = (params: TParams, ctx: RpcContext) => Promise | unknown diff --git a/src/main/runtime/rpc/dispatcher.ts b/src/main/runtime/rpc/dispatcher.ts index faa8c57e33d..bdd2f181b30 100644 --- a/src/main/runtime/rpc/dispatcher.ts +++ b/src/main/runtime/rpc/dispatcher.ts @@ -74,7 +74,11 @@ export class RpcDispatcher { // Why: streaming dispatch sends multiple responses through the reply callback // instead of returning a single Promise. This enables terminal.subscribe and // other subscription-style methods that push data over time. - async dispatchStreaming(request: RpcRequest, reply: (response: string) => void): Promise { + async dispatchStreaming( + request: RpcRequest, + reply: (response: string) => void, + options?: { connectionId?: string } + ): Promise { const meta = this.meta() const method = this.registry.get(request.method) if (!method) { @@ -94,7 +98,10 @@ export class RpcDispatcher { if (!isStreamingMethod(method)) { try { - const result = await method.handler(parsedParams.value, { runtime: this.runtime }) + const result = await method.handler(parsedParams.value, { + runtime: this.runtime, + connectionId: options?.connectionId + }) reply(JSON.stringify(successResponse(request.id, meta, result))) } catch (error) { reply(JSON.stringify(this.mapError(request, meta, error))) @@ -109,7 +116,11 @@ export class RpcDispatcher { } try { - await method.handler(parsedParams.value, { runtime: this.runtime }, emit) + await method.handler( + parsedParams.value, + { runtime: this.runtime, connectionId: options?.connectionId }, + emit + ) } catch (error) { reply(JSON.stringify(this.mapError(request, meta, error))) } diff --git a/src/main/runtime/rpc/methods/accounts.ts b/src/main/runtime/rpc/methods/accounts.ts new file mode 100644 index 00000000000..21612a707f0 --- /dev/null +++ b/src/main/runtime/rpc/methods/accounts.ts @@ -0,0 +1,111 @@ +import { z } from 'zod' +import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core' + +// Why: monotonically increasing per-process counter avoids the Date.now() +// collision that fired when two near-simultaneous accounts.subscribe calls +// collided on the same millisecond and one evicted the other through +// registerSubscriptionCleanup's existing-key eviction path. +let accountsSubscriptionSeq = 0 + +const SelectAccountParams = z.object({ + accountId: z + .union([z.string().min(1, 'Missing accountId'), z.null()]) + .transform((v) => (v === null ? null : v)) +}) + +const RemoveAccountParams = z.object({ + accountId: z.string().min(1, 'Missing accountId') +}) + +const AccountsUnsubscribeParams = z.object({ + subscriptionId: z + .unknown() + .transform((value) => (typeof value === 'string' && value.length > 0 ? value : '')) + .pipe(z.string().min(1, 'Missing subscriptionId')) +}) + +// Why: bridges the desktop ClaudeAccountService / CodexAccountService / +// RateLimitService into the mobile WebSocket RPC. Read + switch + remove +// only — interactive add/re-auth flows spawn `claude login` / `codex login` +// PTYs that need a desktop browser, so they intentionally remain +// desktop-only. See plan in spec doc for issue #1438. +export const ACCOUNT_METHODS: readonly RpcAnyMethod[] = [ + defineMethod({ + name: 'accounts.list', + params: null, + handler: async (_params, { runtime }) => { + // Why: ensure the snapshot reflects the latest provider state before + // returning. Desktop polling pauses when the window is unfocused and + // inactive-account caches only fill on AccountsPane open, so without + // this the mobile UI would render stale nulls / zeroes. + await runtime.refreshAccountsForMobile() + return runtime.getAccountsSnapshot() + } + }), + defineMethod({ + name: 'accounts.selectClaude', + params: SelectAccountParams, + handler: async (params, { runtime }) => runtime.selectClaudeAccount(params.accountId) + }), + defineMethod({ + name: 'accounts.selectCodex', + params: SelectAccountParams, + handler: async (params, { runtime }) => runtime.selectCodexAccount(params.accountId) + }), + defineMethod({ + name: 'accounts.removeClaude', + params: RemoveAccountParams, + handler: async (params, { runtime }) => runtime.removeClaudeAccount(params.accountId) + }), + defineMethod({ + name: 'accounts.removeCodex', + params: RemoveAccountParams, + handler: async (params, { runtime }) => runtime.removeCodexAccount(params.accountId) + }), + // Why: streaming counterpart so mobile usage bars refresh in place when the + // desktop's 5-minute rate-limit poll completes or when the user switches + // accounts on either side. Mirrors the notifications.subscribe pattern. + defineStreamingMethod({ + name: 'accounts.subscribe', + params: null, + handler: async (_params, { runtime, connectionId }, emit) => { + await new Promise((resolve) => { + const unsubscribe = runtime.onAccountsChanged((snapshot) => { + emit({ type: 'snapshot', snapshot }) + }) + + // Why: scope the id by connectionId so two sockets from the same + // device (host + accounts screen) cannot evict each other through + // registerSubscriptionCleanup's "existing key" branch, and append a + // per-process counter so two concurrent subscribes on the same + // socket also can't collide. + const seq = ++accountsSubscriptionSeq + const subscriptionId = `accounts-${connectionId ?? 'inproc'}-${seq}` + runtime.registerSubscriptionCleanup( + subscriptionId, + () => { + unsubscribe() + emit({ type: 'end' }) + resolve() + }, + connectionId + ) + + // Why: emit the current snapshot synchronously so the phone has + // something to render immediately, then kick a forced refresh that + // will broadcast a fresh snapshot through the listener once each + // provider fetch completes. + emit({ type: 'ready', subscriptionId, snapshot: runtime.getAccountsSnapshot() }) + void runtime.refreshAccountsForMobile() + }) + } + }), + defineMethod({ + name: 'accounts.unsubscribe', + params: AccountsUnsubscribeParams, + handler: async (params, { runtime }) => { + runtime.cleanupSubscription(params.subscriptionId) + return { unsubscribed: true } + } + }) +] diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index cf3fbf34f86..bce5a50c4e0 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -8,6 +8,7 @@ import { BROWSER_EXTRA_METHODS } from './browser-extras' import { ORCHESTRATION_METHODS } from './orchestration' import { NOTIFICATION_METHODS } from './notifications' import { STATS_METHODS } from './stats' +import { ACCOUNT_METHODS } from './accounts' // Why: a flat manifest keeps registration order explicit and provides one // grep-point for "what methods does the RPC server expose?" — useful when @@ -21,5 +22,6 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [ ...BROWSER_EXTRA_METHODS, ...ORCHESTRATION_METHODS, ...NOTIFICATION_METHODS, - ...STATS_METHODS + ...STATS_METHODS, + ...ACCOUNT_METHODS ] diff --git a/src/main/runtime/rpc/methods/notifications.ts b/src/main/runtime/rpc/methods/notifications.ts index 607474bb941..1d63f26d785 100644 --- a/src/main/runtime/rpc/methods/notifications.ts +++ b/src/main/runtime/rpc/methods/notifications.ts @@ -1,6 +1,11 @@ import { z } from 'zod' import { defineStreamingMethod, defineMethod, type RpcAnyMethod } from '../core' +// Why: monotonically increasing per-process counter eliminates the +// Date.now() collision that could fire when two near-simultaneous +// notifications.subscribe calls landed on the same millisecond. +let notificationsSubscriptionSeq = 0 + const NotificationUnsubscribeParams = z.object({ subscriptionId: z .unknown() @@ -16,18 +21,25 @@ export const NOTIFICATION_METHODS: readonly RpcAnyMethod[] = [ defineStreamingMethod({ name: 'notifications.subscribe', params: null, - handler: async (_params, { runtime }, emit) => { + handler: async (_params, { runtime, connectionId }, emit) => { await new Promise((resolve) => { const unsubscribe = runtime.onNotificationDispatched((event) => { emit({ type: 'notification', ...event }) }) - const subscriptionId = `notifications-${Date.now()}` - runtime.registerSubscriptionCleanup(subscriptionId, () => { - unsubscribe() - emit({ type: 'end' }) - resolve() - }) + // Why: scope by per-ws connectionId + per-process counter so + // concurrent subscribes never collide on the cleanup map. + const seq = ++notificationsSubscriptionSeq + const subscriptionId = `notifications-${connectionId ?? 'inproc'}-${seq}` + runtime.registerSubscriptionCleanup( + subscriptionId, + () => { + unsubscribe() + emit({ type: 'end' }) + resolve() + }, + connectionId + ) emit({ type: 'ready', subscriptionId }) }) diff --git a/src/main/runtime/rpc/ws-transport.ts b/src/main/runtime/rpc/ws-transport.ts index 218bdfe2851..2d5881324eb 100644 --- a/src/main/runtime/rpc/ws-transport.ts +++ b/src/main/runtime/rpc/ws-transport.ts @@ -28,7 +28,9 @@ export class WebSocketTransport implements RpcTransport { private messageHandler: | ((msg: string, reply: (response: string) => void, ws: WebSocket) => void) | null = null - private connectionCloseHandler: ((clientId: string) => void) | null = null + private connectionCloseHandler: + | ((clientId: string, ws: WebSocket, hasOtherConnections: boolean) => void) + | null = null // Why: maps each WebSocket to the clientId (deviceToken) that authenticated it, // so ws.on('close') can notify the runtime which mobile client disconnected. private wsClientIds = new Map() @@ -46,7 +48,15 @@ export class WebSocketTransport implements RpcTransport { this.messageHandler = handler } - onConnectionClose(handler: (clientId: string) => void): void { + // Why: handlers receive the closing `ws` so per-connection state can be + // targeted exactly (one paired device may hold multiple concurrent sockets, + // e.g. host screen + accounts screen). `hasOtherConnections` tells the + // runtime whether other sockets for the same deviceToken are still alive, + // so client-scoped teardown (mobile-fit overrides, etc.) only fires on the + // last disconnect. + onConnectionClose( + handler: (clientId: string, ws: WebSocket, hasOtherConnections: boolean) => void + ): void { this.connectionCloseHandler = handler } @@ -178,7 +188,13 @@ export class WebSocketTransport implements RpcTransport { const clientId = this.wsClientIds.get(ws) this.wsClientIds.delete(ws) if (clientId) { - this.connectionCloseHandler?.(clientId) + // Why: a paired device may have multiple concurrent sockets open + // (e.g. one per app screen). Per-client teardown must only fire when + // the last socket for this token closes — otherwise closing the + // accounts-screen socket would clobber the host-screen socket's + // state and strand it in a non-functional state until re-paired. + const hasOtherConnections = Array.from(this.wsClientIds.values()).includes(clientId) + this.connectionCloseHandler?.(clientId, ws, hasOtherConnections) } }) diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index b2e4cf27660..c1f946e6990 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -88,6 +88,10 @@ export class OrcaRuntimeRpcServer { // Why: each WebSocket connection has its own E2EE channel that manages the // handshake and encrypt/decrypt lifecycle. Keyed by WebSocket instance. private e2eeChannels = new Map() + // Why: stable per-WebSocket id used as the cleanup key for streaming + // subscriptions, so the server can reap a closing socket's subscriptions + // without affecting other live sockets that share the same deviceToken. + private wsConnectionIds = new Map() // Why: separate from Node's server.maxConnections because we need to count // only long-running dispatches, not every in-flight short RPC. See §3.1 + // §7 risk #2. @@ -220,6 +224,11 @@ export class OrcaRuntimeRpcServer { wsTransport.onMessage((msg, _reply, ws) => { let channel = this.e2eeChannels.get(ws) if (!channel) { + // Why: stable per-ws id used as the cleanup-index key for + // streaming subscriptions, so the server can reap them exactly + // when this socket closes (without affecting other live sockets + // that share the same deviceToken). + this.wsConnectionIds.set(ws, randomBytes(8).toString('hex')) channel = new E2EEChannel(ws, { serverSecretKey: this.e2eeKeypair!.secretKey, validateToken: (token) => this.deviceRegistry?.validateToken(token) != null, @@ -251,16 +260,28 @@ export class OrcaRuntimeRpcServer { // Why: when a mobile client disconnects, the runtime must clean up // connection-scoped state like mobile-fit overrides and the E2EE - // channel to prevent orphaned state. - wsTransport.onConnectionClose((clientId) => { - for (const [ws, channel] of this.e2eeChannels) { - if (channel.deviceToken === clientId) { - channel.destroy() - this.e2eeChannels.delete(ws) - break - } + // channel to prevent orphaned state. A single paired device can hold + // multiple concurrent sockets (host screen + accounts screen, etc.), + // so destroy the channel for THIS exact ws and skip the per-client + // teardown when other sockets for the same token are still alive. + wsTransport.onConnectionClose((clientId, ws, hasOtherConnections) => { + // Why: sweep streaming subscriptions for THIS ws regardless of + // hasOtherConnections, so per-ws listeners (notifications, + // accounts, terminal) don't leak across reconnects. This is + // independent of the deviceToken-scoped onClientDisconnected. + const connectionId = this.wsConnectionIds.get(ws) + if (connectionId) { + this.runtime.cleanupSubscriptionsForConnection(connectionId) + this.wsConnectionIds.delete(ws) + } + const channel = this.e2eeChannels.get(ws) + if (channel) { + channel.destroy() + this.e2eeChannels.delete(ws) + } + if (!hasOtherConnections) { + this.runtime.onClientDisconnected(clientId) } - this.runtime.onClientDisconnected(clientId) }) await wsTransport.start() @@ -429,7 +450,8 @@ export class OrcaRuntimeRpcServer { wsTransport.setClientId(ws, token) } - await this.dispatcher.dispatchStreaming(request, reply) + const connectionId = ws ? this.wsConnectionIds.get(ws) : undefined + await this.dispatcher.dispatchStreaming(request, reply, { connectionId }) } private buildError(id: string, code: string, message: string): RpcResponse { From 55d3a4207983701474e1f9cb230312c693887d3d Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 5 May 2026 16:09:26 -0700 Subject: [PATCH 011/213] feat(mobile): pairing paste flow + Keychain-backed device tokens (#1452) Co-authored-by: Orca --- mobile/app.json | 4 +- mobile/app/_layout.tsx | 48 +++- mobile/app/pair-confirm.tsx | 217 ++++++++++++++++++ mobile/app/pair-scan.tsx | 117 ++++++++-- mobile/package.json | 1 + mobile/pnpm-lock.yaml | 12 + mobile/src/transport/host-store.ts | 97 ++++++-- mobile/src/transport/pairing.ts | 34 ++- mobile/src/transport/types.ts | 13 ++ src/main/ipc/mobile.ts | 1 + src/preload/api-types.ts | 12 +- src/preload/index.ts | 8 +- .../src/components/settings/MobilePane.tsx | 39 +++- src/shared/pairing.test.ts | 47 +++- src/shared/pairing.ts | 23 +- 15 files changed, 624 insertions(+), 49 deletions(-) create mode 100644 mobile/app/pair-confirm.tsx diff --git a/mobile/app.json b/mobile/app.json index 5bfa5a4b6fb..ecee00a8bfc 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Orca", "slug": "orca-mobile", - "version": "0.0.2", + "version": "0.0.3", "orientation": "portrait", "icon": "./assets/icon.png", "userInterfaceStyle": "automatic", @@ -16,7 +16,7 @@ "ios": { "supportsTablet": true, "bundleIdentifier": "com.stably.orca.mobile", - "buildNumber": "3", + "buildNumber": "4", "infoPlist": { "NSLocalNetworkUsageDescription": "Orca connects to the desktop app on your local network.", "NSAppTransportSecurity": { diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index 742addac1cb..bdae84320c3 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -1,9 +1,10 @@ -import { useCallback } from 'react' +import { useCallback, useEffect } from 'react' import { View, StyleSheet } from 'react-native' -import { Stack } from 'expo-router' +import { Stack, useRouter } from 'expo-router' import { StatusBar } from 'expo-status-bar' import * as SplashScreen from 'expo-splash-screen' import * as Notifications from 'expo-notifications' +import * as Linking from 'expo-linking' import { colors } from '../src/theme/mobile-theme' import { OrcaLogo } from '../src/components/OrcaLogo' @@ -26,7 +27,49 @@ Notifications.setNotificationHandler({ }) }) +// Why: extract the path+payload that follows the orca://pair anchor so we +// can route it to the confirm screen. Accept either a hash payload +// (`orca://pair#`, the QR / shared form) or a query param +// (`orca://pair?code=<...>`, future-proof for share sheets that strip +// fragments). +function extractPairCode(url: string): string | null { + if (!url.startsWith('orca://pair')) return null + const hashIndex = url.indexOf('#') + if (hashIndex !== -1) { + return url.slice(hashIndex + 1) || null + } + const queryIndex = url.indexOf('?') + if (queryIndex !== -1) { + const params = new URLSearchParams(url.slice(queryIndex + 1)) + return params.get('code') + } + return null +} + export default function RootLayout() { + const router = useRouter() + + // Why: route `orca://pair#` deep links to the confirm screen so + // the same pairing flow runs whether the link arrived via QR scan, + // paste, AirDrop, Messages, or `xcrun simctl openurl`. getInitialURL + // covers cold-start (link tapped while app was closed); the listener + // covers warm-start (link tapped while app is in memory). + useEffect(() => { + function handleUrl(url: string) { + const code = extractPairCode(url) + if (code) { + router.push({ pathname: '/pair-confirm', params: { code } }) + } + } + + void Linking.getInitialURL().then((url) => { + if (url) handleUrl(url) + }) + + const sub = Linking.addEventListener('url', ({ url }) => handleUrl(url)) + return () => sub.remove() + }, [router]) + // Why: hide the native splash only once the navigation Stack has been laid // out — this is the earliest moment the user will see actual app content. // Previously the splash hid when a placeholder View rendered, leaving a @@ -55,6 +98,7 @@ export default function RootLayout() { }} /> + diff --git a/mobile/app/pair-confirm.tsx b/mobile/app/pair-confirm.tsx new file mode 100644 index 00000000000..185375eb564 --- /dev/null +++ b/mobile/app/pair-confirm.tsx @@ -0,0 +1,217 @@ +import { useEffect, useState } from 'react' +import { View, Text, StyleSheet, Pressable, ActivityIndicator } from 'react-native' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { useLocalSearchParams, useRouter } from 'expo-router' +import { ChevronLeft } from 'lucide-react-native' +import { parsePairingCode } from '../src/transport/pairing' +import { connect } from '../src/transport/rpc-client' +import { saveHost, getNextHostName } from '../src/transport/host-store' +import type { PairingOffer } from '../src/transport/types' +import { colors, spacing, radii, typography } from '../src/theme/mobile-theme' + +type Status = 'awaiting-confirm' | 'connecting' | 'error' + +export default function PairConfirmScreen() { + const router = useRouter() + const insets = useSafeAreaInsets() + const params = useLocalSearchParams<{ code?: string }>() + const [offer, setOffer] = useState(null) + const [status, setStatus] = useState('awaiting-confirm') + const [errorMessage, setErrorMessage] = useState('') + + useEffect(() => { + if (!params.code) { + setStatus('error') + setErrorMessage('Missing pairing code') + return + } + const parsed = parsePairingCode(params.code) + if (!parsed) { + setStatus('error') + setErrorMessage('Not a valid pairing code') + return + } + setOffer(parsed) + }, [params.code]) + + async function confirm() { + if (!offer) return + setStatus('connecting') + let client: ReturnType | null = null + try { + client = connect(offer.endpoint, offer.deviceToken, offer.publicKeyB64) + const response = await client.sendRequest('status.get') + client.close() + client = null + + if (!response.ok) { + setStatus('error') + setErrorMessage( + response.error.code === 'unauthorized' + ? 'Authentication failed — token may be expired' + : `Server error: ${response.error.message}` + ) + return + } + + const hostId = `host-${Date.now()}` + const hostName = await getNextHostName() + await saveHost({ + id: hostId, + name: hostName, + endpoint: offer.endpoint, + deviceToken: offer.deviceToken, + publicKeyB64: offer.publicKeyB64, + lastConnected: Date.now() + }) + router.replace(`/h/${hostId}`) + } catch { + setStatus('error') + setErrorMessage('Cannot connect — check that your computer is on the same network') + } finally { + client?.close() + } + } + + function cancel() { + router.replace('/') + } + + const containerPadding = { paddingTop: insets.top + spacing.sm } + + return ( + + + + + + + Pair with this desktop? + + {offer && status === 'awaiting-confirm' && ( + <> + + You opened a pairing link from your desktop. Confirm to add it to your hosts. + + + Endpoint + {offer.endpoint} + + void confirm()}> + Pair + + + Cancel + + + )} + + {status === 'connecting' && ( + + + Connecting… + + )} + + {status === 'error' && ( + + {errorMessage} + + Back to home + + + )} + + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.bgBase, + padding: spacing.lg + }, + backButton: { + width: 36, + height: 36, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center', + marginBottom: spacing.sm + }, + content: { + flex: 1, + paddingHorizontal: spacing.sm + }, + title: { + fontSize: typography.titleSize, + fontWeight: '600', + color: colors.textPrimary, + marginBottom: spacing.sm + }, + subtitle: { + fontSize: typography.bodySize, + color: colors.textSecondary, + lineHeight: 20, + marginBottom: spacing.lg + }, + detailsCard: { + backgroundColor: colors.bgPanel, + borderRadius: radii.input, + padding: spacing.md, + marginBottom: spacing.lg + }, + detailsLabel: { + fontSize: 12, + color: colors.textMuted, + marginBottom: 4 + }, + detailsValue: { + fontSize: typography.bodySize, + color: colors.textPrimary, + fontFamily: 'Menlo', + fontWeight: '500' + }, + primaryButton: { + backgroundColor: colors.textPrimary, + paddingHorizontal: spacing.xl, + paddingVertical: spacing.sm + 2, + borderRadius: radii.button, + alignItems: 'center', + marginBottom: spacing.sm + }, + primaryButtonText: { + color: colors.bgBase, + fontSize: typography.bodySize, + fontWeight: '600' + }, + secondaryButton: { + paddingHorizontal: spacing.xl, + paddingVertical: spacing.sm + 2, + borderRadius: radii.button, + alignItems: 'center' + }, + secondaryButtonText: { + color: colors.textSecondary, + fontSize: typography.bodySize, + fontWeight: '500' + }, + centered: { + flex: 1, + alignItems: 'center', + justifyContent: 'center' + }, + connectingText: { + color: colors.textSecondary, + fontSize: typography.bodySize, + marginTop: spacing.lg + }, + errorText: { + color: colors.statusRed, + fontSize: typography.bodySize, + textAlign: 'center', + marginBottom: spacing.xl, + lineHeight: 20 + } +}) diff --git a/mobile/app/pair-scan.tsx b/mobile/app/pair-scan.tsx index ca522e7ddae..38944f88c0a 100644 --- a/mobile/app/pair-scan.tsx +++ b/mobile/app/pair-scan.tsx @@ -3,12 +3,13 @@ import { View, Text, StyleSheet, Pressable, ActivityIndicator } from 'react-nati import { useSafeAreaInsets } from 'react-native-safe-area-context' import { CameraView, useCameraPermissions } from 'expo-camera' import { useRouter } from 'expo-router' -import { ChevronLeft } from 'lucide-react-native' -import { decodePairingUrl } from '../src/transport/pairing' +import { ChevronLeft, Clipboard as ClipboardIcon } from 'lucide-react-native' +import { decodePairingUrl, parsePairingCode } from '../src/transport/pairing' import { connect } from '../src/transport/rpc-client' import { saveHost, getNextHostName } from '../src/transport/host-store' import type { PairingOffer } from '../src/transport/types' import { colors, spacing, radii, typography } from '../src/theme/mobile-theme' +import { TextInputModal } from '../src/components/TextInputModal' function Step({ number, text }: { number: number; text: string }) { return ( @@ -27,6 +28,7 @@ export default function PairScanScreen() { const [permission, requestPermission] = useCameraPermissions() const [status, setStatus] = useState<'scanning' | 'connecting' | 'error'>('scanning') const [errorMessage, setErrorMessage] = useState('') + const [pasteVisible, setPasteVisible] = useState(false) const processingRef = useRef(false) const handleBarCodeScanned = useCallback( @@ -47,6 +49,22 @@ export default function PairScanScreen() { [router] ) + const handlePasteSubmit = useCallback((input: string) => { + setPasteVisible(false) + if (processingRef.current) return + processingRef.current = true + + const offer = parsePairingCode(input) + if (!offer) { + setStatus('error') + setErrorMessage('Not a valid pairing code — copy it from your computer and paste again') + processingRef.current = false + return + } + + void testAndSave(offer) + }, []) + async function testAndSave(offer: PairingOffer) { setStatus('connecting') let client: ReturnType | null = null @@ -140,20 +158,29 @@ export default function PairScanScreen() { {status === 'scanning' && ( - - - - - - - + <> + + + + + + + + - + [styles.pasteButton, pressed && styles.pasteButtonPressed]} + onPress={() => setPasteVisible(true)} + > + + Or paste pairing code + + )} {status === 'connecting' && ( @@ -166,11 +193,34 @@ export default function PairScanScreen() { {status === 'error' && ( {errorMessage} - - Try Again - + + + Try Again + + [ + styles.secondaryButton, + pressed && styles.pasteButtonPressed + ]} + onPress={() => { + retry() + setPasteVisible(true) + }} + > + Paste code instead + + )} + + setPasteVisible(false)} + /> ) } @@ -303,5 +353,36 @@ const styles = StyleSheet.create({ color: colors.bgBase, fontSize: typography.bodySize, fontWeight: '600' + }, + pasteButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.xs, + marginTop: spacing.md, + paddingVertical: spacing.sm, + borderRadius: radii.button + }, + pasteButtonPressed: { + opacity: 0.6 + }, + pasteButtonText: { + color: colors.textSecondary, + fontSize: typography.bodySize, + fontWeight: '500' + }, + errorActions: { + alignItems: 'center', + gap: spacing.sm + }, + secondaryButton: { + paddingHorizontal: spacing.xl, + paddingVertical: spacing.sm, + borderRadius: radii.button + }, + secondaryButtonText: { + color: colors.textSecondary, + fontSize: typography.bodySize, + fontWeight: '500' } }) diff --git a/mobile/package.json b/mobile/package.json index 6006461c802..02fa1bf57d0 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -22,6 +22,7 @@ "expo-linking": "^55.0.14", "expo-notifications": "^55.0.21", "expo-router": "^55.0.13", + "expo-secure-store": "^55.0.13", "expo-splash-screen": "^55.0.19", "expo-status-bar": "^55.0.5", "lucide-react-native": "^1.11.0", diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml index 11f0e80157f..9a346120f2a 100644 --- a/mobile/pnpm-lock.yaml +++ b/mobile/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: expo-router: specifier: ^55.0.13 version: 55.0.13(121105c3b042d5e83ab3c1d3b84ed55f) + expo-secure-store: + specifier: ^55.0.13 + version: 55.0.13(expo@55.0.17) expo-splash-screen: specifier: ^55.0.19 version: 55.0.19(expo@55.0.17)(typescript@5.9.3) @@ -3234,6 +3237,11 @@ packages: react-server-dom-webpack: optional: true + expo-secure-store@55.0.13: + resolution: {integrity: sha512-I6r0JNO1Fd4o0Gu7Ixiic7s89lqgdUHq17uBH9y1f/AntoyKn71TdtYJH82RgfsBbu5qNVzrwImmvlANyOlITQ==} + peerDependencies: + expo: '*' + expo-server@55.0.8: resolution: {integrity: sha512-AoV5TKuO4biSzrhe/OVLyInfTT0pV9/OOc/g/oVq5vmCjL8SaSYTkES8PLt+67Tm7VqX+Dn0+kSx1nQcjEKaPw==} engines: {node: '>=20.16.0'} @@ -9565,6 +9573,10 @@ snapshots: - expo-font - supports-color + expo-secure-store@55.0.13(expo@55.0.17): + dependencies: + expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo-server@55.0.8: {} expo-splash-screen@55.0.19(expo@55.0.17)(typescript@5.9.3): diff --git a/mobile/src/transport/host-store.ts b/mobile/src/transport/host-store.ts index 7f35fb742a2..3677c902f36 100644 --- a/mobile/src/transport/host-store.ts +++ b/mobile/src/transport/host-store.ts @@ -1,16 +1,73 @@ import AsyncStorage from '@react-native-async-storage/async-storage' -import { HostProfileSchema, type HostProfile } from './types' +import * as SecureStore from 'expo-secure-store' +import { + HostProfileSchema, + StoredHostProfileSchema, + type HostProfile, + type StoredHostProfile +} from './types' const STORAGE_KEY = 'orca:hosts' +const TOKEN_KEY_PREFIX = 'orca:host-token:' + +// Why: WHEN_UNLOCKED_THIS_DEVICE_ONLY keeps the pairing token off +// iCloud Keychain and out of iCloud/iTunes backup restores onto a +// different physical device. Reads/writes are silent (no biometric +// prompt) since we don't request access control flags. +const KEYCHAIN_OPTIONS: SecureStore.SecureStoreOptions = { + keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY +} + +function tokenKey(hostId: string): string { + return `${TOKEN_KEY_PREFIX}${hostId}` +} export async function loadHosts(): Promise { + const raw = await AsyncStorage.getItem(STORAGE_KEY) + if (!raw) return [] + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + return [] + } + if (!Array.isArray(parsed)) return [] + + const out: HostProfile[] = [] + for (const item of parsed) { + // Why: pre-v0.0.3 records carry the deviceToken in AsyncStorage. + // Drop them silently — the three pre-launch users will re-pair on + // first run rather than carry a migration shim through the auth + // path. + if (item && typeof item === 'object' && 'deviceToken' in item) { + continue + } + const stored = StoredHostProfileSchema.safeParse(item) + if (!stored.success) continue + + const token = await SecureStore.getItemAsync(tokenKey(stored.data.id), KEYCHAIN_OPTIONS) + if (!token) { + // Why: orphaned metadata with no matching keychain entry — most + // likely a stale record from a development install. Skip it + // rather than surface a half-broken host. + continue + } + out.push({ ...stored.data, deviceToken: token }) + } + return out +} + +async function loadStoredHosts(): Promise { const raw = await AsyncStorage.getItem(STORAGE_KEY) if (!raw) return [] try { const parsed = JSON.parse(raw) as unknown if (!Array.isArray(parsed)) return [] return parsed.flatMap((item) => { - const result = HostProfileSchema.safeParse(item) + // Why: same drop-old-records rule as loadHosts; keeps internal + // mutators from re-persisting pre-v0.0.3 entries. + if (item && typeof item === 'object' && 'deviceToken' in item) return [] + const result = StoredHostProfileSchema.safeParse(item) return result.success ? [result.data] : [] }) } catch { @@ -18,25 +75,39 @@ export async function loadHosts(): Promise { } } -export async function saveHost(host: HostProfile): Promise { - const hosts = await loadHosts() - const index = hosts.findIndex((h) => h.id === host.id) - if (index >= 0) { - hosts[index] = host - } else { - hosts.push(host) +function toStored(host: HostProfile): StoredHostProfile { + return { + id: host.id, + name: host.name, + endpoint: host.endpoint, + publicKeyB64: host.publicKeyB64, + lastConnected: host.lastConnected } +} + +export async function saveHost(host: HostProfile): Promise { + const validated = HostProfileSchema.parse(host) + const hosts = await loadStoredHosts() + const stored = toStored(validated) + const index = hosts.findIndex((h) => h.id === stored.id) + if (index >= 0) { + hosts[index] = stored + } else { + hosts.push(stored) + } + await SecureStore.setItemAsync(tokenKey(stored.id), validated.deviceToken, KEYCHAIN_OPTIONS) await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(hosts)) } export async function removeHost(hostId: string): Promise { - const hosts = await loadHosts() + const hosts = await loadStoredHosts() const filtered = hosts.filter((h) => h.id !== hostId) await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(filtered)) + await SecureStore.deleteItemAsync(tokenKey(hostId), KEYCHAIN_OPTIONS) } export async function renameHost(hostId: string, newName: string): Promise { - const hosts = await loadHosts() + const hosts = await loadStoredHosts() const host = hosts.find((h) => h.id === hostId) if (host) { host.name = newName @@ -45,7 +116,7 @@ export async function renameHost(hostId: string, newName: string): Promise } export async function getNextHostName(): Promise { - const hosts = await loadHosts() + const hosts = await loadStoredHosts() const existingNumbers = hosts .map((h) => { const match = h.name.match(/^Host (\d+)$/) @@ -57,7 +128,7 @@ export async function getNextHostName(): Promise { } export async function updateLastConnected(hostId: string): Promise { - const hosts = await loadHosts() + const hosts = await loadStoredHosts() const host = hosts.find((h) => h.id === hostId) if (host) { host.lastConnected = Date.now() diff --git a/mobile/src/transport/pairing.ts b/mobile/src/transport/pairing.ts index ada3e2715eb..2b54f4e58db 100644 --- a/mobile/src/transport/pairing.ts +++ b/mobile/src/transport/pairing.ts @@ -1,16 +1,38 @@ import { PairingOfferSchema, type PairingOffer } from './types' +// Why: this file mirrors src/shared/pairing.ts (which is covered by CI +// vitest) but uses atob/btoa because Metro/Hermes don't ship Node's +// Buffer. Keep the parsing semantics in sync — when one changes, update +// the other. + export function decodePairingUrl(url: string): PairingOffer | null { try { const hashIndex = url.indexOf('#') if (!url.startsWith('orca://pair') || hashIndex === -1) return null - - const base64url = url.slice(hashIndex + 1) - const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/') - const json = atob(base64) - const parsed = JSON.parse(json) - return PairingOfferSchema.parse(parsed) + return decodePairingBase64(url.slice(hashIndex + 1)) } catch { return null } } + +// Why: accept either an `orca://pair#` URL or the bare base64 +// string so the paste-pair flow can take whichever the user actually +// copied from desktop. +export function parsePairingCode(input: string): PairingOffer | null { + const trimmed = input.trim() + if (!trimmed) return null + try { + if (trimmed.startsWith('orca://pair')) { + return decodePairingUrl(trimmed) + } + return decodePairingBase64(trimmed) + } catch { + return null + } +} + +function decodePairingBase64(base64url: string): PairingOffer { + const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/') + const json = atob(base64) + return PairingOfferSchema.parse(JSON.parse(json)) +} diff --git a/mobile/src/transport/types.ts b/mobile/src/transport/types.ts index c73489a9751..2e9e8fcf7ff 100644 --- a/mobile/src/transport/types.ts +++ b/mobile/src/transport/types.ts @@ -60,3 +60,16 @@ export const HostProfileSchema = z.object({ publicKeyB64: z.string().min(1), lastConnected: z.number().finite() }) + +// Why: persisted host record after the v0.0.3 keychain split. The +// deviceToken is held in iOS Keychain via expo-secure-store and joined +// in at load time; it must NOT appear in AsyncStorage anymore. +export const StoredHostProfileSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + endpoint: z.string().min(1), + publicKeyB64: z.string().min(1), + lastConnected: z.number().finite() +}) + +export type StoredHostProfile = z.infer diff --git a/src/main/ipc/mobile.ts b/src/main/ipc/mobile.ts index c7ee87d07ee..528749c29b5 100644 --- a/src/main/ipc/mobile.ts +++ b/src/main/ipc/mobile.ts @@ -82,6 +82,7 @@ export function registerMobileHandlers(rpcServer: OrcaRuntimeRpcServer): void { return { available: true as const, qrDataUrl, + pairingUrl: url, endpoint, deviceId: device.deviceId } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index d34889c47c0..50dc624ebb7 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1172,11 +1172,15 @@ export type PreloadApi = { listNetworkInterfaces: () => Promise<{ interfaces: { name: string; address: string }[] }> - getPairingQR: (args?: { - address?: string - }) => Promise< + getPairingQR: (args?: { address?: string }) => Promise< | { available: false } - | { available: true; qrDataUrl: string; endpoint: string; deviceId: string } + | { + available: true + qrDataUrl: string + pairingUrl: string + endpoint: string + deviceId: string + } > listDevices: () => Promise<{ devices: { deviceId: string; name: string; pairedAt: number; lastSeenAt: number }[] diff --git a/src/preload/index.ts b/src/preload/index.ts index e95b70a6872..db9f0d01664 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2135,7 +2135,13 @@ const api = { address?: string }): Promise< | { available: false } - | { available: true; qrDataUrl: string; endpoint: string; deviceId: string } + | { + available: true + qrDataUrl: string + pairingUrl: string + endpoint: string + deviceId: string + } > => ipcRenderer.invoke('mobile:getPairingQR', args), listDevices: (): Promise<{ diff --git a/src/renderer/src/components/settings/MobilePane.tsx b/src/renderer/src/components/settings/MobilePane.tsx index 2dcc31ee03e..acab65da46c 100644 --- a/src/renderer/src/components/settings/MobilePane.tsx +++ b/src/renderer/src/components/settings/MobilePane.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useState } from 'react' import { toast } from 'sonner' -import { Maximize2, RefreshCw, Trash2, Wifi } from 'lucide-react' +import { Check, Copy, Maximize2, RefreshCw, Trash2, Wifi } from 'lucide-react' import { Button } from '../ui/button' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../ui/dialog' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' @@ -38,12 +38,14 @@ type NetworkInterface = { export function MobilePane(): React.JSX.Element { const [qrDataUrl, setQrDataUrl] = useState(null) + const [pairingUrl, setPairingUrl] = useState(null) const [endpoint, setEndpoint] = useState(null) const [loading, setLoading] = useState(false) const [devices, setDevices] = useState([]) const [qrEnlarged, setQrEnlarged] = useState(false) const [networkInterfaces, setNetworkInterfaces] = useState([]) const [selectedAddress, setSelectedAddress] = useState(undefined) + const [codeCopied, setCodeCopied] = useState(false) const loadDevices = useCallback(async () => { try { @@ -74,7 +76,9 @@ export function MobilePane(): React.JSX.Element { ) if (result.available) { setQrDataUrl(result.qrDataUrl) + setPairingUrl(result.pairingUrl) setEndpoint(result.endpoint) + setCodeCopied(false) void loadDevices() } else { toast.error('WebSocket transport is not running') @@ -110,6 +114,19 @@ export function MobilePane(): React.JSX.Element { return () => clearInterval(interval) }, [deviceCountAtQr, devices.length, loadDevices]) + async function copyPairingCode() { + if (!pairingUrl) { + return + } + try { + await navigator.clipboard.writeText(pairingUrl) + setCodeCopied(true) + setTimeout(() => setCodeCopied(false), 2000) + } catch { + toast.error('Failed to copy pairing code') + } + } + async function revokeDevice(deviceId: string) { try { await window.api.mobile.revokeDevice({ deviceId }) @@ -177,6 +194,26 @@ export function MobilePane(): React.JSX.Element {

Scan this code with the Orca mobile app. Each code creates a unique device token.

+ {pairingUrl && ( +
+
+ Or paste this code in the mobile app: +
+ +
+ )} )} diff --git a/src/shared/pairing.test.ts b/src/shared/pairing.test.ts index de0738b5907..cf469f6ed4b 100644 --- a/src/shared/pairing.test.ts +++ b/src/shared/pairing.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest' -import { encodePairingOffer, decodePairingOffer, type PairingOffer } from './pairing' +import { + encodePairingOffer, + decodePairingOffer, + parsePairingCode, + type PairingOffer +} from './pairing' describe('pairing offer', () => { const offer: PairingOffer = { @@ -49,3 +54,43 @@ describe('pairing offer', () => { expect(() => decodePairingOffer(`orca://pair#${base64}`)).toThrow() }) }) + +describe('parsePairingCode', () => { + const offer: PairingOffer = { + v: 2, + endpoint: 'ws://192.168.1.10:6768', + deviceToken: 'token-abc', + publicKeyB64: 'pubkey-xyz' + } + + it('parses a full orca://pair# URL', () => { + const url = encodePairingOffer(offer) + expect(parsePairingCode(url)).toEqual(offer) + }) + + it('parses a bare base64url payload (without scheme prefix)', () => { + const url = encodePairingOffer(offer) + const base64url = url.split('#')[1]! + expect(parsePairingCode(base64url)).toEqual(offer) + }) + + it('tolerates surrounding whitespace from clipboard', () => { + const url = encodePairingOffer(offer) + expect(parsePairingCode(` ${url}\n`)).toEqual(offer) + }) + + it('returns null for empty input', () => { + expect(parsePairingCode('')).toBeNull() + expect(parsePairingCode(' ')).toBeNull() + }) + + it('returns null for garbage input', () => { + expect(parsePairingCode('not a pairing code')).toBeNull() + expect(parsePairingCode('https://example.com')).toBeNull() + }) + + it('returns null for valid base64 of unrelated JSON', () => { + const bogus = Buffer.from(JSON.stringify({ hello: 'world' })).toString('base64') + expect(parsePairingCode(bogus)).toBeNull() + }) +}) diff --git a/src/shared/pairing.ts b/src/shared/pairing.ts index 5f8d4afc3b4..4e9b5beba80 100644 --- a/src/shared/pairing.ts +++ b/src/shared/pairing.ts @@ -28,7 +28,28 @@ export function decodePairingOffer(url: string): PairingOffer { if (!url.startsWith('orca://pair') || hashIndex === -1) { throw new Error('Invalid pairing URL: must start with orca://pair#') } - const base64url = url.slice(hashIndex + 1) + return decodePairingBase64(url.slice(hashIndex + 1)) +} + +// Why: accept either an `orca://pair#` URL or the bare base64 +// string so the mobile paste-pair flow can take whichever the user +// actually copied from desktop. +export function parsePairingCode(input: string): PairingOffer | null { + const trimmed = input.trim() + if (!trimmed) { + return null + } + try { + if (trimmed.startsWith('orca://pair')) { + return decodePairingOffer(trimmed) + } + return decodePairingBase64(trimmed) + } catch { + return null + } +} + +function decodePairingBase64(base64url: string): PairingOffer { const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/') const json = Buffer.from(base64, 'base64').toString('utf-8') return PairingOfferSchema.parse(JSON.parse(json)) From 8de43cd3b47d6eda1c4947ef304dcfdb802ee2ba Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 5 May 2026 16:17:51 -0700 Subject: [PATCH 012/213] fix(sidebar): stamp lastActivityAt on first worktree discovery (#905) Co-authored-by: Orca --- src/main/ipc/worktree-logic.ts | 1 + src/main/ipc/worktree-remote.ts | 16 ++- src/main/ipc/worktrees.test.ts | 130 ++++++++++++++++++ src/main/ipc/worktrees.ts | 18 ++- src/main/runtime/orca-runtime.test.ts | 48 +++++++ src/main/runtime/orca-runtime.ts | 8 +- .../src/components/sidebar/smart-sort.test.ts | 108 ++++++++++++++- .../src/components/sidebar/smart-sort.ts | 57 ++++++-- src/shared/types.ts | 7 + tests/e2e/worktree-recent-sort.spec.ts | 119 ++++++++++++++++ 10 files changed, 497 insertions(+), 15 deletions(-) create mode 100644 tests/e2e/worktree-recent-sort.spec.ts diff --git a/src/main/ipc/worktree-logic.ts b/src/main/ipc/worktree-logic.ts index f4fc0e292b8..d6472413bf3 100644 --- a/src/main/ipc/worktree-logic.ts +++ b/src/main/ipc/worktree-logic.ts @@ -172,6 +172,7 @@ export function mergeWorktree( isPinned: meta?.isPinned ?? false, sortOrder: meta?.sortOrder ?? 0, lastActivityAt: meta?.lastActivityAt ?? 0, + ...(meta?.createdAt !== undefined ? { createdAt: meta.createdAt } : {}), ...(git.isSparse === true ? { sparseDirectories: meta?.sparseDirectories, diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index cb37a482ae4..416206d2687 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -204,8 +204,16 @@ export async function createRemoteWorktree( } const worktreeId = `${repo.id}::${created.path}` + const now = Date.now() const metaUpdates: Partial = { - lastActivityAt: Date.now(), + lastActivityAt: now, + // Why: grants the new worktree a short grace window at the top of the + // Recent sort. During worktree creation (git fetch + add can take several + // seconds) other worktrees get ambient PTY bumps that would otherwise + // leave the newly-created one below them; the Recent comparator uses + // max(lastActivityAt, createdAt + GRACE_MS) to keep it on top until the + // window elapses. See smart-sort.ts `CREATE_GRACE_MS`. + createdAt: now, ...(shouldSetDisplayName(requestedName, branchName, sanitizedName) ? { displayName: requestedName } : {}) @@ -434,11 +442,15 @@ export async function createLocalWorktree( } const worktreeId = `${repo.id}::${created.path}` + const now = Date.now() const metaUpdates: Partial = { // Stamp activity so the worktree sorts into its final position // immediately — prevents scroll-to-reveal racing with a later // bumpWorktreeActivity that would re-sort the list. - lastActivityAt: Date.now(), + lastActivityAt: now, + // See createRemoteWorktree above: createdAt protects the newly-created + // worktree from ambient PTY bumps in other worktrees for CREATE_GRACE_MS. + createdAt: now, ...(shouldSetDisplayName(effectiveRequestedName, branchName, effectiveSanitizedName) ? { displayName: effectiveRequestedName } : {}), diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 12547caecde..e2e055f0866 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -378,6 +378,136 @@ describe('registerWorktreeHandlers', () => { expect(listWorktreesMock).not.toHaveBeenCalled() }) + it('stamps lastActivityAt on first discovery so newly-added worktrees sort to the top of Recent', async () => { + // Why: a worktree that exists on disk but has no persisted WorktreeMeta + // (e.g. a folder repo just added, or a pre-existing worktree in a + // newly-added git repo) would otherwise fall back to `lastActivityAt: 0` + // and rank dead last in the Recent sort. + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/discovered-wt', + head: 'abc123', + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false + } + ]) + store.getWorktreeMeta.mockReturnValue(undefined) + const stampedMeta = { lastActivityAt: 1_700_000_000_000 } + store.setWorktreeMeta.mockReturnValue(stampedMeta) + + const listed = (await handlers['worktrees:list'](null, { repoId: 'repo-1' })) as { + id: string + lastActivityAt: number + }[] + + expect(store.setWorktreeMeta).toHaveBeenCalledWith( + 'repo-1::/workspace/discovered-wt', + expect.objectContaining({ lastActivityAt: expect.any(Number) }) + ) + expect(listed[0]).toMatchObject({ + id: 'repo-1::/workspace/discovered-wt', + lastActivityAt: 1_700_000_000_000 + }) + }) + + it('does not re-stamp lastActivityAt when a worktree already has persisted meta', async () => { + // Why: only the *first* discovery should stamp. Re-stamping on every list + // would overwrite real activity and reshuffle the sidebar on refresh. + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/existing-wt', + head: 'abc123', + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false + } + ]) + store.getWorktreeMeta.mockReturnValue({ + displayName: '', + comment: '', + linkedIssue: null, + linkedPR: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 42 + }) + + const listed = (await handlers['worktrees:list'](null, { repoId: 'repo-1' })) as { + id: string + lastActivityAt: number + }[] + + expect(store.setWorktreeMeta).not.toHaveBeenCalled() + expect(listed[0].lastActivityAt).toBe(42) + }) + + it('stamps lastActivityAt on first discovery for folder-mode repos', async () => { + // Why: folder repos produce a synthetic worktree that flows through the + // same list path. Without the stamp, adding a folder puts its card at the + // bottom of Recent even though the user just added it. + store.getRepos.mockReturnValue([ + { + id: 'repo-1', + path: '/workspace/folder', + displayName: 'folder', + badgeColor: '#000', + addedAt: 0, + kind: 'folder' + } + ]) + store.getRepo.mockReturnValue({ + id: 'repo-1', + path: '/workspace/folder', + displayName: 'folder', + badgeColor: '#000', + addedAt: 0, + kind: 'folder' + }) + store.getWorktreeMeta.mockReturnValue(undefined) + store.setWorktreeMeta.mockReturnValue({ lastActivityAt: 1_700_000_000_000 }) + + await handlers['worktrees:list'](null, { repoId: 'repo-1' }) + + expect(store.setWorktreeMeta).toHaveBeenCalledWith( + 'repo-1::/workspace/folder', + expect.objectContaining({ lastActivityAt: expect.any(Number) }) + ) + }) + + it('stamps lastActivityAt on first discovery via worktrees:listAll', async () => { + // Why: the stamping logic lives in both worktrees:list and worktrees:listAll. + // Without a dedicated test, a regression in the listAll loop would silently + // bury newly-discovered worktrees from the multi-repo sidebar view. + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/discovered-wt', + head: 'abc123', + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false + } + ]) + store.getWorktreeMeta.mockReturnValue(undefined) + store.setWorktreeMeta.mockReturnValue({ lastActivityAt: 1_700_000_000_000 }) + + const listed = (await handlers['worktrees:listAll'](null, undefined)) as { + id: string + lastActivityAt: number + }[] + + expect(store.setWorktreeMeta).toHaveBeenCalledWith( + 'repo-1::/workspace/discovered-wt', + expect.objectContaining({ lastActivityAt: expect.any(Number) }) + ) + expect(listed[0]).toMatchObject({ + id: 'repo-1::/workspace/discovered-wt', + lastActivityAt: 1_700_000_000_000 + }) + }) + it('skips past a suffix that already belongs to a PR after an initial branch conflict', async () => { // Why: `gh pr list` is network-bound and previously fired on every single // create, adding 1–3s to the happy path. We now only probe PR conflicts diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index f2c0b806f56..77274212631 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -41,6 +41,20 @@ import { removeWorktreeSymlinks } from './worktree-symlinks' import { track } from '../telemetry/client' import { workspaceSourceSchema, type WorkspaceSource } from '../../shared/telemetry-events' +// Why: worktrees discovered on disk (not created via Orca's UI) have no +// persisted WorktreeMeta, so mergeWorktree falls back to `lastActivityAt: 0`. +// That makes them sort to the bottom of "Recent" even though the user just +// added the repo / folder. Stamp discovery time the first time we see a +// worktree so its very existence counts as a recency signal. Subsequent +// list calls find the persisted meta and skip the stamp. +function resolveWorktreeMetaWithDiscoveryStamp(store: Store, worktreeId: string): WorktreeMeta { + const existing = store.getWorktreeMeta(worktreeId) + if (existing) { + return existing + } + return store.setWorktreeMeta(worktreeId, { lastActivityAt: Date.now() }) +} + export function registerWorktreeHandlers( mainWindow: BrowserWindow, store: Store, @@ -86,7 +100,7 @@ export function registerWorktreeHandlers( } return gitWorktrees.map((gw) => { const worktreeId = `${repo.id}::${gw.path}` - const meta = store.getWorktreeMeta(worktreeId) + const meta = resolveWorktreeMetaWithDiscoveryStamp(store, worktreeId) return mergeWorktree(repo.id, gw, meta, repo.displayName) }) } catch { @@ -128,7 +142,7 @@ export function registerWorktreeHandlers( } return gitWorktrees.map((gw) => { const worktreeId = `${repo.id}::${gw.path}` - const meta = store.getWorktreeMeta(worktreeId) + const meta = resolveWorktreeMetaWithDiscoveryStamp(store, worktreeId) return mergeWorktree(repo.id, gw, meta, repo.displayName) }) }) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index d4d67b91721..20d66814f9c 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -1104,6 +1104,54 @@ describe('OrcaRuntimeService', () => { expect(activateWorktree).toHaveBeenCalledWith('repo-1', expect.any(String), undefined) }) + it('stamps createdAt alongside lastActivityAt so CLI-created worktrees get the Recent-sort grace window', async () => { + // Why: parity with createLocalWorktree / createRemoteWorktree. Without + // createdAt, ambient PTY bumps in OTHER worktrees during the few seconds + // after creation can push the new worktree below them in Recent sort. + const runtime = new OrcaRuntimeService(store) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + runtime.attachWindow(1) + + computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-grace') + ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-grace') + vi.mocked(getEffectiveHooks).mockReturnValue({ scripts: {} }) + vi.mocked(listWorktrees).mockResolvedValueOnce([ + { + path: '/tmp/workspaces/runtime-grace', + head: 'def', + branch: 'runtime-grace', + isBare: false, + isMainWorktree: false + } + ]) + + const before = Date.now() + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'runtime-grace' + }) + const after = Date.now() + + expect(result.worktree.createdAt).toBeDefined() + expect(result.worktree.createdAt).toBeGreaterThanOrEqual(before) + expect(result.worktree.createdAt).toBeLessThanOrEqual(after) + // Both fields must be stamped from the same `now` so the grace-window + // math (max(lastActivityAt, createdAt + GRACE_MS)) is well-defined. + expect(result.worktree.createdAt).toBe(result.worktree.lastActivityAt) + }) + it('skips archive hooks for CLI worktree removal by default', async () => { const runtime = new OrcaRuntimeService(store) vi.mocked(getEffectiveHooks).mockReturnValue({ diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 8ea72e381a3..9e50759173c 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -2967,8 +2967,14 @@ export class OrcaRuntimeService { } const worktreeId = `${repo.id}::${created.path}` + const now = Date.now() const meta = this.store.setWorktreeMeta(worktreeId, { - lastActivityAt: Date.now(), + lastActivityAt: now, + // See createRemoteWorktree: createdAt grants the new worktree a grace + // window in Recent sort so ambient PTY bumps in OTHER worktrees can't + // push it down before the user has had a chance to notice it. Smart-sort + // uses max(lastActivityAt, createdAt + CREATE_GRACE_MS). + createdAt: now, ...(shouldSetDisplayName(requestedName, branchName, sanitizedName) ? { displayName: requestedName } : {}), diff --git a/src/renderer/src/components/sidebar/smart-sort.test.ts b/src/renderer/src/components/sidebar/smart-sort.test.ts index dc470ce89eb..b56f9c2c0db 100644 --- a/src/renderer/src/components/sidebar/smart-sort.test.ts +++ b/src/renderer/src/components/sidebar/smart-sort.test.ts @@ -1,7 +1,13 @@ /* eslint-disable max-lines */ import { afterEach, describe, expect, it, vi } from 'vitest' import type { Repo, TerminalTab, Worktree } from '../../../../shared/types' -import { buildWorktreeComparator, computeSmartScore, type SmartSortOverride } from './smart-sort' +import { + buildWorktreeComparator, + computeSmartScore, + CREATE_GRACE_MS, + effectiveRecentActivity, + type SmartSortOverride +} from './smart-sort' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' const NOW = new Date('2026-03-27T12:00:00.000Z').getTime() @@ -37,7 +43,8 @@ function makeWorktree(overrides: Partial = {}): Worktree { isPinned: overrides.isPinned ?? false, displayName: overrides.displayName ?? overrides.id ?? 'wt-1', sortOrder: overrides.sortOrder ?? 0, - lastActivityAt: overrides.lastActivityAt ?? 0 + lastActivityAt: overrides.lastActivityAt ?? 0, + ...(overrides.createdAt !== undefined ? { createdAt: overrides.createdAt } : {}) } } @@ -511,3 +518,100 @@ describe('buildWorktreeComparator — recent (lastActivityAt)', () => { expect(worktrees.map((w) => w.id)).toEqual(['fresh-active', 'stale-high-order']) }) }) + +describe('effectiveRecentActivity — create-grace floor', () => { + it('returns lastActivityAt when createdAt is absent', () => { + const wt = makeWorktree({ id: 'old', lastActivityAt: 12345 }) + expect(effectiveRecentActivity(wt, NOW)).toBe(12345) + }) + + it('returns createdAt + CREATE_GRACE_MS when grace window exceeds lastActivityAt', () => { + const wt = makeWorktree({ id: 'fresh', lastActivityAt: NOW, createdAt: NOW }) + expect(effectiveRecentActivity(wt, NOW)).toBe(NOW + CREATE_GRACE_MS) + }) + + it('returns lastActivityAt when grace window has elapsed', () => { + const wt = makeWorktree({ + id: 'post-grace', + createdAt: NOW - CREATE_GRACE_MS - 60_000, + lastActivityAt: NOW - 1000 + }) + expect(effectiveRecentActivity(wt, NOW)).toBe(NOW - 1000) + }) + + it('returns lastActivityAt when real activity has surpassed the grace floor', () => { + // A user who interacted 3 minutes after create has lastActivityAt > createdAt + 3min, + // but createdAt + 5min still wins for the next 2 minutes. + const createdAt = NOW - 3 * 60 * 1000 + const wt = makeWorktree({ id: 'used', createdAt, lastActivityAt: NOW - 60_000 }) + // createdAt + GRACE_MS = NOW + 2min, which exceeds lastActivityAt (NOW - 1min). + expect(effectiveRecentActivity(wt, NOW)).toBe(createdAt + CREATE_GRACE_MS) + }) + + it('returns lastActivityAt once the grace window has elapsed even when no other activity has occurred', () => { + // Bug-fix case: a worktree created days ago that was never touched after + // creation. Without the time-bound check, the floor would still apply and + // the worktree would rank as `createdAt + 5min` forever, masking truly + // fresher worktrees. + const createdAt = NOW - CREATE_GRACE_MS - 1 + const wt = makeWorktree({ id: 'untouched', createdAt, lastActivityAt: createdAt }) + expect(effectiveRecentActivity(wt, NOW)).toBe(createdAt) + }) +}) + +describe('buildWorktreeComparator — recent with createdAt grace window', () => { + it('keeps a newly-created worktree on top even when another worktree bumps lastActivityAt', () => { + // Simulates the bug: user creates a worktree at t=0, then an ambient PTY + // bump on a different worktree lands at t=+100ms. Without the grace + // window, the bumped worktree would outrank the new one by 100ms. + const newWorktree = makeWorktree({ + id: 'new', + displayName: 'New', + createdAt: NOW, + lastActivityAt: NOW + }) + const bumpedByAmbient = makeWorktree({ + id: 'bumped', + displayName: 'Bumped', + lastActivityAt: NOW + 100 + }) + const worktrees = [bumpedByAmbient, newWorktree] + + worktrees.sort(buildWorktreeComparator('recent', null, repoMap, null, NOW)) + + expect(worktrees.map((w) => w.id)).toEqual(['new', 'bumped']) + }) + + it('falls through to normal recency once the grace window has elapsed', () => { + const oldCreated = makeWorktree({ + id: 'old-created', + displayName: 'Old created', + // Created longer ago than GRACE_MS so the floor has expired. + createdAt: NOW - CREATE_GRACE_MS - 10_000, + lastActivityAt: NOW - 30_000 + }) + const freshActivity = makeWorktree({ + id: 'fresh-activity', + displayName: 'Fresh activity', + // No createdAt (discovered on disk), but has recent real activity. + lastActivityAt: NOW - 1000 + }) + const worktrees = [oldCreated, freshActivity] + + worktrees.sort(buildWorktreeComparator('recent', null, repoMap, null, NOW)) + + expect(worktrees.map((w) => w.id)).toEqual(['fresh-activity', 'old-created']) + }) + + it('does not disturb ranking for worktrees without createdAt', () => { + // All existing worktrees (persisted before createdAt field existed) stay + // sorted by lastActivityAt alone. + const alpha = makeWorktree({ id: 'alpha', displayName: 'Alpha', lastActivityAt: 5000 }) + const bravo = makeWorktree({ id: 'bravo', displayName: 'Bravo', lastActivityAt: 10_000 }) + const worktrees = [alpha, bravo] + + worktrees.sort(buildWorktreeComparator('recent', null, repoMap, null, NOW)) + + expect(worktrees.map((w) => w.id)).toEqual(['bravo', 'alpha']) + }) +}) diff --git a/src/renderer/src/components/sidebar/smart-sort.ts b/src/renderer/src/components/sidebar/smart-sort.ts index 8200cb357ff..54867cef86c 100644 --- a/src/renderer/src/components/sidebar/smart-sort.ts +++ b/src/renderer/src/components/sidebar/smart-sort.ts @@ -8,6 +8,38 @@ import { type SortBy = 'name' | 'smart' | 'recent' | 'repo' +// Why: a newly-created worktree's lastActivityAt is stamped at the moment +// createLocalWorktree finishes git + setup-runner prep (often several seconds +// after the user clicked Create). During and after that window, ambient PTY +// bumps on OTHER worktrees (data flush, exit, reconnect) can push the new +// worktree below them in Recent sort. This grace period gives the new +// worktree a floor of `createdAt + CREATE_GRACE_MS` in the Recent comparator +// so it stays on top until the user has had a chance to notice it. 5 min is +// long enough for the user to interact, short enough that steady-state +// ordering resumes quickly. +export const CREATE_GRACE_MS = 5 * 60 * 1000 + +/** + * Rank a worktree in Recent sort using `lastActivityAt`, but with a floor of + * `createdAt + CREATE_GRACE_MS` *only during* the grace window (i.e. while + * `now < createdAt + CREATE_GRACE_MS`). Once the window has elapsed, returns + * `lastActivityAt` unchanged. Returns `lastActivityAt` unchanged for worktrees + * without `createdAt` (discovered on disk, or persisted before this field + * existed). + */ +export function effectiveRecentActivity(worktree: Worktree, now: number): number { + const { lastActivityAt, createdAt } = worktree + // Why bound by now: a worktree with createdAt set but no subsequent activity + // should not retain artificially-high recency forever; the floor exists to + // absorb the noisy creation window only. Without this bound, a worktree + // created days ago and never touched would keep ranking as if its activity + // were `createdAt + 5min`, masking truly fresher worktrees indefinitely. + if (createdAt === undefined || now >= createdAt + CREATE_GRACE_MS) { + return lastActivityAt + } + return Math.max(lastActivityAt, createdAt + CREATE_GRACE_MS) +} + type PRCacheEntry = { data: object | null; fetchedAt: number } export type SmartSortOverride = { worktree: Worktree @@ -276,18 +308,27 @@ export function buildWorktreeComparator( ) return ( scoreB - scoreA || - smartB.worktree.lastActivityAt - smartA.worktree.lastActivityAt || + effectiveRecentActivity(smartB.worktree, now) - + effectiveRecentActivity(smartA.worktree, now) || a.displayName.localeCompare(b.displayName) ) } case 'recent': - // Why lastActivityAt (not sortOrder): sortOrder is a snapshot of the - // smart-sort ranking that only gets repersisted while the user is in - // "Smart" mode, so it's frozen in Recent mode and ignores new terminal - // events, meta edits, etc. lastActivityAt is the real "recency" signal - // — it's bumped by bumpWorktreeActivity (PTY spawn, background events) - // and by meaningful meta edits (comment, isUnread). - return b.lastActivityAt - a.lastActivityAt || a.displayName.localeCompare(b.displayName) + // Why effectiveRecentActivity (not raw lastActivityAt): newly-created + // worktrees get a CREATE_GRACE_MS floor on top of lastActivityAt so + // ambient PTY bumps in other worktrees don't immediately push them + // down. See CREATE_GRACE_MS above. + // + // Why not sortOrder: sortOrder is a snapshot of the smart-sort + // ranking that only gets repersisted while the user is in "Smart" + // mode, so it's frozen in Recent mode and ignores new terminal + // events, meta edits, etc. lastActivityAt is the real "recency" + // signal — bumped by bumpWorktreeActivity (PTY spawn, background + // events) and by meaningful meta edits (comment, isUnread). + return ( + effectiveRecentActivity(b, now) - effectiveRecentActivity(a, now) || + a.displayName.localeCompare(b.displayName) + ) case 'repo': { const ra = repoMap.get(a.repoId)?.displayName ?? '' const rb = repoMap.get(b.repoId)?.displayName ?? '' diff --git a/src/shared/types.ts b/src/shared/types.ts index 01a63fa0d14..f83802006d8 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -101,6 +101,11 @@ export type Worktree = { isPinned: boolean sortOrder: number lastActivityAt: number + /** Set once when Orca creates the worktree. Absent for worktrees discovered + * on disk or persisted before this field existed. Used by the sidebar to + * grant newly-created worktrees a short grace window at the top of Recent, + * immune to ambient PTY-bump reordering in other worktrees. */ + createdAt?: number sparseDirectories?: string[] sparseBaseRef?: string /** ID of the saved preset this worktree was created from, if any. Cleared @@ -121,6 +126,8 @@ export type WorktreeMeta = { isPinned: boolean sortOrder: number lastActivityAt: number + /** See {@link Worktree.createdAt}. Persisted to orca-data.json. */ + createdAt?: number sparseDirectories?: string[] sparseBaseRef?: string sparsePresetId?: string diff --git a/tests/e2e/worktree-recent-sort.spec.ts b/tests/e2e/worktree-recent-sort.spec.ts new file mode 100644 index 00000000000..82e24ca6e7b --- /dev/null +++ b/tests/e2e/worktree-recent-sort.spec.ts @@ -0,0 +1,119 @@ +/** + * E2E test for newly-added worktrees sorting correctly in "Recent" mode. + * + * Why this exists: + * Before the fix in `src/main/ipc/worktrees.ts`, a worktree that existed + * on disk but had no persisted WorktreeMeta (the case for folder-mode + * repos and pre-existing worktrees discovered when adding a new git repo) + * fell back to `lastActivityAt: 0`. "Recent" sort orders by + * `lastActivityAt` descending, so those worktrees landed dead last — + * even though the user had just added them. + * + * The `worktrees:list` / `worktrees:listAll` handlers now stamp + * `lastActivityAt = Date.now()` on first discovery. This test locks that + * behavior in end-to-end. + */ + +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs' +import os from 'os' +import path from 'path' +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { waitForSessionReady, waitForActiveWorktree } from './helpers/store' + +async function addFolderRepo(page: Page, folderPath: string): Promise { + return page.evaluate(async (p) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is unavailable') + } + // Why: go through the public addNonGitFolder path (not window.api.repos.add + // directly) so the test exercises the same flow the "Add Folder" dialog + // uses. That path fetches worktrees internally, which is what triggers the + // discovery stamp we're asserting about. + const repo = await store.getState().addNonGitFolder(p) + if (!repo) { + throw new Error('addNonGitFolder returned null') + } + return repo.id + }, folderPath) +} + +async function readFolderWorktreeLastActivity(page: Page, repoId: string): Promise { + return page.evaluate((id) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is unavailable') + } + const worktree = store.getState().worktreesByRepo[id]?.[0] + if (!worktree) { + throw new Error(`No worktree found for repo ${id}`) + } + return worktree.lastActivityAt + }, repoId) +} + +test.describe('Worktree Recent Sort', () => { + // Why: keep fixture tracking scoped to this describe block. Module-level + // shared arrays race if the file ever flips to parallel mode or another + // describe is added. + const createdFolderFixtures: string[] = [] + + function createFolderFixture(): string { + const dir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-folder-')) + createdFolderFixtures.push(dir) + mkdirSync(path.join(dir, 'src'), { recursive: true }) + writeFileSync(path.join(dir, 'README.md'), '# folder fixture\n') + return dir + } + + test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + }) + + test.afterEach(() => { + // Why: mkdtempSync fixtures leak unless we clean them up explicitly — + // matches the mkdtempSync/rmSync pairing used in helpers/orca-app.ts + // and helpers/orca-restart.ts. + while (createdFolderFixtures.length) { + const dir = createdFolderFixtures.pop() + if (dir) { + rmSync(dir, { recursive: true, force: true }) + } + } + }) + + test('stamps lastActivityAt on a newly-added folder repo so it sorts to the top of Recent', async ({ + orcaPage + }) => { + const folderPath = createFolderFixture() + + const repoId = await addFolderRepo(orcaPage, folderPath) + const lastActivityAt = await readFolderWorktreeLastActivity(orcaPage, repoId) + + // Why: the exact failure mode before the fix was `lastActivityAt === 0` + // (the fallback in mergeWorktree when meta is undefined). Asserting + // `> 0` captures that regression precisely without coupling to the + // wall-clock of the main process, which would introduce cross-process + // clock-skew flakiness in CI. + expect(lastActivityAt).toBeGreaterThan(0) + }) + + test('leaves lastActivityAt stable across repeated list refreshes', async ({ orcaPage }) => { + // Why: the stamp fires only on *first* discovery. Re-fetching must not + // overwrite it, or every sidebar refresh would reshuffle Recent order. + const folderPath = createFolderFixture() + const repoId = await addFolderRepo(orcaPage, folderPath) + + const first = await readFolderWorktreeLastActivity(orcaPage, repoId) + + await orcaPage.evaluate(async (id) => { + await window.__store?.getState().fetchWorktrees(id) + await window.__store?.getState().fetchWorktrees(id) + }, repoId) + + const second = await readFolderWorktreeLastActivity(orcaPage, repoId) + expect(second).toBe(first) + }) +}) From 1b25138e068720ac1cb878de3c00bcf470ded572 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 5 May 2026 16:24:44 -0700 Subject: [PATCH 013/213] fix(mobile): remove dead-row gap above keyboard accessory bar (#1457) Co-authored-by: Orca --- mobile/src/terminal/TerminalWebView.tsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/mobile/src/terminal/TerminalWebView.tsx b/mobile/src/terminal/TerminalWebView.tsx index da19da50c85..9766354f1e5 100644 --- a/mobile/src/terminal/TerminalWebView.tsx +++ b/mobile/src/terminal/TerminalWebView.tsx @@ -350,12 +350,15 @@ const XTERM_HTML = ` ? containerHeightPx : window.innerHeight; var cols = Math.floor(vpWidth / cellWidth); - // Why: subtract 2 rows after dividing. The WebView's reported height - // can slightly overstate the usable area (layout timing, subpixel - // rounding, safe-area insets). Subtracting 2 guarantees the last - // visible row plus the shell prompt (which often wraps on narrow - // screens) stays fully above the accessory bar. - var rows = Math.max(8, Math.floor(vpHeight / cellHeight) - 2); + // Why: the rows we report become the PTY's actual row count after the + // server fits to viewport, and xterm renders exactly that many lines + // anchored top-left of the WebView. Subtracting rows here would leave + // dead xterm-background space at the bottom of the container and make + // the last PTY rows visually appear above an "invisible line." Any + // safety margin between the prompt and the accessory bar must come + // from RN layout (terminalFrame's flex bounds), not from undersizing + // the PTY. + var rows = Math.max(8, Math.floor(vpHeight / cellHeight)); notify({ type: 'measure-result', cols: cols, rows: rows }); } From d3388603bb6a4ced45cbceccf65d6e667660c2e8 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 5 May 2026 16:27:20 -0700 Subject: [PATCH 014/213] feat(new-workspace): inline create-branch row, scoped suggestions, name under Advanced, tab/focus polish (#1469) * feat(new-workspace): branch-tab create row, scoped empty hints, name under Advanced, focus polish - Branch tab autocomplete now offers "Create new branch " instead of a separate + button + dialog (replaces the original UX from #1408 / #1400). Suppressed when the typed query exactly matches an existing branch. - "Use as workspace name" row is restricted to Smart mode; on dedicated source tabs (GitHub/Linear/Branch) it was off-topic noise. - Per-mode empty-state hints ("search GitHub PRs and issues", "find a branch or create a new one", etc.) replace the generic message. - When a source (PR/issue/Linear/branch) is selected, the auto-derived workspace name is exposed as a "Name" input under Advanced where it can be reviewed/overridden. With an explicitly typed name the smart input itself is the name field, so no duplicated control. - Forward Tab from the Repo combobox now skips the Smart/GitHub/Branch/Linear segmented control and lands directly in the search input. Shift-Tab from the input still focuses the active tab trigger so the segmented control is reachable in reverse. Implemented via a focusCapture interceptor on TabsList that distinguishes outside-entry from intra-list moves and from the input's own shift-tab. - Trigger checkLinearConnection() on mount so the "Connect Linear in Settings" hint isn't shown stalely when the composer is the first Linear-aware surface to render in the session; gate the disconnected message on linearStatusChecked. Co-authored-by: Orca * fix(new-workspace): Enter commits typed text while results are stale When the user types faster than the 200ms search debounce, rows and commandValue still reflect the previous query, so Enter would silently pick a stale source row (e.g. a PR from the prior letters). Treat results as stale while loading or while debouncedQuery hasn't caught up to the input value, and commit the typed text via onValueChange instead. Co-authored-by: Orca * fix(new-workspace): suppress stale source rows in autocomplete while typing Source rows (GitHub items / branches / Linear issues) are driven by debouncedQuery, so they're stale until the user pauses for the 200ms debounce. The previous attempt only short-circuited Enter in the input's onKeyDown, but cmdk's Command component has its own Enter handler that still fires onSelect on the highlighted (stale) row. Filter source rows out of the rows list itself when debouncedQuery hasn't caught up to value, leaving only the typed-text row (use-name in Smart, create-branch in Branches). With no stale rows present, neither cmdk nor our own handler can pick a wrong source on Enter, and the popover gives visual feedback (collapses to the typed-text row) while results catch up. Co-authored-by: Orca * fix(new-workspace): keep stale results visible, force highlight to typed-text row Previous attempt removed source rows while debouncedQuery hadn't caught up to value, which caused funky disappear/reappear flicker as the user typed (rows present briefly while fresh, gone after the next keystroke). Keep all source rows visible at all times to preserve continuity, but control which row is highlighted while stale: - Smart/Branches: force highlight onto the typed-text row (use-name / create-branch) so cmdk's Enter handler commits the typed text instead of a stale issue/PR/branch. - GitHub/Linear: no typed-text fallback row exists, so clear the highlight; the input's onKeyDown falls through to onPlainEnter rather than picking a stale source. Also fixed the input's Enter handler to fall through to onPlainEnter when no row matches the (possibly empty) commandValue, so the keypress isn't inert in the cleared-highlight case. Co-authored-by: Orca * feat(new-workspace): auto-highlight matching source for #NNN, GH URLs, Linear IDs When the typed value is unambiguously a source reference, snap the autocomplete highlight onto the matching source row once it appears instead of leaving it on the typed-text fallback. Enter then picks the intended source. Recognized patterns: - GitHub shorthand: #1234 - GitHub URL: https://github.com///issues/123 or /pull/123 - Linear identifier: STA-123 (case-insensitive [A-Z][A-Z0-9_]*-\d+) Stale-results behavior is unchanged: while debouncedQuery hasn't caught up to value, the typed-text row stays highlighted (Smart/Branches) or the highlight is cleared (GitHub/Linear) regardless of intent. Co-authored-by: Orca --------- Co-authored-by: Orca --- .../components/NewWorkspaceComposerCard.tsx | 19 ++ .../new-workspace/SmartWorkspaceNameField.tsx | 171 ++++++++++++++++-- 2 files changed, 177 insertions(+), 13 deletions(-) diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index 98219c7a5f6..7cacd39f437 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -398,6 +398,25 @@ export default function NewWorkspaceComposerCard({ : '-translate-y-1 opacity-0 delay-0' )} > + {smartNameSelection ? ( + // Why: when a source (PR/issue/Linear/branch) is picked the + // smart field shows a pill instead of an editable name, so + // surface the auto-derived workspace name here under Advanced + // where it can be reviewed/overridden. When the user typed an + // explicit name there's no source pill — the smart input is + // already the name field, so we don't duplicate it here. +
+ + onNameValueChange(event.target.value)} + placeholder="Workspace name" + className="w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1.5 text-sm shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50" + /> +
+ ) : null} +