From 14e879a825bd03d198e011791639df0c7f70c6fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leynier=20Guti=C3=A9rrez=20Gonz=C3=A1lez?= Date: Tue, 5 May 2026 12:10:52 -0600 Subject: [PATCH 01/28] fix(layout): align right sidebar to top in non-workspace views (#1356) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(layout): align right sidebar to top in non-workspace views Wrap the left + center columns in a flex-col that owns the conditional titlebar, and lift to be a sibling of that wrapper. The titlebar now spans only the left+center area, so the right sidebar's own header anchors at the top of the window in landing/settings/tasks views — matching the workspace-view alignment. Also gate the titlebar's right-sidebar toggle on !rightSidebarOpen so it no longer double-renders alongside the close button inside the right sidebar's own header when the sidebar is open. Closes #1354 * fix(titlebar): shrink non-workspace .titlebar to 36px PR #1381 shrank .titlebar-left to 36px but left .titlebar at 42px. Before the right-sidebar top-alignment fix, the 6px mismatch was hidden because the right sidebar was pushed below the titlebar in non-workspace views. With top-alignment, the 36px right-sidebar header no longer lines up with the 42px titlebar — visible as a 6px seam on the Landing page when the right sidebar is open. Match all three (titlebar, titlebar-left, right-sidebar header) at 36px so the top strip is uniform across views. Co-authored-by: Orca --------- Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Co-authored-by: Orca --- src/renderer/src/App.tsx | 220 ++++++++++++++++--------------- src/renderer/src/assets/main.css | 4 +- 2 files changed, 118 insertions(+), 106 deletions(-) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 00efe96c5a4..7dcdff43c35 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -975,118 +975,130 @@ function App(): React.JSX.Element { The hooks' internal early-returns remain as defense-in-depth (see the comment above useIpcEvents()). */} {agentDashboardEnabled ? : null} - {/* Why: in workspace view (split groups always enabled), the full-width - titlebar is removed so tab groups + terminal extend to the top of - the window. Left titlebar controls move to a header above the sidebar. - Settings, landing, and the tasks page keep the full-width titlebar. */} - {!workspaceActive ? ( -
-
- {titlebarLeftControls} -
-
- {showTitlebarExpandButton && ( - - - - - - Collapse pane - - - )} - {rightSidebarToggle} -
- ) : null}
- {showSidebar ? ( - workspaceActive ? ( - /* Why: left column wraps the sidebar with a titlebar-height - header above it. The header holds the same controls - (traffic lights, sidebar toggle, "Orca" title, agent badge) - that the full-width titlebar held while the center and right - columns keep their own top strips at the same 42px height. - When the sidebar is collapsed, take this header out of flex - layout so the terminal/editor reclaim the left edge instead of - leaving behind a content-width blank strip. */ -
+ {/* Why: the non-workspace titlebar lives inside this left+center + wrapper so it does not span over the right-sidebar column — + when the right sidebar is open, its own header anchors at the + top alongside the titlebar instead of being pushed below it. */} +
+ {/* Why: in workspace view (split groups always enabled), the + full-width titlebar is removed so tab groups + terminal extend + to the top of the window. Left titlebar controls move to a + header above the sidebar. Settings, landing, and the tasks + page keep the titlebar. */} + {!workspaceActive ? ( +
{titlebarLeftControls}
-
- {/* Why: the workspace-view wrapper adds a fixed 42px header - above the sidebar. Without a flex-1/min-h-0 slot here, - the sidebar falls back to its content height, so the - worktree list loses its scroll viewport and the fixed - bottom toolbar (including Add Project) gets pushed offscreen. */} +
+ {showTitlebarExpandButton && ( + + + + + + Collapse pane + + + )} + {/* Why: when the right sidebar is open, its own header renders + an identical close button — hide this copy so only one is + visible at a time. */} + {!rightSidebarOpen && rightSidebarToggle} +
+ ) : null} +
+ {showSidebar ? ( + workspaceActive ? ( + /* Why: left column wraps the sidebar with a titlebar-height + header above it. The header holds the same controls + (traffic lights, sidebar toggle, "Orca" title, agent badge) + that the full-width titlebar held while the center and right + columns keep their own top strips at the same 36px height. + When the sidebar is collapsed, take this header out of flex + layout so the terminal/editor reclaim the left edge instead of + leaving behind a content-width blank strip. */ +
+
+ {titlebarLeftControls} +
+
+ {/* Why: the workspace-view wrapper adds a fixed 36px header + above the sidebar. Without a flex-1/min-h-0 slot here, + the sidebar falls back to its content height, so the + worktree list loses its scroll viewport and the fixed + bottom toolbar (including Add Project) gets pushed offscreen. */} + +
+
+ ) : ( + ) + ) : null} +
+ {/* Why: right sidebar toggle floats at the top-right of the center + column so it's always accessible whether the right sidebar is + open or closed. Match the RightSidebar header's 36px height and + top-0 anchor so the icon's vertical center is identical between + open and closed states — otherwise toggling makes the icon jump + a few pixels, which reads as layout jitter. */} + {workspaceActive && !rightSidebarOpen && ( +
+ {rightSidebarToggle} +
+ )} +
+
+ +
+ + {activeView === 'settings' ? : null} + {activeView === 'tasks' ? : null} + {activeView === 'terminal' && !activeWorktreeId ? : null} +
- ) : ( - - ) - ) : null} -
- {/* Why: right sidebar toggle floats at the top-right of the center - column so it's always accessible whether the right sidebar is - open or closed. Match the RightSidebar header's 42px height and - top-0 anchor so the icon's vertical center is identical between - open and closed states — otherwise toggling makes the icon jump - a few pixels, which reads as layout jitter. */} - {workspaceActive && !rightSidebarOpen && ( -
- {rightSidebarToggle} -
- )} -
-
- -
- - {activeView === 'settings' ? : null} - {activeView === 'tasks' ? : null} - {activeView === 'terminal' && !activeWorktreeId ? : null} -
{/* Why: keep RightSidebar mounted even when closed so that its diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index ebc86413453..5ddadaebf4f 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -359,8 +359,8 @@ } .titlebar { - height: 42px; - min-height: 42px; + height: 36px; + min-height: 36px; display: flex; flex-direction: row; align-items: center; From 5ce9c3b4810331d77926736fb6ee634416281f38 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 5 May 2026 11:19:50 -0700 Subject: [PATCH 02/28] feat(tasks): remember tasks page resume state across sessions (#1442) Persist transient Tasks page position (GitHub mode, active preset/query, Linear preset/query) in PersistedUIState so reopening Tasks restores the user's working context instead of falling back to defaults. Source, repo selection, team selection, and active project keep using their existing settings paths. Co-authored-by: Orca --- docs/tasks-page-resume-state.md | 176 +++++++++++++++++++++++ src/renderer/src/components/TaskPage.tsx | 146 +++++++++++++++---- src/renderer/src/store/slices/ui.test.ts | 42 +++++- src/renderer/src/store/slices/ui.ts | 99 ++++++++++++- src/shared/types.ts | 13 ++ 5 files changed, 443 insertions(+), 33 deletions(-) create mode 100644 docs/tasks-page-resume-state.md diff --git a/docs/tasks-page-resume-state.md b/docs/tasks-page-resume-state.md new file mode 100644 index 00000000000..64589b277ac --- /dev/null +++ b/docs/tasks-page-resume-state.md @@ -0,0 +1,176 @@ +# Tasks Page Resume State + +## Goal + +When a user leaves the Tasks page and comes back later, Orca should reopen the page in the same working context instead of falling back to a generic GitHub Issues/PRs list. + +The resume behavior should cover: + +- GitHub vs Linear. +- GitHub Issues/PRs vs GitHub Project mode. +- The selected GitHub Issues/PRs preset or custom search. +- The selected GitHub Project and Project view. +- The selected repos for GitHub Issues/PRs. +- The selected Linear teams. + +This should be lightweight. It should not introduce a second project-selection model or duplicate state that is already persisted elsewhere. + +## Existing Persisted State + +The current settings model already remembers most durable task choices: + +- `settings.defaultTaskSource` remembers GitHub vs Linear. +- `settings.defaultRepoSelection` remembers GitHub repo selection for cross-repo Issues/PRs. +- `settings.defaultLinearTeamSelection` remembers Linear team selection. +- `settings.githubProjects.activeProject` remembers the active GitHub Project. +- `settings.githubProjects.lastViewByProject` remembers the last selected Project view per Project. +- `settings.defaultTaskViewPreset` remembers the user's default GitHub Issues/PRs preset. + +These should stay in place. They are already wired into the page and, for repo/team/project selection, they represent actual user preferences rather than purely transient page state. + +## Missing State + +The missing piece is the user's current page position inside Tasks: + +- Whether GitHub is showing `Issues/PRs` or `Project`. +- Which GitHub Issues/PRs preset is currently active. +- The currently applied GitHub Issues/PRs query when the user has typed a custom search. +- Which Linear preset is currently active. +- The currently applied Linear query when the user has typed a search. + +Linear presets are visible task tabs and drive the fetch path, so they must be restored too. + +## Proposed Shape + +Add a small optional field to `PersistedUIState`: + +```ts +export type TaskResumeState = { + githubMode?: 'items' | 'project' + githubItemsPreset?: TaskViewPresetId | null + githubItemsQuery?: string + linearPreset?: 'assigned' | 'created' | 'all' | 'completed' + linearQuery?: string +} +``` + +Then add: + +```ts +taskResumeState?: TaskResumeState +``` + +to `PersistedUIState`. + +Why `PersistedUIState`: this is page-position UI state, similar to sidebar filters and widths. It is not an app-wide setting and should not be presented as a configurable default. + +Do not add `source` to `TaskResumeState`. `settings.defaultTaskSource` already represents the user's last-used task source today, and duplicating it would create two persisted sources of truth. Keep source changes on the existing settings path. + +Do not reset `githubMode` just because the current source is Linear. Source selection and GitHub sub-mode are independent pieces of context: if the user was in GitHub Project mode, switched to Linear, then later switches back to GitHub, GitHub should still reopen in Project mode. + +## Restore Rules + +On Tasks page mount: + +1. If `taskPageData.taskSource` was passed by a caller, use it. Explicit navigation intent should win. +2. Otherwise fall back to `settings.defaultTaskSource`. +3. Apply these restore rules only after both settings and persisted UI state have hydrated. The current app loads those asynchronously before setting `persistedUIReady`; a `useState` initializer that reads `settings === null` or an unhydrated `taskResumeState` will capture defaults and miss the restored context. + +The restore should run once per Tasks page mount, after hydration is ready. It should not keep reapplying persisted state over local user interactions while the page remains open. + +For GitHub: + +1. Restore `githubMode` from `taskResumeState.githubMode`. +2. If mode is `items`, restore `githubItemsPreset` and `githubItemsQuery`. +3. Repo selection continues to come from `settings.defaultRepoSelection`. +4. If mode is `project`, use `settings.githubProjects.activeProject` and `settings.githubProjects.lastViewByProject`. +5. If Project mode has no active project or view, show the Project picker empty state instead of silently switching to Issues/PRs. + +For Linear: + +1. Restore `linearPreset`. +2. Restore `linearQuery`. +3. Team selection continues to come from `settings.defaultLinearTeamSelection`. + +Fallback behavior: + +- If resume state is absent, use today's defaults. +- If `githubItemsPreset` is non-null, derive the query from the preset unless `githubItemsQuery` disagrees because of a future migration. The preset should be treated as authoritative. +- If `githubItemsPreset` is null, use `githubItemsQuery` as a custom search. +- If `linearQuery` is non-empty, treat it as custom search and keep the restored `linearPreset` as the preset to return to after the search is cleared. The visible active preset should be suppressed while the search input is non-empty, matching today's UI. + +## Write Rules + +Update `taskResumeState` only when the user changes the active page context: + +- Switching GitHub mode between Issues/PRs and Project. +- Clicking a GitHub Issues/PRs preset. +- Applying the debounced GitHub Issues/PRs search. +- Clearing the GitHub Issues/PRs search. +- Clicking a Linear preset. +- Applying the debounced Linear search. +- Clearing the Linear search. + +Do not persist every raw search keystroke. The current UI applies search after a 300 ms debounce, so the persisted value should follow the debounced applied query, not the raw input. A user who types, waits for results, leaves Tasks, and comes back should see the same applied query. + +Task source, repo selection, Linear team selection, active Project, and active Project view should continue writing through their existing settings paths. + +The resume setter should merge partial updates with the existing resume state so changing one dimension does not erase the others. It should persist via `window.api.ui.set({ taskResumeState })` after updating local Zustand state. + +When writing preset selections, clear stale custom-query ambiguity explicitly: + +- GitHub preset click: write `{ githubItemsPreset: presetId, githubItemsQuery: undefined }` or the canonical preset query. Restore must still use the preset as authoritative. +- GitHub custom search apply/debounce: write `{ githubItemsPreset: null, githubItemsQuery: trimmedQuery }`. +- GitHub search clear: write `{ githubItemsPreset: null, githubItemsQuery: '' }`. +- Linear preset click: write `{ linearPreset: presetId, linearQuery: '' }`. +- Linear custom search apply/debounce: write `{ linearQuery: trimmedQuery }` without changing `linearPreset`. +- Linear search clear: write `{ linearQuery: '' }` without changing `linearPreset`. + +Do not persist mode changes caused only by hiding Project mode while another source is active. In practice, avoid an effect that changes persisted `githubMode` from `project` to `items` when `taskSource !== 'github'`. + +## Implementation Notes + +Primary files: + +- `src/shared/types.ts`: add `TaskResumeState` and `taskResumeState?: TaskResumeState`. +- `src/shared/constants.ts`: no required default beyond leaving the field absent. If a concrete default is preferred, use GitHub Issues/PRs with the existing default preset. +- `src/renderer/src/store/slices/ui.ts`: hydrate `taskResumeState` defensively, expose a setter such as `setTaskResumeState`, and use it in `openTaskPage` prefetch selection. +- `src/renderer/src/components/TaskPage.tsx`: initialize source from existing settings/page data, and initialize GitHub mode, GitHub item preset/query, Linear preset, and Linear query from the resume state. +- `src/renderer/src/App.tsx`: no new work should be needed if the field rides the existing `ui:get` hydration. + +The setter should call `window.api.ui.set({ taskResumeState })` through the same persistence path used by other persisted UI fields, or follow the existing store pattern if there is already a central UI save effect for the relevant slice. + +Hydration must sanitize the nested object because `PersistedUIState` comes from disk. Invalid `githubMode`, invalid GitHub preset ids, invalid Linear preset ids, or non-string queries should fall back field-by-field rather than entering Zustand as untrusted values. + +`TaskPage` should subscribe to `persistedUIReady`, `settings`, and `taskResumeState`, then perform a one-shot local initialization once both settings and UI hydration are available. This avoids the current class of bugs where `useState(settings?.defaultTaskViewPreset ?? 'all')` captures `'all'` before settings arrive. + +`openTaskPage` currently prefetches the settings default GitHub preset. After this change, it should prefetch the query the Tasks page will actually mount with: + +1. If explicit `taskPageData.taskSource` is `linear`, skip GitHub prefetch. +2. If explicit `taskPageData.taskSource` is `github`, or the resolved source is GitHub, prefetch only when the resolved GitHub mode is `items`. +3. If `githubItemsPreset` is non-null, prefetch that preset query. +4. If `githubItemsPreset` is null and `githubItemsQuery` is non-empty, prefetch the custom query. +5. Otherwise prefetch `settings.defaultTaskViewPreset`. + +The prefetch should also use the same repo selection the page will use: `taskPageData.preselectedRepoId` when present, otherwise `settings.defaultRepoSelection`, otherwise all eligible repos. Warming only `activeRepoId` is acceptable as an optimization fallback, but it is not equivalent to the mounted cross-repo query. + +## Non-Goals + +Do not remember scroll position, selected table row, open dialogs, pagination page, Project search overrides, or cached API results in this pass. + +Those states are more fragile because they depend on live remote data. The first version should only restore the user's broad working context. + +## Acceptance Criteria + +- Open Tasks, switch to Linear, leave Tasks, return to Tasks: Linear is selected. +- Open Tasks, switch to GitHub Project mode, switch to Linear, leave Tasks, return to Tasks, then switch back to GitHub: GitHub is still in Project mode. +- Open Tasks, switch to GitHub Project mode, select a Project view, leave Tasks, return to Tasks: Project mode opens on the same Project view. +- Open Tasks, choose GitHub Issues/PRs `My PRs`, leave Tasks, return to Tasks: GitHub Issues/PRs opens with `My PRs` active. +- Open Tasks, type and apply a custom GitHub search, leave Tasks, return to Tasks: GitHub Issues/PRs opens with that custom query and no preset selected. +- Open Tasks, type a GitHub search, wait for the debounced results, leave Tasks, return to Tasks: GitHub Issues/PRs opens with that applied query and no preset selected. +- Open Tasks, choose Linear `Completed`, leave Tasks, return to Tasks: Linear opens with `Completed` active. +- Open Tasks, type a Linear search, wait for the debounced results, leave Tasks, return to Tasks: Linear opens with that applied query. +- Open Tasks before hydration has completed, then wait for hydration: Tasks applies the persisted source/mode/preset/query exactly once and does not overwrite subsequent user changes. +- Corrupt `taskResumeState` on disk with invalid modes/preset ids/non-string queries, restart, and open Tasks: invalid fields are ignored field-by-field without breaking the page. +- Existing repo selection, Linear team selection, and GitHub Project view persistence keep working unchanged. +- Restart the app after each source/mode/preset/custom-query scenario above: the same Tasks context is restored from persisted UI/settings state. diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 1badaee6dc7..10e431fca96 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -653,6 +653,9 @@ const hasUpstreamCandidateDivergence = ( export default function TaskPage(): React.JSX.Element { const settings = useAppStore((s) => s.settings) + const persistedUIReady = useAppStore((s) => s.persistedUIReady) + const taskResumeState = useAppStore((s) => s.taskResumeState) + const setTaskResumeState = useAppStore((s) => s.setTaskResumeState) const pageData = useAppStore((s) => s.taskPageData) const closeTaskPage = useAppStore((s) => s.closeTaskPage) const activeModal = useAppStore((s) => s.activeModal) @@ -768,6 +771,10 @@ export default function TaskPage(): React.JSX.Element { const defaultTaskSource = settings?.defaultTaskSource ?? 'github' const [taskSource, setTaskSource] = useState(pageData.taskSource ?? defaultTaskSource) + const taskResumeAppliedRef = useRef(false) + const githubSearchPersistReadyRef = useRef(false) + const linearSearchPersistReadyRef = useRef(false) + const [taskResumeApplied, setTaskResumeApplied] = useState(false) // Why: pageData.taskSource changes when the user clicks a specific source // icon in the sidebar while the task page is already open. useState only @@ -778,27 +785,11 @@ export default function TaskPage(): React.JSX.Element { } }, [pageData.taskSource]) - // Why: settings load asynchronously — the useState initializer may capture - // null settings on fast navigation. Sync once settings arrive, but only - // when no explicit source was passed via sidebar icon click. - 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. const projectModeVisible = taskSource === 'github' const [githubMode, setGithubMode] = useState<'items' | 'project'>('items') - useEffect(() => { - // Snap back to items if the user leaves the GitHub task source while - // sitting in Project mode. - if (!projectModeVisible && githubMode === 'project') { - setGithubMode('items') - } - }, [projectModeVisible, githubMode]) const [taskSearchInput, setTaskSearchInput] = useState(initialTaskQuery) const [appliedTaskSearch, setAppliedTaskSearch] = useState(initialTaskQuery) @@ -1033,9 +1024,47 @@ export default function TaskPage(): React.JSX.Element { const [linearLoading, setLinearLoading] = useState(false) const [linearError, setLinearError] = useState(null) const [linearSearchInput, setLinearSearchInput] = useState('') + const [appliedLinearSearch, setAppliedLinearSearch] = useState('') const [activeLinearPreset, setActiveLinearPreset] = useState('all') const [linearRefreshNonce, setLinearRefreshNonce] = useState(0) + useEffect(() => { + if (taskResumeAppliedRef.current || !persistedUIReady || !settings) { + return + } + + setTaskSource(pageData.taskSource ?? settings.defaultTaskSource) + setRepoSelection(resolvedInitialSelection) + + const nextGithubMode = taskResumeState?.githubMode ?? 'items' + setGithubMode(nextGithubMode) + + const preset = taskResumeState?.githubItemsPreset + if (preset === null) { + const query = taskResumeState?.githubItemsQuery ?? '' + setTaskSearchInput(query) + setAppliedTaskSearch(query) + setActiveTaskPreset(null) + } else { + const presetId = preset ?? settings.defaultTaskViewPreset + const query = getTaskPresetQuery(presetId) + setTaskSearchInput(query) + setAppliedTaskSearch(query) + setActiveTaskPreset(presetId) + } + + const linearPreset = taskResumeState?.linearPreset ?? 'all' + const linearQuery = taskResumeState?.linearQuery ?? '' + setActiveLinearPreset(linearPreset) + setLinearSearchInput(linearQuery) + setAppliedLinearSearch(linearQuery) + + // Why: settings and persisted UI hydrate asynchronously. Apply the restored + // Tasks context exactly once so later source/filter clicks remain local. + taskResumeAppliedRef.current = true + setTaskResumeApplied(true) + }, [persistedUIReady, settings, pageData.taskSource, resolvedInitialSelection, taskResumeState]) + // Why: fetch the full team list from the Linear API so the selector shows // all teams the user belongs to, not just teams with issues in the current // fetch window. Fetched once when the Linear tab is active and connected. @@ -1044,6 +1073,9 @@ export default function TaskPage(): React.JSX.Element { ) useEffect(() => { + if (!taskResumeApplied) { + return + } if (taskSource !== 'linear' || !linearStatus.connected) { return } @@ -1054,7 +1086,7 @@ export default function TaskPage(): React.JSX.Element { console.warn('[TaskPage] Failed to fetch Linear teams') }) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [taskSource, linearStatus.connected]) + }, [taskSource, linearStatus.connected, taskResumeApplied]) const defaultLinearTeamSelection = settings?.defaultLinearTeamSelection const [linearTeamSelection, setLinearTeamSelection] = useState>(() => { @@ -1197,19 +1229,42 @@ export default function TaskPage(): React.JSX.Element { ) useEffect(() => { + if (!taskResumeApplied) { + return + } const timeout = window.setTimeout(() => { setAppliedTaskSearch(taskSearchInput) }, TASK_SEARCH_DEBOUNCE_MS) return () => window.clearTimeout(timeout) - }, [taskSearchInput]) + }, [taskSearchInput, taskResumeApplied]) useEffect(() => { + if (!taskResumeApplied) { + return + } + if (!githubSearchPersistReadyRef.current) { + githubSearchPersistReadyRef.current = true + return + } + if (activeTaskPreset !== null) { + return + } + setTaskResumeState({ + githubItemsPreset: null, + githubItemsQuery: appliedTaskSearch.trim() + }) + }, [activeTaskPreset, appliedTaskSearch, setTaskResumeState, taskResumeApplied]) + + useEffect(() => { + if (!taskResumeApplied) { + return + } // Why: both early-return branches must clear `retryingRepoPaths` — if the // user clicks Retry and then switches `taskSource` away from 'github' (or // somehow ends up with zero repos selected) before the fetch dispatches, // neither the `.then` nor the `.catch` below will fire, and the Retry // button would stay stuck in its disabled/Retrying state indefinitely. - if (taskSource !== 'github') { + if (taskSource !== 'github' || githubMode !== 'items') { setRetryingRepoPaths(new Set()) return } @@ -1347,15 +1402,24 @@ export default function TaskPage(): React.JSX.Element { // updates. `workItemsInvalidationNonce` is explicitly included so a // preference flip (which only evicts cache) re-dispatches this effect. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedRepos, appliedTaskSearch, taskRefreshNonce, taskSource, workItemsInvalidationNonce]) + }, [ + selectedRepos, + appliedTaskSearch, + taskRefreshNonce, + taskSource, + githubMode, + workItemsInvalidationNonce, + taskResumeApplied + ]) const handleApplyTaskSearch = useCallback((): void => { const trimmed = taskSearchInput.trim() setTaskSearchInput(trimmed) setAppliedTaskSearch(trimmed) setActiveTaskPreset(null) + setTaskResumeState({ githubItemsPreset: null, githubItemsQuery: trimmed }) setTaskRefreshNonce((current) => current + 1) - }, [taskSearchInput]) + }, [setTaskResumeState, taskSearchInput]) const handleTaskSearchChange = useCallback((event: React.ChangeEvent): void => { const next = event.target.value @@ -1605,18 +1669,34 @@ export default function TaskPage(): React.JSX.Element { // Why: debounce the Linear search input so we don't fire a request on every // keystroke — matches the 300ms cadence used for GitHub search. - const [appliedLinearSearch, setAppliedLinearSearch] = useState('') useEffect(() => { + if (!taskResumeApplied) { + return + } const timeout = window.setTimeout(() => { setAppliedLinearSearch(linearSearchInput) }, TASK_SEARCH_DEBOUNCE_MS) return () => window.clearTimeout(timeout) - }, [linearSearchInput]) + }, [linearSearchInput, taskResumeApplied]) + + useEffect(() => { + if (!taskResumeApplied) { + return + } + if (!linearSearchPersistReadyRef.current) { + linearSearchPersistReadyRef.current = true + return + } + setTaskResumeState({ linearQuery: appliedLinearSearch.trim() }) + }, [appliedLinearSearch, setTaskResumeState, taskResumeApplied]) // Why: fetch Linear issues when the tab is active and the account is // connected. An empty search falls back to `listLinearIssues` (assigned // issues) so the default view shows the user's own work. useEffect(() => { + if (!taskResumeApplied) { + return + } if (taskSource !== 'linear') { return } @@ -1661,7 +1741,8 @@ export default function TaskPage(): React.JSX.Element { linearStatus.connected, appliedLinearSearch, activeLinearPreset, - linearRefreshNonce + linearRefreshNonce, + taskResumeApplied ]) // Why: for Linear issues the "Use" flow opens the composer with the issue @@ -1866,7 +1947,10 @@ export default function TaskPage(): React.JSX.Element { + + + + ) : null} + + + ) +} diff --git a/src/renderer/src/components/status-bar/MemoryStatusSegment.tsx b/src/renderer/src/components/status-bar/MemoryStatusSegment.tsx deleted file mode 100644 index 4e314a52fbd..00000000000 --- a/src/renderer/src/components/status-bar/MemoryStatusSegment.tsx +++ /dev/null @@ -1,911 +0,0 @@ -/* eslint-disable max-lines -- Why: the popover, its sub-sections, the - sparkline, and the formatters are all small pieces that only exist to - serve this one status-bar segment. Keeping them co-located follows the - same convention as the other *StatusSegment.tsx files (see StatusBar.tsx). */ -import React, { memo, useCallback, useEffect, useMemo, useState } from 'react' -import { ChevronDown, ChevronRight, MemoryStick, Moon, Trash2 } from 'lucide-react' -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { cn } from '@/lib/utils' -import { activateAndRevealWorktree } from '@/lib/worktree-activation' -import { useAppStore } from '../../store' -import { useWorktreeMap } from '../../store/selectors' -import { runWorktreeDelete } from '../sidebar/delete-worktree-flow' -import { runSleepWorktree } from '../sidebar/sleep-worktree-flow' -import type { - AppMemory, - SessionMemory, - TerminalTab, - UsageValues, - Worktree, - WorktreeMemory -} from '../../../../shared/types' -import { ORPHAN_WORKTREE_ID } from '../../../../shared/constants' - -// ─── Constants ────────────────────────────────────────────────────── - -const POLL_MS = 2_000 - -type SortOption = 'memory' | 'cpu' | 'name' - -const METRIC_COLUMNS_CLS = 'flex items-center shrink-0 tabular-nums' -const CPU_COLUMN_CLS = 'w-12 text-right' -const MEM_COLUMN_CLS = 'w-16 text-right' - -// ─── Formatters ───────────────────────────────────────────────────── - -function formatMemory(bytes: number): string { - if (bytes < 1024 * 1024) { - return `${Math.round(bytes / 1024)} KB` - } - if (bytes < 1024 * 1024 * 1024) { - return `${(bytes / (1024 * 1024)).toFixed(1)} MB` - } - return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB` -} - -function formatCpu(percent: number): string { - return `${percent.toFixed(1)}%` -} - -function formatPercent(value: number): string { - return `${value.toFixed(0)}%` -} - -// ─── Session label resolution ─────────────────────────────────────── - -function parsePaneKey(paneKey: string | null): { tabId: string; paneRuntimeId: number } | null { - if (!paneKey) { - return null - } - const sepIdx = paneKey.indexOf(':') - if (sepIdx <= 0) { - return null - } - const paneRuntimeId = Number(paneKey.slice(sepIdx + 1)) - if (!Number.isFinite(paneRuntimeId)) { - return null - } - return { tabId: paneKey.slice(0, sepIdx), paneRuntimeId } -} - -function sessionRowLabel( - session: SessionMemory, - worktreeId: string, - tabsByWorktree: Record, - runtimePaneTitlesByTabId: Record> -): string { - const parsed = parsePaneKey(session.paneKey) - if (parsed) { - const tabs = tabsByWorktree[worktreeId] ?? [] - const tabIndex = tabs.findIndex((t) => t.id === parsed.tabId) - const tab = tabIndex >= 0 ? tabs[tabIndex] : undefined - if (tab) { - // Why: mirror the tab bar's label precedence (SortableTab: customTitle - // wins over the live OSC-updated title). Fall through to the runtime - // pane title so split panes stay identifiable, then the saved tab - // title, and finally a stable index-based label. - const custom = tab.customTitle?.trim() - if (custom) { - return custom - } - const runtime = runtimePaneTitlesByTabId[parsed.tabId]?.[parsed.paneRuntimeId]?.trim() - if (runtime) { - return runtime - } - return tab.defaultTitle?.trim() || tab.title?.trim() || `Terminal ${tabIndex + 1}` - } - } - if (session.pid > 0) { - return `pid ${session.pid}` - } - const fallback = session.sessionId?.slice(0, 8) - return fallback ? `session ${fallback}` : '(unknown session)' -} - -// ─── Grouping helpers ─────────────────────────────────────────────── - -type RepoGroup = { - repoId: string - repoName: string - cpu: number - memory: number - worktrees: WorktreeMemory[] -} - -function bucketByRepo(worktrees: WorktreeMemory[]): RepoGroup[] { - const map = new Map() - for (const wt of worktrees) { - const key = wt.repoId || 'unknown' - let group = map.get(key) - if (!group) { - group = { - repoId: key, - repoName: wt.repoName || 'Unknown Repo', - cpu: 0, - memory: 0, - worktrees: [] - } - map.set(key, group) - } - group.cpu += wt.cpu - group.memory += wt.memory - group.worktrees.push(wt) - } - return [...map.values()] -} - -function sortWorktreesBy( - list: WorktreeMemory[], - sort: SortOption, - labelFor: (wt: WorktreeMemory) => string -): WorktreeMemory[] { - const copy = [...list] - if (sort === 'memory') { - copy.sort((a, b) => b.memory - a.memory) - } else if (sort === 'cpu') { - copy.sort((a, b) => b.cpu - a.cpu) - } else { - // Why labelFor instead of worktreeName: the row label prefers the - // user-editable displayName over the dirname, so alphabetical order - // needs to match what the user actually sees in the list. - copy.sort((a, b) => labelFor(a).localeCompare(labelFor(b))) - } - return copy -} - -function sortRepoGroupsBy(groups: RepoGroup[], sort: SortOption): RepoGroup[] { - const copy = [...groups] - if (sort === 'memory') { - copy.sort((a, b) => b.memory - a.memory) - } else if (sort === 'cpu') { - copy.sort((a, b) => b.cpu - a.cpu) - } else { - copy.sort((a, b) => a.repoName.localeCompare(b.repoName)) - } - return copy -} - -// ─── Sparkline ────────────────────────────────────────────────────── - -type SparklineProps = { - samples: number[] - width?: number - height?: number -} - -function SparklineImpl({ samples, width = 48, height = 14 }: SparklineProps): React.JSX.Element { - const points = useMemo(() => { - // Why: defensive against IPC payload drift during hot-reload — a missing - // or non-array history should render as a flat line, not throw. - const safe = Array.isArray(samples) ? samples : [] - if (safe.length < 2) { - const midY = (height / 2).toFixed(1) - return `0,${midY} ${width},${midY}` - } - - let min = safe[0] - let max = safe[0] - for (const v of safe) { - if (v < min) { - min = v - } - if (v > max) { - max = v - } - } - const range = max - min || 1 - const stepX = width / (safe.length - 1) - - const out: string[] = [] - for (let i = 0; i < safe.length; i++) { - const x = (i * stepX).toFixed(1) - // SVG y grows downward, so invert: larger values render higher. - const y = (height - ((safe[i] - min) / range) * height).toFixed(1) - out.push(`${x},${y}`) - } - return out.join(' ') - }, [samples, width, height]) - - return ( - - - - ) -} - -/** Why memo: popover polls every 2s and shallow-equal samples shouldn't - * trigger a full re-scan of the min/max/polyline points for every row. */ -const Sparkline = memo(SparklineImpl, (a, b) => { - if (a.width !== b.width || a.height !== b.height) { - return false - } - const sa = Array.isArray(a.samples) ? a.samples : [] - const sb = Array.isArray(b.samples) ? b.samples : [] - if (sa === sb) { - return true - } - if (sa.length !== sb.length) { - return false - } - for (let i = 0; i < sa.length; i++) { - if (sa[i] !== sb[i]) { - return false - } - } - return true -}) - -// ─── Leaf UI: metric row ──────────────────────────────────────────── - -function MetricPair({ - cpu, - memory, - size = 'base' -}: { - cpu: number - memory: number - size?: 'base' | 'small' -}): React.JSX.Element { - const textCls = size === 'small' ? 'text-[11px]' : 'text-xs' - return ( -
- {formatCpu(cpu)} - {formatMemory(memory)} -
- ) -} - -// ─── Section: app (main / renderer / other) ───────────────────────── - -function AppSection({ - app, - isCollapsed, - onToggle -}: { - app: AppMemory - isCollapsed: boolean - onToggle: () => void -}): React.JSX.Element { - return ( -
-
- -
- - Orca - -
- - -
-
-
- {!isCollapsed && ( -
- - - {(app.other.cpu > 0 || app.other.memory > 0) && ( - - )} -
- )} -
- ) -} - -function AppSubRow({ label, values }: { label: string; values: UsageValues }): React.JSX.Element { - return ( -
- {label} - -
- ) -} - -// ─── Section: worktree tree ───────────────────────────────────────── - -function WorktreeSection({ - worktrees, - sortOption, - collapsedRepos, - toggleRepo, - collapsedWorktrees, - toggleWorktree, - navigateToWorktree, - onSleep, - onDelete -}: { - worktrees: WorktreeMemory[] - sortOption: SortOption - collapsedRepos: Set - toggleRepo: (repoId: string) => void - collapsedWorktrees: Set - toggleWorktree: (worktreeId: string) => void - navigateToWorktree: (worktreeId: string) => void - onSleep: (worktreeId: string) => void - onDelete: (worktreeId: string) => void -}): React.JSX.Element { - // Why: these slices mutate frequently (runtimePaneTitlesByTabId updates on - // every terminal OSC escape). Subscribing inside WorktreeSection — which - // only mounts when the popover is open — prevents those updates from - // re-rendering the always-mounted status-bar segment. - const tabsByWorktree = useAppStore((s) => s.tabsByWorktree) - const runtimePaneTitlesByTabId = useAppStore((s) => s.runtimePaneTitlesByTabId) - - // Why: WorktreeMemory is a lightweight snapshot; we look the real Worktree - // record up from the store so rows can (a) disable Delete for the main - // worktree, and (b) render the user-editable displayName instead of the - // dirname. Use the shared cached selector so we don't duplicate the - // WeakMap-cached Map the rest of the app already shares. - const worktreeById = useWorktreeMap() - - // Shared label resolver: prefer displayName, fall back to the dirname - // carried on the memory snapshot. Used for both rendering and alpha-sort. - const labelFor = useCallback( - (wt: WorktreeMemory): string => - worktreeById.get(wt.worktreeId)?.displayName?.trim() || wt.worktreeName, - [worktreeById] - ) - - // Memoize grouping: popover polls every 2s, so without this we'd rebuild - // the Map + arrays on every render even when nothing changed. - const repoGroups = useMemo( - () => - sortRepoGroupsBy(bucketByRepo(worktrees), sortOption).map((group) => ({ - ...group, - worktrees: sortWorktreesBy(group.worktrees, sortOption, labelFor) - })), - [worktrees, sortOption, labelFor] - ) - - // Why: when only one repo is active, the repo header row adds a useless - // level of nesting — the worktrees are the interesting thing. Flatten - // straight to worktree rows in that case. - const singleRepo = repoGroups.length === 1 - - const renderWorktree = (wt: WorktreeMemory): React.JSX.Element => { - const storeRecord = worktreeById.get(wt.worktreeId) ?? null - return ( - toggleWorktree(wt.worktreeId)} - onNavigate={() => navigateToWorktree(wt.worktreeId)} - onSleep={() => onSleep(wt.worktreeId)} - onDelete={() => onDelete(wt.worktreeId)} - tabsByWorktree={tabsByWorktree} - runtimePaneTitlesByTabId={runtimePaneTitlesByTabId} - /> - ) - } - - if (singleRepo) { - return <>{repoGroups[0].worktrees.map(renderWorktree)} - } - - return ( - <> - {repoGroups.map((group) => { - const repoCollapsed = collapsedRepos.has(group.repoId) - return ( -
-
- -
- - {group.repoName} - - -
-
- - {!repoCollapsed && ( -
{group.worktrees.map(renderWorktree)}
- )} -
- ) - })} - - ) -} - -function WorktreeRow({ - worktree, - storeRecord, - isCollapsed, - onToggle, - onNavigate, - onSleep, - onDelete, - tabsByWorktree, - runtimePaneTitlesByTabId -}: { - worktree: WorktreeMemory - storeRecord: Worktree | null - isCollapsed: boolean - onToggle: () => void - onNavigate: () => void - onSleep: () => void - onDelete: () => void - tabsByWorktree: Record - runtimePaneTitlesByTabId: Record> -}): React.JSX.Element { - const hasSessions = worktree.sessions.length > 0 - // Why: actions are only meaningful for real worktrees — orphan/unknown - // rows are synthetic buckets with no row to act on. - const showActions = worktree.worktreeId !== ORPHAN_WORKTREE_ID && storeRecord !== null - const isMainWorktree = storeRecord?.isMainWorktree ?? false - // Why: Worktree.displayName is the user-editable workspace name (set via - // Rename). Fall back to the dirname-shaped worktreeName from the memory - // snapshot for orphan/unresolved rows that have no store record. - const rowLabel = storeRecord?.displayName?.trim() || worktree.worktreeName - - return ( -
-
- {hasSessions ? ( - - ) : ( - // Why this width: matches the chevron button's pl-2 + w-3 + pr-0.5 - // footprint so rows without sessions don't shift horizontally - // relative to rows with sessions. - - )} - -
- {/* Why the relative wrapper + absolute actions: the sparkline - reserves the space so the row width never changes on hover. - The actions fade in on top of the sparkline (which fades out - in the same transition), preventing the layout "jump" that - happened when the sparkline was toggled via display:none. */} -
- - - - {showActions && ( - // Why pointer-events pairing: opacity alone leaves the buttons - // clickable when invisible (touch devices have no hover state), - // so the Delete button can fire on an accidental tap. -
- - - - - - Sleep — close all panels in this workspace to free memory. - - - - - - - - {isMainWorktree ? 'The main workspace cannot be deleted.' : 'Delete workspace.'} - - -
- )} -
- -
-
- - {!isCollapsed && - worktree.sessions.map((session) => ( - // Why: sessionId can be null/fall back to ptyId; combine with pid - // so two sessions from the same process family don't collide in - // React reconciliation. -
- - {sessionRowLabel( - session, - worktree.worktreeId, - tabsByWorktree, - runtimePaneTitlesByTabId - )} - - -
- ))} -
- ) - - // Why: wrap real rows in the shared context menu so right-click exposes -} - -// ─── Segment (top-level) ──────────────────────────────────────────── - -export function MemoryStatusSegment({ - iconOnly -}: { - // Why: `compact` is accepted for uniformity with the other *StatusSegment - // components but is not used — the icon/badge layout already fits inside - // a compact status bar. - compact?: boolean - iconOnly: boolean -}): React.JSX.Element { - const snapshot = useAppStore((s) => s.memorySnapshot) - const fetchSnapshot = useAppStore((s) => s.fetchMemorySnapshot) - // Why: worktree metadata (map, skipDeleteWorktreeConfirm, - // clearWorktreeDeleteState, openModal) is only needed at click time inside - // `deleteWorktree`. This segment is always mounted in the status bar, so - // subscribing to those slices at the top level would cause it (and every - // descendant) to re-render on unrelated worktree metadata churn - // (pin/rename/unread/session). The shared `runWorktreeDelete` helper pulls - // them imperatively via `useAppStore.getState()` instead. - - const [open, setOpen] = useState(false) - const [sortOption, setSortOption] = useState('memory') - const [collapsedRepos, setCollapsedRepos] = useState>(new Set()) - const [collapsedWorktrees, setCollapsedWorktrees] = useState>(new Set()) - // Why: the Orca app breakdown (Main/Renderer/Other) is a diagnostic detail - // most users don't need to see every time — collapse it by default and - // surface the per-worktree usage, which is what people usually open this - // popover to investigate. - const [appCollapsed, setAppCollapsed] = useState(true) - - // Why: only poll while the popover is open. When closed, the badge shows - // whatever value was last fetched — good enough for a passive indicator - // and keeps us from waking the main process every few seconds. - useEffect(() => { - if (!open) { - return - } - void fetchSnapshot() - const timer = window.setInterval(() => { - void fetchSnapshot() - }, POLL_MS) - return () => window.clearInterval(timer) - }, [open, fetchSnapshot]) - - // Derived values are grouped into one memo so open/sort/collapse state - // changes don't recompute them. - const { totalMemory, totalCpu, hostShare, badgeLabel } = useMemo(() => { - const memory = snapshot?.totalMemory ?? 0 - const cpu = snapshot?.totalCpu ?? 0 - const hostTotal = snapshot?.host.totalMemory ?? 0 - return { - totalMemory: memory, - totalCpu: cpu, - hostShare: hostTotal > 0 ? (memory / hostTotal) * 100 : 0, - badgeLabel: snapshot ? formatMemory(memory) : '—' - } - }, [snapshot]) - - // Why empty deps: these callbacks only call the state setter returned by - // useState, which React guarantees is stable across renders — so we don't - // need to list it. Wrapping in useCallback keeps the reference stable across - // the 2s polling re-renders so descendants can be memoized downstream. - const toggleRepo = useCallback((repoId: string): void => { - setCollapsedRepos((prev) => { - const next = new Set(prev) - if (next.has(repoId)) { - next.delete(repoId) - } else { - next.add(repoId) - } - return next - }) - }, []) - - const toggleWorktree = useCallback((worktreeId: string): void => { - setCollapsedWorktrees((prev) => { - const next = new Set(prev) - if (next.has(worktreeId)) { - next.delete(worktreeId) - } else { - next.add(worktreeId) - } - return next - }) - }, []) - - // Deps intentionally empty: only uses the stable setOpen setter and - // module-level imports (ORPHAN_WORKTREE_ID, activateAndRevealWorktree). - const navigateToWorktree = useCallback((worktreeId: string): void => { - // Orphan bucket has a synthetic id with no real worktree to reveal. - if (worktreeId === ORPHAN_WORKTREE_ID) { - setOpen(false) - return - } - // Why: returns false when the worktree has been deleted between - // snapshot capture and this click. Leave the popover open in that - // case so a silent no-op doesn't look like a broken button. - const result = activateAndRevealWorktree(worktreeId) - if (result === false) { - return - } - setOpen(false) - }, []) - - // Why this thin wrapper: the popover needs to close before the modal/toast - // appears (Radix's outside-pointerdown would otherwise dismiss the dialog). - // The actual decision tree lives in `runWorktreeDelete` so both the popover - // and the sidebar context menu stay in sync. - const deleteWorktree = useCallback((worktreeId: string): void => { - setOpen(false) - runWorktreeDelete(worktreeId) - }, []) - - // Stable callback so onSleep prop identity doesn't churn across polls. - const handleSleep = useCallback((id: string): void => { - void runSleepWorktree(id) - }, []) - - return ( - - - - - - - - - Memory — {badgeLabel} - - - - event.preventDefault()} - > - {/* Why: the popover trigger already announces "Memory & CPU", so a - heading row is redundant. We lead with the totals — which is what - most people open this for — on a single compact line. */} - {snapshot && ( -
- - - {/* Why tabIndex on span triggers: Radix Tooltip needs a - focusable child for keyboard reveal; plain isn't - focusable by default. */} - - {formatCpu(totalCpu)} - - - - Combined CPU load. Values above 100% mean more than one core is working at once. - - - · - - - - {formatMemory(totalMemory)} - - - - Resident memory held by Orca plus the processes under each worktree's - terminals. - - - · - - - - {formatPercent(hostShare)} of system RAM - - - - How much of this machine's physical RAM the Orca-tracked processes are sitting - on. - - -
- )} - - {/* Why click-to-sort on the column headers: the headers already - label the columns, so doubling them up with a separate sort - control was pure redundancy. The active column is bolded so - users can see at a glance which one drives the order. */} - {snapshot && ( -
- -
- - -
-
- )} - -
- {snapshot && snapshot.worktrees.length > 0 && ( - - )} - - {snapshot && snapshot.worktrees.length === 0 && ( -
- Nothing running right now -
- )} - - {/* Why Orca App at the bottom: it's a constant baseline everyone has, - so it's less informative than the per-worktree breakdown. Keep - it available but out of the way. */} - {snapshot && ( - setAppCollapsed((v) => !v)} - /> - )} - - {!snapshot && ( -
Loading…
- )} -
-
-
- ) -} diff --git a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx new file mode 100644 index 00000000000..91da475cd11 --- /dev/null +++ b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx @@ -0,0 +1,1287 @@ +/* eslint-disable max-lines -- Why: consolidating memory + sessions into one + surface deliberately co-locates the sparkline, worktree tree, session list, + daemon actions, and kill-confirm dialog so the popover body and badge stay + consistent. Splitting across files would scatter render-state that only + exists to serve this one status-bar segment. See + docs/resource-usage-merge-spec.md for the full design. */ +import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + AlertTriangle, + ChevronDown, + ChevronRight, + LoaderCircle, + MemoryStick, + Moon, + RotateCw, + Terminal, + Trash2, + X +} from 'lucide-react' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { cn } from '@/lib/utils' +import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { useAppStore } from '../../store' +import { useWorktreeMap } from '../../store/selectors' +import { runWorktreeDelete } from '../sidebar/delete-worktree-flow' +import { runSleepWorktree } from '../sidebar/sleep-worktree-flow' +import { useDaemonActions, DaemonActionDialog } from '../shared/useDaemonActions' +import type { AppMemory, UsageValues, Worktree } from '../../../../shared/types' +import { ORPHAN_WORKTREE_ID } from '../../../../shared/constants' +import { + mergeSnapshotAndSessions, + UNATTRIBUTED_REPO_ID, + type DaemonSession, + type Metric, + type UnifiedRepoGroup, + type UnifiedSessionRow, + type UnifiedWorktreeRow +} from './mergeSnapshotAndSessions' + +const POLL_MS = 2_000 +const SESSIONS_POLL_MS = 10_000 + +type SortOption = 'memory' | 'cpu' | 'name' + +const METRIC_COLUMNS_CLS = 'flex items-center shrink-0 tabular-nums' +const CPU_COLUMN_CLS = 'w-12 text-right' +const MEM_COLUMN_CLS = 'w-16 text-right' +// Why: every row (session, worktree, repo, app) AND the column header +// reserve this same trailing gutter so the CPU/Memory columns line up +// regardless of whether a row carries a kill-X. The X button sits inside +// this gutter for session rows; other rows leave it blank. +const ROW_TRAILING_GUTTER_CLS = 'w-5 shrink-0 flex items-center justify-end' + +// ─── Formatters ───────────────────────────────────────────────────── + +function formatMemory(bytes: number): string { + if (bytes < 1024 * 1024) { + return `${Math.round(bytes / 1024)} KB` + } + if (bytes < 1024 * 1024 * 1024) { + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` + } + return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB` +} + +function formatCpu(percent: number): string { + return `${percent.toFixed(1)}%` +} + +function formatPercent(value: number): string { + return `${value.toFixed(0)}%` +} + +function formatMetricCpu(value: Metric): string { + return value === null ? '—' : formatCpu(value) +} + +function formatMetricMemory(value: Metric): string { + return value === null ? '—' : formatMemory(value) +} + +// ─── Sparkline ────────────────────────────────────────────────────── + +type SparklineProps = { + samples: number[] + width?: number + height?: number +} + +function SparklineImpl({ samples, width = 48, height = 14 }: SparklineProps): React.JSX.Element { + const points = useMemo(() => { + const safe = Array.isArray(samples) ? samples : [] + if (safe.length < 2) { + const midY = (height / 2).toFixed(1) + return `0,${midY} ${width},${midY}` + } + + let min = safe[0] + let max = safe[0] + for (const v of safe) { + if (v < min) { + min = v + } + if (v > max) { + max = v + } + } + const range = max - min || 1 + const stepX = width / (safe.length - 1) + + const out: string[] = [] + for (let i = 0; i < safe.length; i++) { + const x = (i * stepX).toFixed(1) + const y = (height - ((safe[i] - min) / range) * height).toFixed(1) + out.push(`${x},${y}`) + } + return out.join(' ') + }, [samples, width, height]) + + return ( + + + + ) +} + +const Sparkline = memo(SparklineImpl, (a, b) => { + if (a.width !== b.width || a.height !== b.height) { + return false + } + const sa = Array.isArray(a.samples) ? a.samples : [] + const sb = Array.isArray(b.samples) ? b.samples : [] + if (sa === sb) { + return true + } + if (sa.length !== sb.length) { + return false + } + for (let i = 0; i < sa.length; i++) { + if (sa[i] !== sb[i]) { + return false + } + } + return true +}) + +// ─── Leaf UI: metric row ──────────────────────────────────────────── + +function MetricPair({ + cpu, + memory, + size = 'base' +}: { + cpu: Metric + memory: Metric + size?: 'base' | 'small' +}): React.JSX.Element { + const textCls = size === 'small' ? 'text-[11px]' : 'text-xs' + const muted = cpu === null && memory === null + return ( +
+ {formatMetricCpu(cpu)} + {formatMetricMemory(memory)} +
+ ) +} + +function AppSubRow({ label, values }: { label: string; values: UsageValues }): React.JSX.Element { + return ( +
+ {label} +
+ + +
+
+ ) +} + +function AppSection({ + app, + isCollapsed, + onToggle +}: { + app: AppMemory + isCollapsed: boolean + onToggle: () => void +}): React.JSX.Element { + return ( +
+
+ +
+ + Orca + +
+ + + +
+
+
+ {!isCollapsed && ( +
+ + + {(app.other.cpu > 0 || app.other.memory > 0) && ( + + )} +
+ )} +
+ ) +} + +// ─── Sorting ──────────────────────────────────────────────────────── + +function compareMetricDesc(a: Metric, b: Metric): number { + // Why: null metrics (remote rows) sort last regardless of direction so + // they don't pollute the "biggest CPU/memory consumers" view. + if (a === null && b === null) { + return 0 + } + if (a === null) { + return 1 + } + if (b === null) { + return -1 + } + return b - a +} + +function sortWorktrees(list: UnifiedWorktreeRow[], sort: SortOption): UnifiedWorktreeRow[] { + const copy = [...list] + if (sort === 'memory') { + copy.sort((a, b) => compareMetricDesc(a.memory, b.memory)) + } else if (sort === 'cpu') { + copy.sort((a, b) => compareMetricDesc(a.cpu, b.cpu)) + } else { + copy.sort((a, b) => a.worktreeName.localeCompare(b.worktreeName)) + } + return copy +} + +function sortRepoGroups(groups: UnifiedRepoGroup[], sort: SortOption): UnifiedRepoGroup[] { + const copy = [...groups] + if (sort === 'memory') { + copy.sort((a, b) => compareMetricDesc(a.memory, b.memory)) + } else if (sort === 'cpu') { + copy.sort((a, b) => compareMetricDesc(a.cpu, b.cpu)) + } else { + copy.sort((a, b) => a.repoName.localeCompare(b.repoName)) + } + return copy +} + +// ─── Session row ──────────────────────────────────────────────────── + +function SessionRow({ + session, + worktreeId, + onNavigate, + onKill +}: { + session: UnifiedSessionRow + worktreeId: string + onNavigate: (tabId: string) => void + onKill: (session: UnifiedSessionRow) => void +}): React.JSX.Element { + const clickable = session.tabId !== null && session.bound + const handleClick = (): void => { + if (clickable && session.tabId) { + onNavigate(session.tabId) + } + } + + return ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + handleClick() + } + } + : undefined + } + data-worktree-id={worktreeId} + > + + + {session.label} + + + {/* Why: kill X lives inside the shared trailing gutter so CPU/Memory + columns stay aligned with the column header (whose gutter is empty). + Bound sessions hide the X until the row is hovered/focused (calm + list); orphan sessions show it always so the "this is reclaimable" + affordance survives. Mirrors Settings > Manage Sessions. */} + + + +
+ ) +} + +// ─── Worktree row ─────────────────────────────────────────────────── + +function WorktreeRow({ + worktree, + storeRecord, + isCollapsed, + onToggle, + onNavigate, + onSleep, + onDelete, + onKillSession, + navigateToTab +}: { + worktree: UnifiedWorktreeRow + storeRecord: Worktree | null + isCollapsed: boolean + onToggle: () => void + onNavigate: () => void + onSleep: () => void + onDelete: () => void + onKillSession: (session: UnifiedSessionRow) => void + navigateToTab: (tabId: string) => void +}): React.JSX.Element { + const hasSessions = worktree.sessions.length > 0 + // Why: synthetic buckets (orphan/unattributed) have no sidebar target to + // reveal. Real and SSH-resolved worktrees both qualify for navigation — + // navigateToWorktree handles the no-store-record case internally by + // bailing out of activateAndRevealWorktree if the worktree isn't known. + const isSynthetic = + worktree.worktreeId === ORPHAN_WORKTREE_ID || worktree.repoId === UNATTRIBUTED_REPO_ID + const isNavigable = !isSynthetic + // Why: Sleep / Delete affordances act on a sidebar worktree record; without + // one (synthesized SSH rows whose worktreeId isn't in worktreeById, or + // synthetic buckets) we hide them but keep the row clickable for navigation. + const showWorktreeActions = !isSynthetic && storeRecord !== null + const isMainWorktree = storeRecord?.isMainWorktree ?? false + const rowLabel = storeRecord?.displayName?.trim() || worktree.worktreeName + + return ( +
+
+ {hasSessions ? ( + + ) : ( + + )} + +
+
+ + + + {showWorktreeActions && ( +
+ + + + + + Sleep — close all panels in this workspace to free memory. + + + + + + + + {isMainWorktree ? 'The main workspace cannot be deleted.' : 'Delete workspace.'} + + +
+ )} +
+ + +
+
+ + {!isCollapsed && + worktree.sessions.map((session) => ( + + ))} +
+ ) +} + +// ─── Repo + worktree tree ─────────────────────────────────────────── + +function ResourceTree({ + repos, + sortOption, + collapsedRepos, + toggleRepo, + collapsedWorktrees, + toggleWorktree, + navigateToWorktree, + navigateToTab, + onSleep, + onDelete, + onKillSession +}: { + repos: UnifiedRepoGroup[] + sortOption: SortOption + collapsedRepos: Set + toggleRepo: (repoId: string) => void + collapsedWorktrees: Set + toggleWorktree: (worktreeId: string) => void + navigateToWorktree: (worktreeId: string) => void + navigateToTab: (tabId: string) => void + onSleep: (worktreeId: string) => void + onDelete: (worktreeId: string) => void + onKillSession: (session: UnifiedSessionRow) => void +}): React.JSX.Element { + const worktreeById = useWorktreeMap() + + const sortedRepos = useMemo(() => { + const grouped = sortRepoGroups(repos, sortOption) + return grouped.map((repo) => ({ + ...repo, + worktrees: sortWorktrees(repo.worktrees, sortOption) + })) + }, [repos, sortOption]) + + const renderWorktree = (wt: UnifiedWorktreeRow): React.JSX.Element => { + const storeRecord = worktreeById.get(wt.worktreeId) ?? null + return ( + toggleWorktree(wt.worktreeId)} + onNavigate={() => navigateToWorktree(wt.worktreeId)} + onSleep={() => onSleep(wt.worktreeId)} + onDelete={() => onDelete(wt.worktreeId)} + onKillSession={onKillSession} + navigateToTab={navigateToTab} + /> + ) + } + + if (sortedRepos.length === 1) { + return <>{sortedRepos[0].worktrees.map(renderWorktree)} + } + + return ( + <> + {sortedRepos.map((group) => { + const repoCollapsed = collapsedRepos.has(group.repoId) + return ( +
+
+ +
+ + + {group.repoName} + + {group.hasRemoteChildren && ( + + · remote + + )} + +
+ + +
+
+
+ + {!repoCollapsed && ( +
{group.worktrees.map(renderWorktree)}
+ )} +
+ ) + })} + + ) +} + +// ─── Top-level segment ────────────────────────────────────────────── + +export function ResourceUsageStatusSegment({ + iconOnly +}: { + compact?: boolean + iconOnly: boolean +}): React.JSX.Element { + const snapshot = useAppStore((s) => s.memorySnapshot) + const memorySnapshotError = useAppStore((s) => s.memorySnapshotError) + const fetchSnapshot = useAppStore((s) => s.fetchMemorySnapshot) + const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady) + const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId) + const tabsByWorktree = useAppStore((s) => s.tabsByWorktree) + const runtimePaneTitlesByTabId = useAppStore((s) => s.runtimePaneTitlesByTabId) + const setActiveTab = useAppStore((s) => s.setActiveTab) + const setActiveView = useAppStore((s) => s.setActiveView) + const repos = useAppStore((s) => s.repos) + + const [open, setOpen] = useState(false) + const [sortOption, setSortOption] = useState('memory') + const [collapsedRepos, setCollapsedRepos] = useState>(new Set()) + const [collapsedWorktrees, setCollapsedWorktrees] = useState>(new Set()) + const [appCollapsed, setAppCollapsed] = useState(true) + const [sessions, setSessions] = useState([]) + const [sessionsError, setSessionsError] = useState(false) + const [killConfirm, setKillConfirm] = useState(null) + const [killing, setKilling] = useState(false) + + // Why: after a kill confirms and the session unmounts, focus would otherwise + // fall to . We park a ref on the popover body so we can restore focus + // somewhere stable for keyboard users. + const popoverBodyRef = useRef(null) + + const refreshSessions = useCallback(async () => { + try { + const result = await window.api.pty.listSessions() + setSessions(result) + setSessionsError(false) + } catch { + setSessionsError(true) + } + }, []) + + const daemonActions = useDaemonActions({ + onRestartSettled: () => { + setSessionsError(false) + void fetchSnapshot() + void refreshSessions() + }, + onKillAllSettled: () => { + void refreshSessions() + } + }) + + // Poll memory + sessions when popover is open. Sessions also poll in the + // background at a slower rate so the badge count stays reasonably fresh + // without keeping the Memory IPC hot. + useEffect(() => { + if (!open) { + return + } + void fetchSnapshot() + void refreshSessions() + const memTimer = window.setInterval(() => { + void fetchSnapshot() + }, POLL_MS) + const sessTimer = window.setInterval(() => { + void refreshSessions() + }, SESSIONS_POLL_MS) + return () => { + window.clearInterval(memTimer) + window.clearInterval(sessTimer) + } + }, [open, fetchSnapshot, refreshSessions]) + + useEffect(() => { + const interval = setInterval(() => void refreshSessions(), SESSIONS_POLL_MS) + void refreshSessions() + return () => clearInterval(interval) + }, [refreshSessions]) + + const repoDisplayNameById = useMemo(() => { + const map = new Map() + for (const repo of repos) { + const display = repo.displayName?.trim() + if (display) { + map.set(repo.id, display) + } + } + return map + }, [repos]) + + // Why: skip the merge entirely when the popover is closed. The merged + // tree is only ever displayed inside ; computing it on + // every store mutation (e.g. runtimePaneTitlesByTabId, which changes on + // every keystroke in any open terminal pane) was making the whole app + // feel laggy because the segment is always mounted in the status bar. + const unifiedRepos = useMemo( + () => + open + ? mergeSnapshotAndSessions(snapshot, sessions, { + tabsByWorktree, + ptyIdsByTabId, + runtimePaneTitlesByTabId, + workspaceSessionReady, + repoDisplayNameById + }) + : [], + [ + open, + snapshot, + sessions, + tabsByWorktree, + ptyIdsByTabId, + runtimePaneTitlesByTabId, + workspaceSessionReady, + repoDisplayNameById + ] + ) + + // Why: orphanCount drives the trigger badge (always visible in the status + // bar, popover open or not) so it must compute outside the open-gate. + // Build the bound set with a single flat walk instead of nested Object + // iterations to keep this light on every store update. + const orphanCount = useMemo(() => { + if (!workspaceSessionReady) { + return 0 + } + const bound = new Set() + for (const ids of Object.values(ptyIdsByTabId)) { + for (const id of ids) { + if (id) { + bound.add(id) + } + } + } + let n = 0 + for (const s of sessions) { + if (!bound.has(s.id)) { + n++ + } + } + return n + }, [sessions, ptyIdsByTabId, workspaceSessionReady]) + + const { totalMemory, totalCpu, hostShare, memBadgeLabel } = useMemo(() => { + const memory = snapshot?.totalMemory ?? 0 + const cpu = snapshot?.totalCpu ?? 0 + const hostTotal = snapshot?.host.totalMemory ?? 0 + return { + totalMemory: memory, + totalCpu: cpu, + hostShare: hostTotal > 0 ? (memory / hostTotal) * 100 : 0, + memBadgeLabel: snapshot ? formatMemory(memory) : '—' + } + }, [snapshot]) + + const daemonUnreachable = sessionsError && memorySnapshotError !== null + // Why: a partial failure where the sessions IPC fails but the snapshot + // IPC still works was silently invisible after the merge — the old + // SessionsTabPanel surfaced it as "Terminal sessions unavailable". Show + // a slim inline notice so the user understands why the session list is + // empty/stale even though the resource numbers look fine. + const sessionsOnlyError = sessionsError && memorySnapshotError === null + + const toggleRepo = useCallback((repoId: string): void => { + setCollapsedRepos((prev) => { + const next = new Set(prev) + if (next.has(repoId)) { + next.delete(repoId) + } else { + next.add(repoId) + } + return next + }) + }, []) + + const toggleWorktree = useCallback((worktreeId: string): void => { + setCollapsedWorktrees((prev) => { + const next = new Set(prev) + if (next.has(worktreeId)) { + next.delete(worktreeId) + } else { + next.add(worktreeId) + } + return next + }) + }, []) + + // Why: navigation handlers leave the popover open so users can chain + // multiple jumps (e.g. browse worktrees, hop between terminals) without + // having to re-open the popover each time. The popover still closes via + // its own outside-click / Escape handlers. + const navigateToWorktree = useCallback((worktreeId: string): void => { + if (worktreeId === ORPHAN_WORKTREE_ID || worktreeId.startsWith(`${UNATTRIBUTED_REPO_ID}::`)) { + return + } + activateAndRevealWorktree(worktreeId) + }, []) + + const navigateToTab = useCallback( + (tabId: string) => { + // Resolve the tab → worktree from the store so we can also reveal the + // worktree in the sidebar before flipping the active tab. + for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { + if (tabs.some((t) => t.id === tabId)) { + activateAndRevealWorktree(worktreeId) + break + } + } + setActiveView('terminal') + setActiveTab(tabId) + }, + [tabsByWorktree, setActiveTab, setActiveView] + ) + + const deleteWorktree = useCallback((worktreeId: string): void => { + setOpen(false) + runWorktreeDelete(worktreeId) + }, []) + + const handleSleep = useCallback((id: string): void => { + void runSleepWorktree(id) + }, []) + + const handleKillSession = useCallback( + (session: UnifiedSessionRow): void => { + // Why: orphan sessions have no tab in this Orca instance, so there's + // no "unsaved work in that pane" the user could lose by killing them. + // Skip the confirm dialog for orphans and fire the kill straight away + // (with optimistic removal) — same UX as a one-off kill from the + // bulk "Kill orphan terminals" button. Bound sessions still confirm. + if (!session.bound) { + setSessions((prev) => prev.filter((s) => s.id !== session.sessionId)) + void window.api.pty.kill(session.sessionId).catch(() => { + /* already dead */ + }) + void refreshSessions() + return + } + setKillConfirm(session) + }, + [refreshSessions] + ) + + const handleKillOrphans = useCallback(async () => { + if (!workspaceSessionReady) { + return + } + const bound = new Set() + for (const ids of Object.values(ptyIdsByTabId)) { + for (const id of ids) { + if (id) { + bound.add(id) + } + } + } + const orphans = sessions.filter((s) => !bound.has(s.id)) + if (orphans.length === 0) { + return + } + // Why: optimistic removal so the rows disappear immediately rather than + // lingering up to SESSIONS_POLL_MS while the daemon-side list reconciles. + const orphanIds = new Set(orphans.map((s) => s.id)) + setSessions((prev) => prev.filter((s) => !orphanIds.has(s.id))) + await Promise.allSettled(orphans.map((s) => window.api.pty.kill(s.id))) + void refreshSessions() + }, [sessions, ptyIdsByTabId, workspaceSessionReady, refreshSessions]) + + const runKillConfirmed = useCallback(async () => { + if (!killConfirm) { + return + } + const target = killConfirm + setKilling(true) + // Why: optimistic removal — the kill X was on the row that's about to be + // unmounted, so updating local state immediately avoids a flash where the + // dialog closes but the killed row stays for up to 10s. + setSessions((prev) => prev.filter((s) => s.id !== target.sessionId)) + try { + await window.api.pty.kill(target.sessionId) + } catch { + /* already dead — fall through */ + } finally { + setKilling(false) + setKillConfirm(null) + // Why: after the killed row unmounts, focus would otherwise drop to + // . Park focus on the popover body so keyboard users land back + // in the list rather than outside the popover. + requestAnimationFrame(() => { + popoverBodyRef.current?.focus() + }) + void refreshSessions() + } + }, [killConfirm, refreshSessions]) + + return ( + + + + + + + + + Resource usage — {memBadgeLabel} · {sessions.length} session + {sessions.length === 1 ? '' : 's'} + + + + event.preventDefault()} + // Why: clicking a terminal row calls setActiveTab, which causes + // xterm to programmatically focus the terminal DOM node. Radix + // would interpret that as a focus-outside event and close the + // popover. Suppress focus-driven closes; the popover still closes + // on outside-click (onPointerDownOutside default) and Escape. + onFocusOutside={(event) => event.preventDefault()} + > + {/* Why: header strip — title on the left, daemon-control icons on + the right. The tab switcher was removed because a single unified + list now covers what the two tabs used to show. */} +
+
+ + Resource Usage +
+ +
+ + + + + + Restart daemon + + + + + + + + Kill all sessions + + +
+
+ + {daemonUnreachable && ( +
+ +
+
Daemon is not responding
+
+ Resource snapshots and terminal sessions are unavailable. +
+
+ +
+ )} + + {!daemonUnreachable && sessionsOnlyError && ( +
+ + Terminal sessions unavailable. The list may be stale. +
+ )} + + {snapshot && ( +
+
+ + + + {formatCpu(totalCpu)} + + + + Combined CPU load. Values above 100% mean more than one core is working at once. + + + · + + + + {formatMemory(totalMemory)} + + + + Resident memory held by Orca plus the processes under each worktree's + terminals. + + + · + + + + {formatPercent(hostShare)} of system RAM + + + + How much of this machine's physical RAM the Orca-tracked processes are + sitting on. + + +
+ {orphanCount > 0 && ( + + {orphanCount} orphan{orphanCount === 1 ? '' : 's'} + + )} +
+ )} + + {/* Why: pin body to a constant 420px so the popover surface doesn't + jump as worktrees expand/collapse or as sessions come and go. The + inner tree owns its own scroll. The footer renders below this + shell when orphan-bulk-kill is available. */} +
+ {(unifiedRepos.length > 0 || snapshot) && ( +
+ +
+
+ + +
+ {/* Why: empty trailing gutter so the CPU/Memory header + cells line up with the row cells; rows reserve the same + width for the kill-X button. */} + +
+
+ )} + +
+ {unifiedRepos.length > 0 && ( + + )} + + {unifiedRepos.length === 0 && snapshot && ( +
+ Nothing running right now +
+ )} + + {snapshot && ( + setAppCollapsed((v) => !v)} + /> + )} + + {!snapshot && !daemonUnreachable && ( +
Loading…
+ )} +
+
+ + {orphanCount > 0 && ( +
+ +
+ )} + + { + if (next) { + return + } + if (killing) { + return + } + setKillConfirm(null) + }} + > + { + if (killing) { + e.preventDefault() + } + }} + onEscapeKeyDown={(e) => { + if (killing) { + e.preventDefault() + } + }} + > + + + Kill{' '} + + {killConfirm?.label ?? 'this session'} + + ? + + + Force-quits this terminal. Any unsaved work in the pane is lost. This can't be + undone. + + + + + + + + +
+ +
+ ) +} diff --git a/src/renderer/src/components/status-bar/SessionsStatusSegment.tsx b/src/renderer/src/components/status-bar/SessionsStatusSegment.tsx deleted file mode 100644 index 72739372326..00000000000 --- a/src/renderer/src/components/status-bar/SessionsStatusSegment.tsx +++ /dev/null @@ -1,310 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react' -import { Terminal, Trash2 } from 'lucide-react' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuSeparator, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' -import { useAppStore } from '../../store' -import { activateAndRevealWorktree } from '@/lib/worktree-activation' - -type DaemonSession = { id: string; cwd: string; title: string } - -function shortCwd(cwd: string): string { - if (!cwd) { - return 'unknown' - } - const separator = cwd.includes('\\') ? '\\' : '/' - const parts = cwd.split(/[\\/]+/).filter(Boolean) - return parts.length > 2 ? parts.slice(-2).join(separator) : cwd -} - -function sessionLabel(session: DaemonSession, tabTitle?: string): string { - if (session.cwd) { - return shortCwd(session.cwd) - } - // Why: daemon session IDs use the format `${worktreeId}@@${shortUuid}`. - // When the shell hasn't emitted OSC 7 cwd updates (e.g. Claude Code agents), - // fall back to showing the worktreeId portion so the user can identify which - // worktree the session belongs to. - const sep = session.id.lastIndexOf('@@') - if (sep !== -1) { - const worktreeId = session.id.slice(0, sep) - return shortCwd(worktreeId) - } - // Why: for sessions with legacy numeric IDs (e.g. from LocalPtyProvider) or - // sessions whose shells haven't emitted OSC 7, use the tab title from the - // renderer store — it carries the live terminal title shown in the tab bar. - // Fall back to the daemon-reported title (typically "shell") so orphan - // sessions without a bound tab still display something meaningful. - if (tabTitle) { - return tabTitle - } - if (session.title) { - return session.title - } - return 'unknown' -} - -function SessionRow({ - session, - isBound, - tabId, - tabTitle, - onKill, - onNavigate -}: { - session: DaemonSession - isBound: boolean - tabId: string | null - tabTitle: string | undefined - onKill: (id: string) => void - onNavigate: (tabId: string) => void -}): React.JSX.Element { - return ( -
onNavigate(tabId) : undefined} - > - -
-
- {sessionLabel(session, tabTitle)} -
-
- {!isBound && ( - - )} -
- ) -} - -export function SessionsStatusSegment({ - compact: _compact, - iconOnly -}: { - compact: boolean - iconOnly: boolean -}): React.JSX.Element { - const [sessions, setSessions] = useState([]) - const [open, setOpen] = useState(false) - const tabsByWorktree = useAppStore((s) => s.tabsByWorktree) - const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId) - const runtimePaneTitlesByTabId = useAppStore((s) => s.runtimePaneTitlesByTabId) - const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady) - const setActiveTab = useAppStore((s) => s.setActiveTab) - const setActiveView = useAppStore((s) => s.setActiveView) - - const boundPtyIds = useMemo( - () => new Set(Object.values(ptyIdsByTabId).flat().filter(Boolean)), - [ptyIdsByTabId] - ) - - // Why: ptyIdsByTabId tracks all ptyIds a tab has ever been associated with - // (including split panes). Build a reverse map so we can navigate from a - // daemon session ID back to the tab that owns it. - const ptyIdToTabId = useMemo(() => { - const map = new Map() - for (const [tabId, ptyIds] of Object.entries(ptyIdsByTabId)) { - for (const ptyId of ptyIds) { - map.set(ptyId, tabId) - } - } - return map - }, [ptyIdsByTabId]) - - const tabIdToWorktreeId = useMemo(() => { - const map = new Map() - for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { - for (const tab of tabs) { - map.set(tab.id, worktreeId) - } - } - return map - }, [tabsByWorktree]) - - // Why: the daemon IPC response has sparse metadata (cwd may be null, title is - // hardcoded to "shell"), but the renderer store already knows each tab's live - // title. Build a ptyId→title map so the popover can show the same name as the - // tab bar for bound sessions. - const ptyIdToTabTitle = useMemo(() => { - // Why: build a tabId→tab lookup once up-front instead of doing - // O(totalTabs) .flat().find() per ptyIdsByTabId entry. - const tabById = new Map() - for (const tabs of Object.values(tabsByWorktree)) { - for (const tab of tabs) { - tabById.set(tab.id, tab) - } - } - const map = new Map() - for (const [tabId, ptyIds] of Object.entries(ptyIdsByTabId)) { - const tab = tabById.get(tabId) - if (!tab) { - continue - } - // Why: runtimePaneTitlesByTabId holds the most current OSC-reported title - // for each mounted pane — it updates on every title change regardless of - // which split pane is focused. Prefer the first non-empty runtime pane - // title, then the persisted tab.title (which only updates for the focused - // pane), then the stable defaultTitle. - const runtimeTitles = runtimePaneTitlesByTabId[tabId] - const liveTitle = runtimeTitles - ? Object.values(runtimeTitles).find((t) => t?.trim()) - : undefined - const title = - tab.customTitle?.trim() || liveTitle?.trim() || tab.title || tab.defaultTitle?.trim() - if (title) { - for (const ptyId of ptyIds) { - map.set(ptyId, title) - } - } - } - return map - }, [ptyIdsByTabId, tabsByWorktree, runtimePaneTitlesByTabId]) - - const refresh = useCallback(async () => { - try { - const result = await window.api.pty.listSessions() - setSessions(result) - } catch { - setSessions([]) - } - }, []) - - useEffect(() => { - if (open) { - void refresh() - } - }, [open, refresh]) - - useEffect(() => { - const interval = setInterval(() => void refresh(), 10_000) - void refresh() - return () => clearInterval(interval) - }, [refresh]) - - const orphanCount = workspaceSessionReady - ? sessions.filter((s) => !boundPtyIds.has(s.id)).length - : 0 - - const handleKill = useCallback( - async (id: string) => { - try { - await window.api.pty.kill(id) - } catch { - /* already dead */ - } - await refresh() - }, - [refresh] - ) - - const handleKillOrphans = useCallback(async () => { - if (!workspaceSessionReady) { - return - } - const orphans = sessions.filter((s) => !boundPtyIds.has(s.id)) - await Promise.allSettled(orphans.map((s) => window.api.pty.kill(s.id))) - await refresh() - }, [sessions, boundPtyIds, refresh, workspaceSessionReady]) - - const handleNavigate = useCallback( - (tabId: string) => { - const worktreeId = tabIdToWorktreeId.get(tabId) - if (worktreeId) { - // Why: opening a session from the status bar is another worktree jump, - // so route it through the shared activation path before restoring the - // specific tab inside that worktree. - activateAndRevealWorktree(worktreeId) - } - setActiveView('terminal') - setActiveTab(tabId) - }, - [tabIdToWorktreeId, setActiveView, setActiveTab] - ) - - return ( - - - - - -
- Terminal Sessions ({sessions.length}) -
- {sessions.length === 0 ? ( -
- No active sessions -
- ) : ( -
- {[...sessions] - .sort((a, b) => { - const aBound = workspaceSessionReady && boundPtyIds.has(a.id) ? 0 : 1 - const bBound = workspaceSessionReady && boundPtyIds.has(b.id) ? 0 : 1 - return aBound - bBound - }) - .map((s) => { - const tabId = ptyIdToTabId.get(s.id) ?? null - return ( - - ) - })} -
- )} - {orphanCount > 0 && ( - <> - -
- -
- - )} -
-
- ) -} diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index 55d8c8d56c3..609473a86e8 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -1,15 +1,7 @@ /* eslint-disable max-lines -- Why: the status bar keeps provider rendering, interaction menus, and compact-layout behavior together so the hover/click states stay consistent across Claude and Codex. */ -import { - AlertTriangle, - ChevronDown, - ChevronRight, - MemoryStick as MemoryStickIcon, - RefreshCw, - Server, - Terminal -} from 'lucide-react' +import { AlertTriangle, Activity, ChevronDown, ChevronRight, RefreshCw, Server } from 'lucide-react' import React, { useCallback, useEffect, useRef, useState } from 'react' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { @@ -32,9 +24,8 @@ import { ClaudeIcon, GeminiIcon, OpenAIIcon, OpenCodeGoIcon } from './icons' import { formatWindowLabel } from '@/lib/window-label-formatter' import { markLiveCodexSessionsForRestart } from '@/lib/codex-session-restart' import { SshStatusSegment } from './SshStatusSegment' -import { SessionsStatusSegment } from './SessionsStatusSegment' import { UpdateStatusSegment } from './UpdateStatusSegment' -import { MemoryStatusSegment } from './MemoryStatusSegment' +import { ResourceUsageStatusSegment } from './ResourceUsageStatusSegment' import { SidekickStatusSegment } from './SidekickStatusSegment' function getCodexAccountLabel( @@ -774,9 +765,8 @@ function StatusBarInner(): React.JSX.Element | null { const showGemini = gemini !== null && statusBarItems.includes('gemini') const showOpencodeGo = opencodeGo !== null && statusBarItems.includes('opencode-go') const showSsh = statusBarItems.includes('ssh') - const showSessions = statusBarItems.includes('sessions') - const showMemory = statusBarItems.includes('memory') - const anyVisible = showClaude || showCodex || showGemini || showOpencodeGo || showMemory + const showResourceUsage = statusBarItems.includes('resource-usage') + const anyVisible = showClaude || showCodex || showGemini || showOpencodeGo || showResourceUsage const anyFetching = claude?.status === 'fetching' || codex?.status === 'fetching' || @@ -850,8 +840,7 @@ function StatusBarInner(): React.JSX.Element | null {
{sidekickEnabled && } - {showMemory && } - {showSessions && } + {showResourceUsage && } {showSsh && }
@@ -901,18 +890,11 @@ function StatusBarInner(): React.JSX.Element | null { SSH Status toggleStatusBarItem('sessions')} + checked={statusBarItems.includes('resource-usage')} + onCheckedChange={() => toggleStatusBarItem('resource-usage')} > - - Terminal Sessions - - toggleStatusBarItem('memory')} - > - - Memory + + Resource Usage diff --git a/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.test.ts b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.test.ts new file mode 100644 index 00000000000..92641313b54 --- /dev/null +++ b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.test.ts @@ -0,0 +1,286 @@ +import { describe, expect, it } from 'vitest' +import type { MemorySnapshot, TerminalTab, WorktreeMemory } from '../../../../shared/types' +import { + mergeSnapshotAndSessions, + UNATTRIBUTED_REPO_ID, + type DaemonSession, + type MergeContext +} from './mergeSnapshotAndSessions' + +function emptyAppMemory() { + return { + cpu: 0, + memory: 0, + main: { cpu: 0, memory: 0 }, + renderer: { cpu: 0, memory: 0 }, + other: { cpu: 0, memory: 0 }, + history: [] + } +} + +function makeSnapshot(worktrees: WorktreeMemory[]): MemorySnapshot { + return { + app: emptyAppMemory(), + worktrees, + host: { + totalMemory: 16e9, + freeMemory: 8e9, + usedMemory: 8e9, + memoryUsagePercent: 50, + cpuCoreCount: 8, + loadAverage1m: 0 + }, + totalCpu: worktrees.reduce((s, w) => s + w.cpu, 0), + totalMemory: worktrees.reduce((s, w) => s + w.memory, 0), + collectedAt: 0 + } +} + +function makeTab(id: string, defaultTitle = 'Terminal'): TerminalTab { + return { + id, + title: defaultTitle, + defaultTitle, + customTitle: null, + type: 'terminal', + paneCount: 1 + } as unknown as TerminalTab +} + +const baseCtx = (overrides: Partial = {}): MergeContext => ({ + tabsByWorktree: {}, + ptyIdsByTabId: {}, + runtimePaneTitlesByTabId: {}, + workspaceSessionReady: true, + repoDisplayNameById: new Map(), + ...overrides +}) + +describe('mergeSnapshotAndSessions', () => { + it('returns empty list when both inputs are empty', () => { + expect(mergeSnapshotAndSessions(null, [], baseCtx())).toEqual([]) + }) + + it('passes through snapshot worktrees with numeric metrics and hasLocalSamples', () => { + const wt: WorktreeMemory = { + worktreeId: 'orca::/Users/me/Triton', + worktreeName: 'Triton', + repoId: 'orca', + repoName: 'ORCA', + cpu: 1.5, + memory: 100_000_000, + history: [1, 2, 3], + sessions: [{ sessionId: 'pty-1', paneKey: null, pid: 1234, cpu: 1.5, memory: 100_000_000 }] + } + const out = mergeSnapshotAndSessions(makeSnapshot([wt]), [], baseCtx()) + expect(out).toHaveLength(1) + expect(out[0]).toMatchObject({ + repoId: 'orca', + repoName: 'ORCA', + cpu: 1.5, + memory: 100_000_000, + hasRemoteChildren: false + }) + expect(out[0].worktrees[0]).toMatchObject({ + worktreeName: 'Triton', + cpu: 1.5, + memory: 100_000_000, + hasLocalSamples: true + }) + expect(out[0].worktrees[0].sessions[0]).toMatchObject({ + sessionId: 'pty-1', + cpu: 1.5, + memory: 100_000_000, + hasLocalSamples: true + }) + }) + + it('dedups: a session present in both snapshot and daemon list renders once with numeric metrics', () => { + const wt: WorktreeMemory = { + worktreeId: 'orca::/Users/me/Triton', + worktreeName: 'Triton', + repoId: 'orca', + repoName: 'ORCA', + cpu: 0.1, + memory: 50_000_000, + history: [], + sessions: [{ sessionId: 'pty-1', paneKey: null, pid: 999, cpu: 0.1, memory: 50_000_000 }] + } + const ds: DaemonSession[] = [{ id: 'pty-1', cwd: '/Users/me/Triton', title: 'shell' }] + const out = mergeSnapshotAndSessions(makeSnapshot([wt]), ds, baseCtx()) + expect(out[0].worktrees[0].sessions).toHaveLength(1) + expect(out[0].worktrees[0].sessions[0]).toMatchObject({ + sessionId: 'pty-1', + hasLocalSamples: true, + cpu: 0.1, + memory: 50_000_000 + }) + }) + + it('@@ parse: an SSH-style session id resolves to its worktree group', () => { + const ds: DaemonSession[] = [ + { id: 'orca::/remote/Stingray@@abcd1234', cwd: '', title: 'orca/Stingray' } + ] + const out = mergeSnapshotAndSessions(null, ds, baseCtx()) + expect(out).toHaveLength(1) + expect(out[0]).toMatchObject({ + repoId: 'orca', + hasRemoteChildren: true, + cpu: null, + memory: null + }) + expect(out[0].worktrees[0]).toMatchObject({ + worktreeId: 'orca::/remote/Stingray', + worktreeName: 'Stingray', + hasLocalSamples: false, + cpu: null, + memory: null + }) + expect(out[0].worktrees[0].sessions[0]).toMatchObject({ + sessionId: 'orca::/remote/Stingray@@abcd1234', + hasLocalSamples: false, + cpu: null, + memory: null, + bound: false + }) + }) + + it('tab walk wins over @@ parse when they disagree', () => { + const tabId = 'tab-xyz' + const ds: DaemonSession[] = [{ id: 'orca::/wrong/path@@feedface', cwd: '', title: 'orca' }] + const ctx = baseCtx({ + tabsByWorktree: { + 'orca::/correct/path': [makeTab(tabId, 'My Tab')] + }, + ptyIdsByTabId: { [tabId]: ['orca::/wrong/path@@feedface'] } + }) + const out = mergeSnapshotAndSessions(null, ds, ctx) + expect(out[0].worktrees[0].worktreeId).toBe('orca::/correct/path') + expect(out[0].worktrees[0].sessions[0].tabId).toBe(tabId) + expect(out[0].worktrees[0].sessions[0].bound).toBe(true) + }) + + it('repo aggregate excludes remote children but flags hasRemoteChildren', () => { + const localWt: WorktreeMemory = { + worktreeId: 'orca::/local/Triton', + worktreeName: 'Triton', + repoId: 'orca', + repoName: 'ORCA', + cpu: 0.5, + memory: 125_000_000, + history: [], + sessions: [] + } + const ds: DaemonSession[] = [ + { id: 'orca::/remote/Stingray@@1234', cwd: '', title: 'orca/Stingray' } + ] + const out = mergeSnapshotAndSessions(makeSnapshot([localWt]), ds, baseCtx()) + expect(out).toHaveLength(1) + expect(out[0]).toMatchObject({ + repoId: 'orca', + cpu: 0.5, + memory: 125_000_000, + hasRemoteChildren: true + }) + expect(out[0].worktrees).toHaveLength(2) + const local = out[0].worktrees.find((w) => w.hasLocalSamples)! + const remote = out[0].worktrees.find((w) => !w.hasLocalSamples)! + expect(local.cpu).toBe(0.5) + expect(remote.cpu).toBeNull() + }) + + it('unresolvable session falls into unattributed bucket', () => { + const ds: DaemonSession[] = [{ id: 'opaque-id-without-prefix', cwd: '', title: 'shell' }] + const out = mergeSnapshotAndSessions(null, ds, baseCtx()) + expect(out).toHaveLength(1) + expect(out[0].repoId).toBe(UNATTRIBUTED_REPO_ID) + expect(out[0].hasRemoteChildren).toBe(true) + expect(out[0].worktrees[0].sessions[0].sessionId).toBe('opaque-id-without-prefix') + }) + + it('local-bound interaction state: numeric metrics + bound=true + tabId set', () => { + const tabId = 'tab-1' + const wt: WorktreeMemory = { + worktreeId: 'orca::/Users/me/Triton', + worktreeName: 'Triton', + repoId: 'orca', + repoName: 'ORCA', + cpu: 0.1, + memory: 1_000, + history: [], + sessions: [{ sessionId: 'pty-bound', paneKey: null, pid: 1, cpu: 0.1, memory: 1_000 }] + } + const ctx = baseCtx({ + tabsByWorktree: { 'orca::/Users/me/Triton': [makeTab(tabId)] }, + ptyIdsByTabId: { [tabId]: ['pty-bound'] } + }) + const out = mergeSnapshotAndSessions(makeSnapshot([wt]), [], ctx) + const session = out[0].worktrees[0].sessions[0] + expect(session).toMatchObject({ + hasLocalSamples: true, + bound: true, + tabId + }) + }) + + it('local-orphan interaction state: numeric metrics + bound=false + tabId null', () => { + const wt: WorktreeMemory = { + worktreeId: 'orca::/Users/me/Triton', + worktreeName: 'Triton', + repoId: 'orca', + repoName: 'ORCA', + cpu: 0, + memory: 0, + history: [], + sessions: [{ sessionId: 'pty-orph', paneKey: null, pid: 0, cpu: 0, memory: 0 }] + } + const out = mergeSnapshotAndSessions(makeSnapshot([wt]), [], baseCtx()) + const session = out[0].worktrees[0].sessions[0] + expect(session.bound).toBe(false) + expect(session.tabId).toBeNull() + expect(session.hasLocalSamples).toBe(true) + }) + + it('remote-orphan interaction state: null metrics + bound=false', () => { + const ds: DaemonSession[] = [{ id: 'orca::/remote/Wt@@deadbeef', cwd: '', title: 'orca/Wt' }] + const out = mergeSnapshotAndSessions(null, ds, baseCtx()) + const session = out[0].worktrees[0].sessions[0] + expect(session).toMatchObject({ + hasLocalSamples: false, + cpu: null, + memory: null, + bound: false, + tabId: null + }) + }) + + it('uses repoDisplayNameById to humanize new repo groups when available', () => { + const ds: DaemonSession[] = [{ id: 'stably-ai/orca::/remote/Wt@@1', cwd: '', title: '' }] + const ctx = baseCtx({ + repoDisplayNameById: new Map([['stably-ai/orca', 'ORCA']]) + }) + const out = mergeSnapshotAndSessions(null, ds, ctx) + expect(out[0].repoName).toBe('ORCA') + }) + + it('workspaceSessionReady=false suppresses bound flags so nothing looks bound prematurely', () => { + const tabId = 'tab-1' + const wt: WorktreeMemory = { + worktreeId: 'orca::/Users/me/Triton', + worktreeName: 'Triton', + repoId: 'orca', + repoName: 'ORCA', + cpu: 0, + memory: 0, + history: [], + sessions: [{ sessionId: 'pty-1', paneKey: null, pid: 1, cpu: 0, memory: 0 }] + } + const ctx = baseCtx({ + workspaceSessionReady: false, + tabsByWorktree: { 'orca::/Users/me/Triton': [makeTab(tabId)] }, + ptyIdsByTabId: { [tabId]: ['pty-1'] } + }) + const out = mergeSnapshotAndSessions(makeSnapshot([wt]), [], ctx) + expect(out[0].worktrees[0].sessions[0].bound).toBe(false) + }) +}) diff --git a/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts new file mode 100644 index 00000000000..27644c48444 --- /dev/null +++ b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts @@ -0,0 +1,417 @@ +/* eslint-disable max-lines -- Why: this module deliberately co-locates the + renderer-local view-model types, the merge function, and its supporting + string-manipulation helpers because they exist solely to feed the popover + in ResourceUsageStatusSegment.tsx. Splitting would scatter logic that has + exactly one consumer. See docs/resource-usage-merge-spec.md. */ +/** + * Resource Usage popover merge helper. + * + * Produces a single grouped list (repo → worktree → session) by unifying: + * + * - `MemorySnapshot.worktrees` — local PTYs only, with numeric CPU/Mem + * per worktree and per session (the local memory collector doesn't see + * SSH process trees, by design — see src/main/memory/collector.ts and + * the registerPty branch at src/main/ipc/pty.ts:832). + * - `pty.listSessions()` — every PTY the daemon tracks, local or SSH. + * + * The merge is renderer-only and pure. It does NOT widen the shared + * `WorktreeMemory` shape; instead it emits a renderer-local view-model + * with `Metric = number | null`, where `null` means "no local sample" + * (e.g. an SSH session). The popover renders `null` cells as `—`. + * + * See docs/resource-usage-merge-spec.md for the full design. + */ + +import type { + MemorySnapshot, + SessionMemory, + TerminalTab, + WorktreeMemory +} from '../../../../shared/types' + +// ─── View-model types (renderer-local) ────────────────────────────── + +/** `null` === "no local sample" (e.g. SSH PTY); UI renders as em-dash. */ +export type Metric = number | null + +export type DaemonSession = { + id: string + cwd: string + title: string +} + +export type UnifiedSessionRow = { + sessionId: string + paneKey: string | null + pid: number + label: string + bound: boolean + tabId: string | null + cpu: Metric + memory: Metric + hasLocalSamples: boolean +} + +export type UnifiedWorktreeRow = { + worktreeId: string + worktreeName: string + repoId: string + repoName: string + cpu: Metric + memory: Metric + history: number[] + hasLocalSamples: boolean + sessions: UnifiedSessionRow[] +} + +export type UnifiedRepoGroup = { + repoId: string + repoName: string + cpu: Metric + memory: Metric + hasRemoteChildren: boolean + worktrees: UnifiedWorktreeRow[] +} + +// ─── Inputs that the renderer already has on hand ─────────────────── + +export type MergeContext = { + /** From useAppStore: maps tabId → worktreeId for tab-walk resolution. */ + tabsByWorktree: Record + /** From useAppStore: maps tabId → ptyIds[] for the bound check. */ + ptyIdsByTabId: Record + /** From useAppStore: per-tab live pane titles (for label resolution). */ + runtimePaneTitlesByTabId: Record> + /** From useAppStore: false until the renderer has booted enough state to + * trust the bound/orphan distinction. Mirrors the existing gate. */ + workspaceSessionReady: boolean + /** Repo display names by repo id. Used for new groups synthesized from + * daemon sessions whose repo isn't in the snapshot (typical SSH case). */ + repoDisplayNameById: Map +} + +// ─── Helpers ──────────────────────────────────────────────────────── + +const ORCA_WORKTREE_ID_SEPARATOR = '::' + +/** Why: minted PTY session ids look like `${worktreeId}@@${shortUuid}` + * (see src/main/daemon/pty-session-id.ts). The renderer-side parser + * only needs the prefix; `lastIndexOf` is robust to worktreeIds that + * may themselves contain `@`. */ +function parseWorktreeIdFromSessionId(sessionId: string): string | null { + const idx = sessionId.lastIndexOf('@@') + if (idx <= 0) { + return null + } + const candidate = sessionId.slice(0, idx) + if (!candidate.includes(ORCA_WORKTREE_ID_SEPARATOR)) { + return null + } + return candidate +} + +function deriveRepoIdFromWorktreeId(worktreeId: string): string { + const sep = worktreeId.indexOf(ORCA_WORKTREE_ID_SEPARATOR) + return sep > 0 ? worktreeId.slice(0, sep) : worktreeId +} + +function deriveWorktreeNameFromWorktreeId(worktreeId: string): string { + const sep = worktreeId.indexOf(ORCA_WORKTREE_ID_SEPARATOR) + if (sep <= 0) { + return worktreeId + } + const path = worktreeId.slice(sep + 2) + if (!path) { + return worktreeId + } + const parts = path.split(/[\\/]+/).filter(Boolean) + return parts.at(-1) ?? worktreeId +} + +function shortCwd(cwd: string): string { + if (!cwd) { + return '' + } + const sep = cwd.includes('\\') ? '\\' : '/' + const parts = cwd.split(/[\\/]+/).filter(Boolean) + return parts.length > 2 ? parts.slice(-2).join(sep) : cwd +} + +function parsePaneKey(paneKey: string | null): { tabId: string; paneRuntimeId: number } | null { + if (!paneKey) { + return null + } + const sepIdx = paneKey.indexOf(':') + if (sepIdx <= 0) { + return null + } + const paneRuntimeId = Number(paneKey.slice(sepIdx + 1)) + if (!Number.isFinite(paneRuntimeId)) { + return null + } + return { tabId: paneKey.slice(0, sepIdx), paneRuntimeId } +} + +function resolveSnapshotSessionLabel( + session: SessionMemory, + worktreeId: string, + ctx: MergeContext +): string { + const parsed = parsePaneKey(session.paneKey) + if (parsed) { + const tabs = ctx.tabsByWorktree[worktreeId] ?? [] + const tabIndex = tabs.findIndex((t) => t.id === parsed.tabId) + const tab = tabIndex >= 0 ? tabs[tabIndex] : undefined + if (tab) { + const custom = tab.customTitle?.trim() + if (custom) { + return custom + } + const runtime = ctx.runtimePaneTitlesByTabId[parsed.tabId]?.[parsed.paneRuntimeId]?.trim() + if (runtime) { + return runtime + } + return tab.defaultTitle?.trim() || tab.title?.trim() || `Terminal ${tabIndex + 1}` + } + } + if (session.pid > 0) { + return `pid ${session.pid}` + } + const fallback = session.sessionId?.slice(0, 8) + return fallback ? `session ${fallback}` : '(unknown session)' +} + +function resolveDaemonSessionLabel( + session: DaemonSession, + resolvedWorktreeId: string | null, + tabId: string | null, + ctx: MergeContext +): string { + if (tabId && resolvedWorktreeId) { + const tabs = ctx.tabsByWorktree[resolvedWorktreeId] ?? [] + const tabIndex = tabs.findIndex((t) => t.id === tabId) + const tab = tabIndex >= 0 ? tabs[tabIndex] : undefined + if (tab) { + const custom = tab.customTitle?.trim() + if (custom) { + return custom + } + const runtimeMap = ctx.runtimePaneTitlesByTabId[tabId] + if (runtimeMap) { + const live = Object.values(runtimeMap).find((t) => t?.trim()) + if (live) { + return live + } + } + const fallback = tab.defaultTitle?.trim() || tab.title?.trim() + if (fallback) { + return fallback + } + } + } + if (session.cwd) { + return shortCwd(session.cwd) + } + if (resolvedWorktreeId) { + return shortCwd(resolvedWorktreeId) + } + if (session.title) { + return session.title + } + return 'unknown' +} + +// Why: the previous implementation did O(N) linear scans over +// ptyIdsByTabId / tabsByWorktree for *every* session it processed. With a +// large workspace that's S * (T + W) work per merge — and the merge runs on +// every snapshot poll plus every store mutation. Pre-build O(1) lookup +// indices once per merge instead. +type MergeIndex = { + ptyIdToTabId: Map + tabIdToWorktreeId: Map +} + +function buildMergeIndex(ctx: MergeContext): MergeIndex { + const ptyIdToTabId = new Map() + for (const [tabId, ptyIds] of Object.entries(ctx.ptyIdsByTabId)) { + for (const ptyId of ptyIds) { + if (ptyId) { + ptyIdToTabId.set(ptyId, tabId) + } + } + } + const tabIdToWorktreeId = new Map() + for (const [worktreeId, tabs] of Object.entries(ctx.tabsByWorktree)) { + for (const tab of tabs) { + tabIdToWorktreeId.set(tab.id, worktreeId) + } + } + return { ptyIdToTabId, tabIdToWorktreeId } +} + +// ─── Public merge function ───────────────────────────────────────── + +export const UNATTRIBUTED_REPO_ID = '__unattributed__' +export const UNATTRIBUTED_REPO_NAME = 'Unattributed' + +export function mergeSnapshotAndSessions( + snapshot: MemorySnapshot | null, + daemonSessions: readonly DaemonSession[], + ctx: MergeContext +): UnifiedRepoGroup[] { + const repos = new Map() + const seenSessionIds = new Set() + const index = buildMergeIndex(ctx) + // Why: bound = the daemon session id appears as a pty id under some tab. + // ptyIdToTabId already encodes that membership in O(1), so the bound set + // is just its keys. + const boundPtyIds = ctx.workspaceSessionReady + ? new Set(index.ptyIdToTabId.keys()) + : new Set() + + function ensureRepo( + repoId: string, + repoName: string, + initiallyHasRemoteChildren = false + ): UnifiedRepoGroup { + const existing = repos.get(repoId) + if (existing) { + return existing + } + const next: UnifiedRepoGroup = { + repoId, + repoName, + cpu: null, + memory: null, + hasRemoteChildren: initiallyHasRemoteChildren, + worktrees: [] + } + repos.set(repoId, next) + return next + } + + function findWorktreeRow( + repo: UnifiedRepoGroup, + worktreeId: string + ): UnifiedWorktreeRow | undefined { + return repo.worktrees.find((w) => w.worktreeId === worktreeId) + } + + // ── Step 1: ingest snapshot worktrees as the local-truth foundation. + if (snapshot) { + for (const wt of snapshot.worktrees as readonly WorktreeMemory[]) { + const repo = ensureRepo(wt.repoId, wt.repoName) + const sessions: UnifiedSessionRow[] = wt.sessions.map((s) => { + seenSessionIds.add(s.sessionId) + const tabId = index.ptyIdToTabId.get(s.sessionId) ?? null + return { + sessionId: s.sessionId, + paneKey: s.paneKey, + pid: s.pid, + label: resolveSnapshotSessionLabel(s, wt.worktreeId, ctx), + bound: ctx.workspaceSessionReady && boundPtyIds.has(s.sessionId), + tabId, + cpu: s.cpu, + memory: s.memory, + hasLocalSamples: true + } + }) + repo.worktrees.push({ + worktreeId: wt.worktreeId, + worktreeName: wt.worktreeName, + repoId: wt.repoId, + repoName: wt.repoName, + cpu: wt.cpu, + memory: wt.memory, + history: wt.history, + hasLocalSamples: true, + sessions + }) + } + } + + // ── Step 2: union daemon sessions that the snapshot didn't cover. + for (const session of daemonSessions) { + if (seenSessionIds.has(session.id)) { + continue + } + seenSessionIds.add(session.id) + + // 2a: tab-store walk — does this session belong to a tab in this renderer? + const tabId = index.ptyIdToTabId.get(session.id) ?? null + let worktreeId = tabId ? (index.tabIdToWorktreeId.get(tabId) ?? null) : null + + // 2b: @@-parse — recover worktreeId from the minted session id format. + if (!worktreeId) { + worktreeId = parseWorktreeIdFromSessionId(session.id) + } + + // 2c: unattributed bucket. + const isUnattributed = !worktreeId + const finalWorktreeId = worktreeId ?? `${UNATTRIBUTED_REPO_ID}::${session.id}` + const finalRepoId = isUnattributed + ? UNATTRIBUTED_REPO_ID + : deriveRepoIdFromWorktreeId(finalWorktreeId) + const finalRepoName = isUnattributed + ? UNATTRIBUTED_REPO_NAME + : ctx.repoDisplayNameById.get(finalRepoId) || finalRepoId + const finalWorktreeName = isUnattributed + ? session.title || session.id.slice(0, 12) + : deriveWorktreeNameFromWorktreeId(finalWorktreeId) + + const repo = ensureRepo(finalRepoId, finalRepoName, true) + repo.hasRemoteChildren = true + + let row = findWorktreeRow(repo, finalWorktreeId) + if (!row) { + row = { + worktreeId: finalWorktreeId, + worktreeName: finalWorktreeName, + repoId: finalRepoId, + repoName: finalRepoName, + cpu: null, + memory: null, + history: [], + hasLocalSamples: false, + sessions: [] + } + repo.worktrees.push(row) + } + + row.sessions.push({ + sessionId: session.id, + paneKey: null, + pid: 0, + label: resolveDaemonSessionLabel(session, worktreeId, tabId, ctx), + bound: ctx.workspaceSessionReady && boundPtyIds.has(session.id), + tabId, + cpu: null, + memory: null, + hasLocalSamples: false + }) + } + + // ── Step 3: per-repo aggregates exclude remote children. + for (const repo of repos.values()) { + let cpuSum = 0 + let memSum = 0 + let anyLocal = false + let anyRemote = false + for (const wt of repo.worktrees) { + if (wt.hasLocalSamples && wt.cpu !== null && wt.memory !== null) { + cpuSum += wt.cpu + memSum += wt.memory + anyLocal = true + } else { + anyRemote = true + } + } + repo.cpu = anyLocal ? cpuSum : null + repo.memory = anyLocal ? memSum : null + if (anyRemote) { + repo.hasRemoteChildren = true + } + } + + return [...repos.values()] +} diff --git a/src/renderer/src/store/slices/memory.ts b/src/renderer/src/store/slices/memory.ts index 2349fecdc0a..b24de6d0c89 100644 --- a/src/renderer/src/store/slices/memory.ts +++ b/src/renderer/src/store/slices/memory.ts @@ -4,18 +4,26 @@ import type { MemorySnapshot } from '../../../../shared/types' export type MemorySlice = { memorySnapshot: MemorySnapshot | null + memorySnapshotError: string | null fetchMemorySnapshot: () => Promise } export const createMemorySlice: StateCreator = (set) => ({ memorySnapshot: null, + memorySnapshotError: null, fetchMemorySnapshot: async () => { try { const snapshot = await window.api.memory.getSnapshot() - set({ memorySnapshot: snapshot }) + set({ memorySnapshot: snapshot, memorySnapshotError: null }) } catch (err) { + // Why: the always-on Resource Usage status-bar segment needs to know when + // the snapshot IPC is failing so it can surface a "daemon not responding" + // banner with a Restart CTA. Prior code only console.error'd. console.error('Failed to fetch memory snapshot:', err) + set({ + memorySnapshotError: err instanceof Error ? err.message : String(err) + }) } } }) diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 455c89b9c9f..934d0b7e48a 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -40,6 +40,22 @@ 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 +// doesn't strip a newer build's ids out of the user's settings. +function migrateStatusBarItems(items: readonly string[] | undefined): StatusBarItem[] { + const source = items ?? DEFAULT_STATUS_BAR_ITEMS + const out: string[] = [] + for (const id of source) { + const mapped = id === 'memory' || id === 'sessions' ? 'resource-usage' : id + if (!out.includes(mapped)) { + out.push(mapped) + } + } + 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' @@ -648,7 +664,7 @@ export const createUISlice: StateCreator = (set, get) uiZoomLevel: ui.uiZoomLevel ?? 0, editorFontZoomLevel: ui.editorFontZoomLevel ?? 0, worktreeCardProperties: ui.worktreeCardProperties ?? [...DEFAULT_WORKTREE_CARD_PROPERTIES], - statusBarItems: ui.statusBarItems ?? [...DEFAULT_STATUS_BAR_ITEMS], + statusBarItems: migrateStatusBarItems(ui.statusBarItems), statusBarVisible: ui.statusBarVisible ?? true, // Why: absent → true so existing users see the sidekick the first time // they enable the experimental flag. Only an explicit Hide sidekick diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 7a2075acb63..0f9a3e96326 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -82,8 +82,7 @@ export const DEFAULT_STATUS_BAR_ITEMS: StatusBarItem[] = [ 'gemini', 'opencode-go', 'ssh', - 'sessions', - 'memory' + 'resource-usage' ] /** Synthetic worktree id used by the memory collector to bucket PTYs that diff --git a/src/shared/types.ts b/src/shared/types.ts index f5af7171843..424f56b5d72 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1307,14 +1307,7 @@ export type WorktreeCardProperty = // view options. | 'inline-agents' -export type StatusBarItem = - | 'claude' - | 'codex' - | 'gemini' - | 'opencode-go' - | 'ssh' - | 'sessions' - | 'memory' +export type StatusBarItem = 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'ssh' | 'resource-usage' export type TaskResumeState = { githubMode?: 'items' | 'project' From 6a7cd2c9aeab002634387cb5a614f476b6d8ecfb Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 5 May 2026 11:43:13 -0700 Subject: [PATCH 05/28] fix(github): link create-from-PR worktrees back to their PR (#1444) When a worktree is created from a PR via the source picker, the new local branch differs from the PR's head ref, so the branch-keyed PR lookup misses and the worktree card shows no PR strip. Pass the worktree's linkedPR number through the IPC call and fall back to a number-based lookup in the main process. Also recover linkedPR from a PR URL pasted into the workspace name when the user skips the source picker. PR strip now wraps the whole row as the PR link. Co-authored-by: Orca --- src/main/github/client.ts | 107 +++++++++++------- src/main/ipc/github.test.ts | 2 +- src/main/ipc/github.ts | 33 +++--- src/preload/api-types.ts | 14 +-- src/preload/index.ts | 13 ++- .../components/right-sidebar/ChecksPanel.tsx | 9 +- .../right-sidebar/SourceControl.tsx | 8 +- .../src/components/sidebar/WorktreeCard.tsx | 17 ++- .../components/sidebar/WorktreeCardMeta.tsx | 21 ++-- src/renderer/src/hooks/useComposerState.ts | 28 ++++- src/renderer/src/store/slices/github.ts | 56 ++++++--- 11 files changed, 198 insertions(+), 110 deletions(-) diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 81d427efb70..55bf9035d66 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -851,17 +851,21 @@ export async function getWorkItemByOwnerRepo( /** * Get PR info for a given branch using gh CLI. * Returns null if gh is not installed, or no PR exists for the branch. + * + * When `linkedPRNumber` is provided and the branch lookup yields nothing, + * falls back to looking up the PR by number. This handles "create from PR" + * worktrees, whose branch is a fresh local branch (not the PR's head ref) — + * the branch-keyed lookup misses, but the user still expects the linked PR + * to surface on the worktree card. */ -export async function getPRForBranch(repoPath: string, branch: string): Promise { +export async function getPRForBranch( + repoPath: string, + branch: string, + linkedPRNumber?: number | null +): Promise { // Strip refs/heads/ prefix if present const branchName = branch.replace(/^refs\/heads\//, '') - // During a rebase the worktree is in detached HEAD and branch is empty. - // An empty --head filter causes gh to return an arbitrary PR — bail early. - if (!branchName) { - return null - } - await acquire() try { const ownerRepo = await getOwnerRepo(repoPath) @@ -880,37 +884,64 @@ export async function getPRForBranch(repoPath: string, branch: string): Promise< headRefOid?: string } | null = null - if (ownerRepo) { - const { stdout } = await ghExecFileAsync( - [ - 'pr', - 'list', - '--repo', - `${ownerRepo.owner}/${ownerRepo.repo}`, - '--head', - branchName, - '--state', - 'all', - '--limit', - '1', - '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' - ], - { cwd: repoPath } - ) - const list = JSON.parse(stdout) as NonNullable[] - data = list[0] ?? null - } else { - const { stdout } = await ghExecFileAsync( - [ - 'pr', - 'view', - branchName, - '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' - ], - { cwd: repoPath } - ) + // During a rebase the worktree is in detached HEAD and branch is empty. + // An empty --head filter causes gh to return an arbitrary PR — skip the + // branch lookup and rely on the linkedPR fallback below if available. + if (branchName) { + if (ownerRepo) { + const { stdout } = await ghExecFileAsync( + [ + 'pr', + 'list', + '--repo', + `${ownerRepo.owner}/${ownerRepo.repo}`, + '--head', + branchName, + '--state', + 'all', + '--limit', + '1', + '--json', + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + ], + { cwd: repoPath } + ) + const list = JSON.parse(stdout) as NonNullable[] + data = list[0] ?? null + } else { + const { stdout } = await ghExecFileAsync( + [ + 'pr', + 'view', + branchName, + '--json', + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + ], + { cwd: repoPath } + ) + data = JSON.parse(stdout) + } + } + + if (!data && typeof linkedPRNumber === 'number') { + const args = ownerRepo + ? [ + 'pr', + 'view', + String(linkedPRNumber), + '--repo', + `${ownerRepo.owner}/${ownerRepo.repo}`, + '--json', + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + ] + : [ + 'pr', + 'view', + String(linkedPRNumber), + '--json', + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + ] + const { stdout } = await ghExecFileAsync(args, { cwd: repoPath }) data = JSON.parse(stdout) } diff --git a/src/main/ipc/github.test.ts b/src/main/ipc/github.test.ts index 8e09575309c..8a74e3b741c 100644 --- a/src/main/ipc/github.test.ts +++ b/src/main/ipc/github.test.ts @@ -93,7 +93,7 @@ describe('registerGitHubHandlers', () => { branch: 'feature/test' }) - expect(getPRForBranchMock).toHaveBeenCalledWith('/workspace/repo', 'feature/test') + expect(getPRForBranchMock).toHaveBeenCalledWith('/workspace/repo', 'feature/test', null) }) it('rejects unknown repository paths', async () => { diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index d0da43341c3..0926093bbde 100644 --- a/src/main/ipc/github.ts +++ b/src/main/ipc/github.ts @@ -84,22 +84,25 @@ function assertRegisteredRepo(repoPath: string, store: Store): Repo { } export function registerGitHubHandlers(store: Store, stats: StatsCollector): void { - ipcMain.handle('gh:prForBranch', async (_event, args: { repoPath: string; branch: string }) => { - const repo = assertRegisteredRepo(args.repoPath, store) - const pr = await getPRForBranch(repo.path, args.branch) - // Emit pr_created when a PR is first detected for a branch. - // Why here: the renderer polls gh:prForBranch to check PR status per worktree. - // This captures PRs opened from any workflow (Orca UI, gh CLI, github.com). - if (pr && !stats.hasCountedPR(pr.url)) { - stats.record({ - type: 'pr_created', - at: Date.now(), - repoId: repo.id, - meta: { prNumber: pr.number, prUrl: pr.url } - }) + ipcMain.handle( + 'gh:prForBranch', + async (_event, args: { repoPath: string; branch: string; linkedPRNumber?: number | null }) => { + const repo = assertRegisteredRepo(args.repoPath, store) + const pr = await getPRForBranch(repo.path, args.branch, args.linkedPRNumber ?? null) + // Emit pr_created when a PR is first detected for a branch. + // Why here: the renderer polls gh:prForBranch to check PR status per worktree. + // This captures PRs opened from any workflow (Orca UI, gh CLI, github.com). + if (pr && !stats.hasCountedPR(pr.url)) { + stats.record({ + type: 'pr_created', + at: Date.now(), + repoId: repo.id, + meta: { prNumber: pr.number, prUrl: pr.url } + }) + } + return pr } - return pr - }) + ) ipcMain.handle('gh:issue', (_event, args: { repoPath: string; number: number }) => { const repo = assertRegisteredRepo(args.repoPath, store) diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index e5aaf567b87..4fb6e1acbb5 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -499,7 +499,11 @@ export type PreloadApi = { gh: { viewer: () => Promise repoSlug: (args: { repoPath: string }) => Promise<{ owner: string; repo: string } | null> - prForBranch: (args: { repoPath: string; branch: string }) => Promise + prForBranch: (args: { + repoPath: string + branch: string + linkedPRNumber?: number | null + }) => Promise issue: (args: { repoPath: string; number: number }) => Promise workItem: (args: { repoPath: string @@ -603,9 +607,7 @@ export type PreloadApi = { updateProjectItemField: ( args: UpdateProjectItemFieldArgs ) => Promise - clearProjectItemField: ( - args: ClearProjectItemFieldArgs - ) => Promise + clearProjectItemField: (args: ClearProjectItemFieldArgs) => Promise updateIssueBySlug: (args: UpdateIssueBySlugArgs) => Promise updatePullRequestBySlug: ( args: UpdatePullRequestBySlugArgs @@ -624,9 +626,7 @@ export type PreloadApi = { args: ListAssignableUsersBySlugArgs ) => Promise listIssueTypesBySlug: (args: ListIssueTypesBySlugArgs) => Promise - updateIssueTypeBySlug: ( - args: UpdateIssueTypeBySlugArgs - ) => Promise + updateIssueTypeBySlug: (args: UpdateIssueTypeBySlugArgs) => Promise } linear: { connect: (args: { diff --git a/src/preload/index.ts b/src/preload/index.ts index 2a62a9e4332..b722f086389 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -522,8 +522,11 @@ const api = { repoSlug: (args: { repoPath: string }): Promise => ipcRenderer.invoke('gh:repoSlug', args), - prForBranch: (args: { repoPath: string; branch: string }): Promise => - ipcRenderer.invoke('gh:prForBranch', args), + prForBranch: (args: { + repoPath: string + branch: string + linkedPRNumber?: number | null + }): Promise => ipcRenderer.invoke('gh:prForBranch', args), issue: (args: { repoPath: string; number: number }): Promise => ipcRenderer.invoke('gh:issue', args), @@ -678,8 +681,7 @@ const api = { ipcRenderer.invoke('gh:updateProjectItemField', args), clearProjectItemField: ( args: ClearProjectItemFieldArgs - ): Promise => - ipcRenderer.invoke('gh:clearProjectItemField', args), + ): Promise => ipcRenderer.invoke('gh:clearProjectItemField', args), updateIssueBySlug: (args: UpdateIssueBySlugArgs): Promise => ipcRenderer.invoke('gh:updateIssueBySlug', args), updatePullRequestBySlug: ( @@ -708,8 +710,7 @@ const api = { ipcRenderer.invoke('gh:listIssueTypesBySlug', args), updateIssueTypeBySlug: ( args: UpdateIssueTypeBySlugArgs - ): Promise => - ipcRenderer.invoke('gh:updateIssueTypeBySlug', args) + ): Promise => ipcRenderer.invoke('gh:updateIssueTypeBySlug', args) }, linear: { diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index 1850727140c..df4205926a8 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -79,12 +79,15 @@ export default function ChecksPanel(): React.JSX.Element { ? (gitConflictOperationByWorktree[activeWorktreeId] ?? 'unknown') : 'unknown' - // Fetch PR data when the active worktree/branch changes + // Fetch PR data when the active worktree/branch changes. + // Why: pass linkedPR so worktrees created from a PR (whose new local branch + // differs from the PR's head ref) resolve via the number-based fallback. + const linkedPR = activeWorktree?.linkedPR ?? null useEffect(() => { if (repo && !isFolder && branch) { - void fetchPRForBranch(repo.path, branch) + void fetchPRForBranch(repo.path, branch, { linkedPRNumber: linkedPR }) } - }, [repo, isFolder, branch, fetchPRForBranch]) + }, [repo, isFolder, branch, linkedPR, fetchPRForBranch]) useEffect(() => { if (!repo || isFolder || !branch || !pr || pr.mergeable !== 'CONFLICTING') { diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index d933045ed90..788c4bdc58e 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -297,6 +297,7 @@ function SourceControlInner(): React.JSX.Element { const prCacheKey = activeRepo && branchName ? `${activeRepo.path}::${branchName}` : null const prInfo: PRInfo | null = prCacheKey ? (prCache[prCacheKey]?.data ?? null) : null + const linkedPR = activeWorktree?.linkedPR ?? null useEffect(() => { if (!isBranchVisible || !activeRepo || isFolder || !branchName || branchName === 'HEAD') { return @@ -305,9 +306,10 @@ function SourceControlInner(): React.JSX.Element { // Why: the Source Control panel renders the branch's PR badge directly. // When a terminal checkout moves this worktree onto a new branch, we need // to fetch that branch's PR immediately instead of waiting for the user to - // reselect the worktree or open the separate Checks panel. - void fetchPRForBranch(activeRepo.path, branchName) - }, [activeRepo, branchName, fetchPRForBranch, isBranchVisible, isFolder]) + // reselect the worktree or open the separate Checks panel. Pass linkedPR + // so create-from-PR worktrees resolve via the number-based fallback. + void fetchPRForBranch(activeRepo.path, branchName, { linkedPRNumber: linkedPR }) + }, [activeRepo, branchName, fetchPRForBranch, isBranchVisible, isFolder, linkedPR]) const grouped = useMemo(() => { const groups = { diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index 8a3ae5a9e0c..1bd9f786efb 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -255,9 +255,22 @@ const WorktreeCard = React.memo(function WorktreeCard({ // spend rate limit budget on data the user cannot see. useEffect(() => { if (repo && !isFolder && !worktree.isBare && prCacheKey && (showPR || showCI)) { - fetchPRForBranch(repo.path, branch) + // Why: pass linkedPR so worktrees created from a PR (whose new local + // branch differs from the PR's head ref) still resolve their PR via + // a number-based fallback in the main process. + fetchPRForBranch(repo.path, branch, { linkedPRNumber: worktree.linkedPR ?? null }) } - }, [repo, isFolder, worktree.isBare, fetchPRForBranch, branch, prCacheKey, showPR, showCI]) + }, [ + repo, + isFolder, + worktree.isBare, + worktree.linkedPR, + fetchPRForBranch, + branch, + prCacheKey, + showPR, + showCI + ]) // Same rationale for issues: once that section is hidden, polling only burns // GitHub calls and keeps stale-but-invisible data warm for no user benefit. diff --git a/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx b/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx index 2bbe8e7ddab..d2f8e4c7128 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx @@ -73,13 +73,16 @@ type PrSectionProps = { onClick: (e: React.MouseEvent) => void } -export function PrSection({ pr, onClick }: PrSectionProps): React.JSX.Element { +export function PrSection({ pr, onClick: _onClick }: PrSectionProps): React.JSX.Element { return ( -
e.stopPropagation()} > -
+
diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 21a3a6c0028..2da6a45d5c3 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -7,7 +7,11 @@ import { toast } from 'sonner' import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '@/store' import { AGENT_CATALOG } from '@/lib/agent-catalog' -import { parseGitHubIssueOrPRNumber, normalizeGitHubLinkQuery } from '@/lib/github-links' +import { + parseGitHubIssueOrPRNumber, + parseGitHubIssueOrPRLink, + normalizeGitHubLinkQuery +} from '@/lib/github-links' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '@/lib/tui-agent-startup' import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' @@ -398,6 +402,20 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS () => (linkedIssue.trim() ? parseGitHubIssueOrPRNumber(linkedIssue) : null), [linkedIssue] ) + // Why: when the user pastes a PR URL straight into the workspace name field + // (without picking from the source picker), `linkedPR` stays null and the + // worktree card has no PR strip. Recover the PR number from the name on + // submit so create-from-PR worktrees always link back to their PR. + const effectiveLinkedPR = useMemo(() => { + if (linkedPR !== null) { + return linkedPR + } + const fromName = parseGitHubIssueOrPRLink(name) + if (fromName && fromName.type === 'pr') { + return fromName.number + } + return null + }, [linkedPR, name]) const setupConfig = useMemo( () => getSetupConfig(selectedRepo, yamlHooks), [selectedRepo, yamlHooks] @@ -1235,7 +1253,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS await applyWorktreeMeta(worktree.id, { ...(parsedLinkedIssueNumber !== null ? { linkedIssue: parsedLinkedIssueNumber } : {}), - ...(linkedPR !== null ? { linkedPR } : {}), + ...(effectiveLinkedPR !== null ? { linkedPR: effectiveLinkedPR } : {}), ...(note.trim() ? { comment: note.trim() } : {}) }) @@ -1288,7 +1306,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS createWorktree, applyWorktreeMeta, issueCommandTemplate, - linkedPR, + effectiveLinkedPR, linkedWorkItem?.url, normalizedSparseDirectories, note, @@ -1362,7 +1380,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const trimmedNote = note.trim() await applyWorktreeMeta(worktree.id, { ...(parsedLinkedIssueNumber !== null ? { linkedIssue: parsedLinkedIssueNumber } : {}), - ...(linkedPR !== null ? { linkedPR } : {}), + ...(effectiveLinkedPR !== null ? { linkedPR: effectiveLinkedPR } : {}), ...(trimmedNote ? { comment: trimmedNote } : {}) }) @@ -1464,7 +1482,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS clearNewWorkspaceDraft, createWorktree, fallbackCreatureName, - linkedPR, + effectiveLinkedPR, linkedWorkItem, name, normalizedSparseDirectories, diff --git a/src/renderer/src/store/slices/github.ts b/src/renderer/src/store/slices/github.ts index db8cadc6dd2..e77901aba9f 100644 --- a/src/renderer/src/store/slices/github.ts +++ b/src/renderer/src/store/slices/github.ts @@ -71,7 +71,9 @@ export type ProjectRowContentPatch = { // different rows when the view's stored filter is non-empty, so it gets its // own cache key. function queryOverrideKeyPart(queryOverride: string | undefined): string { - if (queryOverride === undefined) {return ''} + if (queryOverride === undefined) { + return '' + } return `:q=${queryOverride}` } @@ -405,7 +407,7 @@ export type GitHubSlice = { fetchPRForBranch: ( repoPath: string, branch: string, - options?: FetchOptions + options?: FetchOptions & { linkedPRNumber?: number | null } ) => Promise fetchIssue: (repoPath: string, number: number) => Promise fetchPRChecks: ( @@ -566,11 +568,7 @@ export type GitHubSlice = { * alone only walks `workItemsCache` and would leave the Project view stale * until the next refresh. The actual write is dispatched separately via * the slug-addressed update IPCs. */ - patchProjectRowContent: ( - cacheKey: string, - rowId: string, - patch: ProjectRowContentPatch - ) => void + patchProjectRowContent: (cacheKey: string, rowId: string, patch: ProjectRowContentPatch) => void } export const createGitHubSlice: StateCreator = (set, get) => ({ @@ -784,12 +782,18 @@ export const createGitHubSlice: StateCreator = (s } // Optimistic content patch. const nextContent = { ...previousRow.content } - if (updates.title !== undefined) {nextContent.title = updates.title} - if (updates.body !== undefined) {nextContent.body = updates.body} + if (updates.title !== undefined) { + nextContent.title = updates.title + } + if (updates.body !== undefined) { + nextContent.body = updates.body + } if (updates.addLabels || updates.removeLabels) { const next = new Map(nextContent.labels.map((l) => [l.name, l])) for (const name of updates.addLabels ?? []) { - if (!next.has(name)) {next.set(name, { name, color: '808080' })} + if (!next.has(name)) { + next.set(name, { name, color: '808080' }) + } } for (const name of updates.removeLabels ?? []) { next.delete(name) @@ -799,7 +803,9 @@ export const createGitHubSlice: StateCreator = (s if (updates.addAssignees || updates.removeAssignees) { const next = new Map(nextContent.assignees.map((u) => [u.login, u])) for (const login of updates.addAssignees ?? []) { - if (!next.has(login)) {next.set(login, { login, name: null, avatarUrl: null })} + if (!next.has(login)) { + next.set(login, { login, name: null, avatarUrl: null }) + } } for (const login of updates.removeAssignees ?? []) { next.delete(login) @@ -827,7 +833,9 @@ export const createGitHubSlice: StateCreator = (s ...(updates.body !== undefined ? { body: updates.body } : {}) } }) - if (!prRes.ok) {envelope = prRes} + if (!prRes.ok) { + envelope = prRes + } } if ( envelope.ok && @@ -851,7 +859,9 @@ export const createGitHubSlice: StateCreator = (s ...(updates.removeAssignees ? { removeAssignees: updates.removeAssignees } : {}) } }) - if (!issueRes.ok) {envelope = issueRes} + if (!issueRes.ok) { + envelope = issueRes + } } if (!envelope.ok) { rollbackRowIfPresent(set, get, cacheKey, rowId, previousRow) @@ -913,8 +923,12 @@ export const createGitHubSlice: StateCreator = (s return } const nextContent = { ...previousRow.content } - if (patch.title !== undefined) {nextContent.title = patch.title} - if (patch.body !== undefined) {nextContent.body = patch.body} + if (patch.title !== undefined) { + nextContent.title = patch.title + } + if (patch.body !== undefined) { + nextContent.body = patch.body + } if (patch.state !== undefined) { // Why: ProjectV2 row.state mirrors GitHub's UPPERCASE state enum // ('OPEN' | 'CLOSED' | 'MERGED'). The dialog tracks lowercase @@ -1167,21 +1181,27 @@ export const createGitHubSlice: StateCreator = (s fetchPRForBranch: async (repoPath, branch, options): Promise => { const cacheKey = `${repoPath}::${branch}` const cached = get().prCache[cacheKey] - if (!options?.force && isFresh(cached)) { + // Why: if a prior caller without a linkedPR cached `null` for this branch, + // the worktree-card lookup (which has a linked PR fallback) would otherwise + // return null forever. Refetch when the cached miss could now resolve via + // the linkedPR path. + const linkedRefetch = cached?.data === null && (options?.linkedPRNumber ?? null) !== null + if (!options?.force && !linkedRefetch && isFresh(cached)) { return cached.data } const inflightRequest = inflightPRRequests.get(cacheKey) - if (inflightRequest && (!options?.force || inflightRequest.force)) { + if (inflightRequest && (!options?.force || inflightRequest.force) && !linkedRefetch) { return inflightRequest.promise } const generation = (prRequestGenerations.get(cacheKey) ?? 0) + 1 prRequestGenerations.set(cacheKey, generation) + const linkedPRNumber = options?.linkedPRNumber ?? null const request = (async () => { try { - const pr = await window.api.gh.prForBranch({ repoPath, branch }) + const pr = await window.api.gh.prForBranch({ repoPath, branch, linkedPRNumber }) if (prRequestGenerations.get(cacheKey) === generation) { set((s) => ({ prCache: { ...s.prCache, [cacheKey]: { data: pr, fetchedAt: Date.now() } } From 0a2e5aa8f71e36b83c81ffa0e10a3701e841af71 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 5 May 2026 11:47:57 -0700 Subject: [PATCH 06/28] Refresh first-launch telemetry banner copy (#1445) Co-authored-by: Orca --- .../src/components/FirstLaunchBanner.tsx | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/renderer/src/components/FirstLaunchBanner.tsx b/src/renderer/src/components/FirstLaunchBanner.tsx index 7335c197ded..1372acf9dd0 100644 --- a/src/renderer/src/components/FirstLaunchBanner.tsx +++ b/src/renderer/src/components/FirstLaunchBanner.tsx @@ -103,7 +103,7 @@ export function FirstLaunchBanner({ // `relative` is load-bearing: the absolutely-positioned ✕ anchors to // this container.
-

Help us figure out what to build next

+

Help us decide what to build next

- We'd like to start sending anonymous counts of which features you use and where - things break — no file contents, prompts, terminal output, or anything that identifies - you. Change this anytime in Settings → Privacy & Telemetry.{' '} + Anonymous counts of which features you use help us prioritize what to build. No file + contents, prompts, terminal output, or anything that identifies you. Change anytime in + Settings → Privacy & Telemetry.{' '} ) diff --git a/src/renderer/src/components/github-project/slug-dialog/SlugDialogBody.tsx b/src/renderer/src/components/github-project/slug-dialog/SlugDialogBody.tsx index 9eb9e5eb950..5878ab970ef 100644 --- a/src/renderer/src/components/github-project/slug-dialog/SlugDialogBody.tsx +++ b/src/renderer/src/components/github-project/slug-dialog/SlugDialogBody.tsx @@ -27,10 +27,16 @@ export function SlugDialogBody({ // patches applied by the table (e.g. inline assignee edits). const row = useMemo(() => { const table = projectViewCache[cacheKey]?.data - if (!table) {return null} - return table.rows.find( - (r) => r.content.number === number && r.content.repository?.toLowerCase() === `${owner}/${repo}`.toLowerCase() - ) ?? null + if (!table) { + return null + } + return ( + table.rows.find( + (r) => + r.content.number === number && + r.content.repository?.toLowerCase() === `${owner}/${repo}`.toLowerCase() + ) ?? null + ) }, [projectViewCache, cacheKey, owner, repo, number]) const [details, setDetails] = useState(null) @@ -47,7 +53,9 @@ export function SlugDialogBody({ window.api.gh .projectWorkItemDetailsBySlug({ owner, repo, number, type }) .then((res) => { - if (rid !== requestIdRef.current) {return} + if (rid !== requestIdRef.current) { + return + } if (res.ok) { setDetails(res.details) } else { @@ -55,11 +63,15 @@ export function SlugDialogBody({ } }) .catch((err) => { - if (rid !== requestIdRef.current) {return} + if (rid !== requestIdRef.current) { + return + } setError(err instanceof Error ? err.message : 'Failed to load details') }) .finally(() => { - if (rid !== requestIdRef.current) {return} + if (rid !== requestIdRef.current) { + return + } setLoading(false) }) }, [owner, repo, number, type]) @@ -74,13 +86,19 @@ export function SlugDialogBody({ const commitTitle = useCallback(async () => { const next = titleDraft.trim() setEditingTitle(false) - if (!next || next === title) {return} + if (!next || next === title) { + return + } // Why: without a row id we can't address the project item — the helper // would just return "Row not found" and toast-spam the user. The title // button is also disabled in this case (see render below). - if (!row) {return} + if (!row) { + return + } const res = await patchProjectIssueOrPr(cacheKey, row.id, { title: next }) - if (!res.ok) {toast.error(res.error.message)} + if (!res.ok) { + toast.error(res.error.message) + } }, [titleDraft, title, patchProjectIssueOrPr, cacheKey, row]) const [editingBody, setEditingBody] = useState(false) @@ -88,10 +106,14 @@ export function SlugDialogBody({ const body = details?.body ?? '' const commitBody = useCallback(async () => { setEditingBody(false) - if (bodyDraft === body) {return} + if (bodyDraft === body) { + return + } // Why: same reason as commitTitle — bail rather than ask the helper to // patch a missing row. The body button is also disabled when row is null. - if (!row) {return} + if (!row) { + return + } const res = await patchProjectIssueOrPr(cacheKey, row.id, { body: bodyDraft }) if (!res.ok) { toast.error(res.error.message) @@ -177,12 +199,16 @@ export function SlugDialogBody({ onChange={async (add, remove) => { // Why: bail rather than call the helper with an empty id — // see commitTitle above. Trigger is also disabled when !row. - if (!row) {return} + if (!row) { + return + } const res = await patchProjectIssueOrPr(cacheKey, row.id, { ...(add.length ? { addLabels: add } : {}), ...(remove.length ? { removeLabels: remove } : {}) }) - if (!res.ok) {toast.error(res.error.message)} + if (!res.ok) { + toast.error(res.error.message) + } }} /> { - if (!row) {return} + if (!row) { + return + } const res = await patchProjectIssueOrPr(cacheKey, row.id, { ...(add.length ? { addAssignees: add } : {}), ...(remove.length ? { removeAssignees: remove } : {}) }) - if (!res.ok) {toast.error(res.error.message)} + if (!res.ok) { + toast.error(res.error.message) + } }} />

@@ -269,9 +299,7 @@ export function SlugDialogBody({ owner={owner} repo={repo} number={number} - onAdded={(c) => - setDetails((d) => (d ? { ...d, comments: [...d.comments, c] } : d)) - } + onAdded={(c) => setDetails((d) => (d ? { ...d, comments: [...d.comments, c] } : d))} />
diff --git a/src/renderer/src/components/github/GitHubRateLimitPill.tsx b/src/renderer/src/components/github/GitHubRateLimitPill.tsx index 7ee26f9026a..51a2e5293be 100644 --- a/src/renderer/src/components/github/GitHubRateLimitPill.tsx +++ b/src/renderer/src/components/github/GitHubRateLimitPill.tsx @@ -190,10 +190,7 @@ export default function GitHubRateLimitPill(): React.JSX.Element | null {
{b.description} {v.remaining} of {v.limit} left · resets in {formatReset(v.resetAt)} diff --git a/src/renderer/src/components/sidebar/AddRepoDialog.tsx b/src/renderer/src/components/sidebar/AddRepoDialog.tsx index 3f5d5a9beba..b7ff3fbe95e 100644 --- a/src/renderer/src/components/sidebar/AddRepoDialog.tsx +++ b/src/renderer/src/components/sidebar/AddRepoDialog.tsx @@ -205,7 +205,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { // the first focus frame from the composer's prompt textarea. closeModal() setTimeout(() => { - openModal('new-workspace-composer', { initialRepoId: repoId }) + openModal('new-workspace-composer', { initialRepoId: repoId, telemetrySource: 'sidebar' }) }, 150) }, [closeModal, openModal, repoId]) diff --git a/src/renderer/src/components/sidebar/SidebarHeader.tsx b/src/renderer/src/components/sidebar/SidebarHeader.tsx index a943e8cd9cd..1458aaf3654 100644 --- a/src/renderer/src/components/sidebar/SidebarHeader.tsx +++ b/src/renderer/src/components/sidebar/SidebarHeader.tsx @@ -162,7 +162,7 @@ const SidebarHeader = React.memo(function SidebarHeader() { if (!canCreateWorktree) { return } - openModal('new-workspace-composer') + openModal('new-workspace-composer', { telemetrySource: 'sidebar' }) }} aria-label="New workspace" disabled={!canCreateWorktree} diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 0ef0a28160d..0264ec4585e 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -790,7 +790,7 @@ const WorktreeList = React.memo(function WorktreeList() { const handleCreateForRepo = useCallback( (repoId: string) => { - openModal('new-workspace-composer', { initialRepoId: repoId }) + openModal('new-workspace-composer', { initialRepoId: repoId, telemetrySource: 'sidebar' }) }, [openModal] ) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-types.ts b/src/renderer/src/components/terminal-pane/pty-connection-types.ts index 2994f43b976..bb41e96990c 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-types.ts @@ -1,11 +1,18 @@ import type { PtyTransport } from './pty-transport' import type { ReplayingPanesRef } from './replay-guard' +import type { EventProps } from '../../../../shared/telemetry-events' export type PtyConnectionDeps = { tabId: string worktreeId: string cwd?: string - startup?: { command: string; env?: Record } | null + startup?: { + command: string + env?: Record + /** Telemetry payload for `agent_started`. Forwarded to `pty:spawn` + * so main fires the event only after the spawn succeeds. */ + telemetry?: EventProps<'agent_started'> + } | null restoredLeafId?: string | null restoredPtyIdByLeafId?: Record paneTransportsRef: React.RefObject> diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index cfd4400accc..afc0d5d0469 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -334,6 +334,7 @@ export function connectPanePty( connectionId, worktreeId: deps.worktreeId, ...(shellOverride ? { shellOverride } : {}), + ...(paneStartup?.telemetry ? { telemetry: paneStartup.telemetry } : {}), onPtyExit: onExit, onTitleChange, onPtySpawn, diff --git a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts index 8abbe6223fe..f10be8bafdb 100644 --- a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts +++ b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts @@ -6,6 +6,7 @@ * and the eager-buffer reconnection logic share. */ import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-types' +import type { EventProps } from '../../../../shared/telemetry-events' // ── Singleton PTY event dispatcher ─────────────────────────────────── // One global IPC listener per channel, routes events to transports by @@ -257,6 +258,11 @@ export type IpcPtyTransportOptions = { worktreeId?: string /** Why: mirrors PtySpawnOptions.shellOverride — see types.ts for rationale. */ shellOverride?: string + /** Telemetry metadata for the `agent_started` event. Forwarded verbatim + * to `pty:spawn` so main can fire the event after confirmed launch. The + * IPC handler re-validates the schema; this type is the renderer-side + * contract. */ + telemetry?: EventProps<'agent_started'> onPtyExit?: (ptyId: string) => void onTitleChange?: (title: string, rawTitle: string) => void onPtySpawn?: (ptyId: string) => void diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index f8cb7f7f233..e81f4efc286 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -150,6 +150,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra connectionId, worktreeId, shellOverride, + telemetry, onPtyExit, onTitleChange, onPtySpawn, @@ -353,7 +354,8 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra ...(connectionId ? { connectionId } : {}), ...(options.sessionId ? { sessionId: options.sessionId } : {}), worktreeId, - ...(shellOverride ? { shellOverride } : {}) + ...(shellOverride ? { shellOverride } : {}), + ...(telemetry ? { telemetry } : {}) }) const spawnResult = result as PtyConnectResult & { isReattach?: boolean } diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index f045bc12643..c60facc8012 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -15,6 +15,7 @@ import type { SetupSplitDirection, TerminalLayoutSnapshot } from '../../../../shared/types' +import type { EventProps } from '../../../../shared/telemetry-events' import { resolveTerminalFontWeights } from '../../../../shared/terminal-fonts' import { buildFontFamily, @@ -52,7 +53,13 @@ type UseTerminalPaneLifecycleDeps = { tabId: string worktreeId: string cwd?: string - startup?: { command: string; env?: Record } | null + startup?: { + command: string + env?: Record + /** Telemetry payload for `agent_started`. Forwarded to `pty:spawn` + * so main fires the event only after the spawn succeeds. */ + telemetry?: EventProps<'agent_started'> + } | null /** When present, the initial pane boots clean and a split pane is created * (vertical or horizontal per the user setting) to run the setup command — * keeping the main terminal interactive. */ diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 2da6a45d5c3..1045da4fd0d 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -12,9 +12,10 @@ import { parseGitHubIssueOrPRLink, normalizeGitHubLinkQuery } from '@/lib/github-links' -import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { activateAndRevealWorktree, type AgentStartedTelemetry } from '@/lib/worktree-activation' import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '@/lib/tui-agent-startup' import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' +import { tuiAgentToAgentKind } from '@/lib/telemetry' import { isGitRepoKind } from '../../../shared/repo-kind' import type { GitHubWorkItem, @@ -23,7 +24,8 @@ import type { SetupDecision, SetupRunPolicy, SparsePreset, - TuiAgent + TuiAgent, + WorkspaceCreateTelemetrySource } from '../../../shared/types' import { ADD_ATTACHMENT_SHORTCUT, @@ -65,6 +67,12 @@ export type UseComposerStateOptions = { * which drives repo selection from the page header, not the card. */ repoIdOverride?: string onRepoIdOverrideChange?: (value: string) => void + /** Telemetry surface that opened this composer. Threaded into + * `createWorktree` so `workspace_created.source` reflects the actual + * entry point (Cmd+J palette → `command_palette`, sidebar buttons → + * `sidebar`, keyboard shortcut → `shortcut`). Omitted callers default + * to `unknown` at the IPC boundary. */ + telemetrySource?: WorkspaceCreateTelemetrySource } export type ComposerCardProps = { @@ -175,7 +183,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS persistDraft, onCreated, repoIdOverride, - onRepoIdOverrideChange + onRepoIdOverrideChange, + telemetrySource } = options // Why: each `useAppStore(s => s.someAction)` registers its own equality @@ -1247,7 +1256,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS directories: normalizedSparseDirectories, ...(effectivePresetId ? { presetId: effectivePresetId } : {}) } - : undefined + : undefined, + telemetrySource ) const worktree = result.worktree @@ -1273,10 +1283,27 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS platform: CLIENT_PLATFORM }) + // Why: thread agent_started telemetry through the queued startup so + // main fires the event after the spawn succeeds. The composer + // "create" path is the new-workspace surface; request_kind is + // `'new'` because this is always a fresh session (issue/PR-driven + // follow-ups go through launch-work-item-direct.ts). + const composerTelemetry: AgentStartedTelemetry = { + agent_kind: tuiAgentToAgentKind(tuiAgent), + launch_source: 'new_workspace_composer', + request_kind: 'new' + } activateAndRevealWorktree(worktree.id, { setup: result.setup, issueCommand, - ...(startupPlan ? { startup: { command: startupPlan.launchCommand } } : {}) + ...(startupPlan + ? { + startup: { + command: startupPlan.launchCommand, + telemetry: composerTelemetry + } + } + : {}) }) if (startupPlan) { void ensureAgentStartupInTerminal({ @@ -1326,6 +1353,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS sparseEnabled, sparseError, effectivePresetId, + telemetrySource, tuiAgent, shouldRunIssueAutomation, shouldWaitForIssueAutomationCheck, @@ -1373,7 +1401,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS directories: normalizedSparseDirectories, ...(effectivePresetId ? { presetId: effectivePresetId } : {}) } - : undefined + : undefined, + telemetrySource ) const worktree = result.worktree @@ -1449,9 +1478,28 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS } } + // Why: only attach telemetry when an agent was selected — the + // quick path also handles "blank shell" (agent === null) where no + // agent_started event should fire. When telemetry is present main + // emits the event after pty:spawn succeeds. + const quickTelemetry: AgentStartedTelemetry | null = + agent === null + ? null + : { + agent_kind: tuiAgentToAgentKind(agent), + launch_source: 'new_workspace_composer', + request_kind: 'new' + } activateAndRevealWorktree(worktree.id, { setup: result.setup, - ...(startupPlan ? { startup: { command: startupPlan.launchCommand } } : {}) + ...(startupPlan + ? { + startup: { + command: startupPlan.launchCommand, + ...(quickTelemetry ? { telemetry: quickTelemetry } : {}) + } + } + : {}) }) if (startupPlan) { void ensureAgentStartupInTerminal({ @@ -1483,6 +1531,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS createWorktree, fallbackCreatureName, effectiveLinkedPR, + linkedPR, linkedWorkItem, name, normalizedSparseDirectories, @@ -1503,6 +1552,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS sparseEnabled, sparseError, effectivePresetId, + telemetrySource, shouldWaitForSetupCheck ] ) diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index ad5ceb7a831..141cc6186b0 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -149,7 +149,7 @@ export function useIpcEvents(): void { if (store.activeModal === 'new-workspace-composer') { return } - store.openModal('new-workspace-composer') + store.openModal('new-workspace-composer', { telemetrySource: 'shortcut' }) }) ) diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index dd49861c803..6e959f9c895 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -2,6 +2,7 @@ import { useAppStore } from '@/store' import { buildAgentStartupPlan, type AgentStartupPlan } from '@/lib/tui-agent-startup' import { CLIENT_PLATFORM } from '@/lib/new-workspace' import { reconcileTabOrder } from '@/components/tab-bar/reconcile-order' +import { tuiAgentToAgentKind } from '@/lib/telemetry' import type { TuiAgent } from '../../../shared/types' export type LaunchAgentInNewTabArgs = { @@ -55,8 +56,20 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI // lands after mount the agent binary never starts; the user sees a bare shell. // Since both calls happen synchronously in the same React batch, the queue // is in place by the time the pane commits. + // + // The telemetry payload is threaded through the queue → pty-connection → + // pty-transport → pty:spawn IPC → main, where main fires `agent_started` + // only after the spawn succeeds. `request_kind: 'new'` because + // quick-launch always opens a fresh empty-prompt session. const tab = store.createTab(worktreeId, groupId) - store.queueTabStartupCommand(tab.id, { command: startupPlan.launchCommand }) + store.queueTabStartupCommand(tab.id, { + command: startupPlan.launchCommand, + telemetry: { + agent_kind: tuiAgentToAgentKind(agent), + launch_source: 'tab_bar_quick_launch', + request_kind: 'new' + } + }) // Why: match the `+` button's `createNewTerminalTab` sequence — without // `setActiveTabType('terminal')`, a worktree currently showing an editor diff --git a/src/renderer/src/lib/launch-work-item-direct.ts b/src/renderer/src/lib/launch-work-item-direct.ts index 182fc38d73c..8e907875787 100644 --- a/src/renderer/src/lib/launch-work-item-direct.ts +++ b/src/renderer/src/lib/launch-work-item-direct.ts @@ -4,16 +4,23 @@ import { AGENT_CATALOG } from '@/lib/agent-catalog' import { pasteDraftWhenAgentReady } from '@/lib/agent-paste-draft' import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '@/lib/tui-agent-startup' import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' -import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { activateAndRevealWorktree, type AgentStartedTelemetry } from '@/lib/worktree-activation' import { CLIENT_PLATFORM, getLinkedWorkItemSuggestedName, getSetupConfig, getWorkspaceSeedName } from '@/lib/new-workspace' -import { getSuggestedCreatureName } from '@/components/sidebar/worktree-name-suggestions' import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' -import type { OrcaHooks, RepoHookSettings, SetupDecision, TuiAgent } from '../../../shared/types' +import { track, tuiAgentToAgentKind } from '@/lib/telemetry' +import type { + OrcaHooks, + RepoHookSettings, + SetupDecision, + TuiAgent, + WorkspaceCreateTelemetrySource +} from '../../../shared/types' +import type { LaunchSource } from '../../../shared/telemetry-events' export type LaunchableWorkItem = { title: string @@ -47,6 +54,15 @@ export type LaunchWorkItemDirectArgs = { * smart workspace-name PR selection to branch from the PR's head so the first * commit lands on the correct base without the user touching the UI. */ baseBranch?: string + /** Telemetry surface that initiated this agent launch. Threaded into + * the queued startup payload so `agent_started.launch_source` reflects + * the actual entry point. */ + launchSource: LaunchSource + /** Telemetry surface that initiated this launch. Threaded into + * `createWorktree` so `workspace_created.source` reflects the actual + * entry point (Tasks page row → `sidebar`, Create-from modal → + * `command_palette`). Omitted callers default to `unknown`. */ + telemetrySource?: WorkspaceCreateTelemetrySource } function pickAgent( @@ -96,20 +112,48 @@ async function resolveSetupDecision( } } +// Why: telemetry rides the queued startup so main fires `agent_started` +// only after pty:spawn confirms the launch. No agent / no plan → no event. +function buildStartupOpts( + agent: TuiAgent | null, + plan: ReturnType, + launchSource: LaunchSource +): { startup?: { command: string; telemetry?: AgentStartedTelemetry } } { + if (!plan) { + return {} + } + const telemetry: AgentStartedTelemetry | null = + agent === null + ? null + : { agent_kind: tuiAgentToAgentKind(agent), launch_source: launchSource, request_kind: 'new' } + return { + startup: { command: plan.launchCommand, ...(telemetry ? { telemetry } : {}) } + } +} + async function pasteWorkItemDraftWhenAgentReady(args: { primaryTabId: string startupPlan: NonNullable> content: string + /** Telemetry-only: which agent the renderer thinks it launched, so an + * `agent_error` on timeout can carry the right `agent_kind`. */ + agentKind?: ReturnType }): Promise { - const { primaryTabId, startupPlan, content } = args + const { primaryTabId, startupPlan, content, agentKind } = args await pasteDraftWhenAgentReady({ tabId: primaryTabId, content, agent: startupPlan.agent, - onTimeout: () => + onTimeout: () => { toast.message( 'Agent took too long to start. The workspace is ready — paste the issue URL when the agent is idle.' ) + // Why: process-startup timeout has no v1 enum slot; the `unknown` slice + // on the dashboard is the trigger to add one. + if (agentKind) { + track('agent_error', { error_class: 'unknown', agent_kind: agentKind }) + } + } }) } @@ -127,7 +171,7 @@ async function pasteWorkItemDraftWhenAgentReady(args: { * has a usable workspace and can paste the URL themselves. */ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Promise { - const { item, repoId, openModalFallback, baseBranch } = args + const { item, repoId, openModalFallback, baseBranch, telemetrySource, launchSource } = args const store = useAppStore.getState() const repo = store.repos.find((r) => r.id === repoId) if (!repo) { @@ -160,14 +204,22 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom let worktreeId: string let primaryTabId: string | null let startupPlan: ReturnType = null + let effectiveAgent: TuiAgent | null = null let draftLaunchedNatively = false try { - const result = await store.createWorktree(repoId, workspaceName, baseBranch, finalSetupDecision) + const result = await store.createWorktree( + repoId, + workspaceName, + baseBranch, + finalSetupDecision, + undefined, + telemetrySource + ) worktreeId = result.worktree.id const worktreePath = result.worktree.path const detectedIds = new Set(await detectedAgentsPromise) - const effectiveAgent = pickAgent(settings?.defaultTuiAgent, detectedIds) + effectiveAgent = pickAgent(settings?.defaultTuiAgent, detectedIds) const draftContent = item.pasteContent ?? item.url // Why: agents that gate first-launch behind a "Do you trust this folder?" @@ -227,7 +279,7 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom const activation = activateAndRevealWorktree(worktreeId, { setup: result.setup, - ...(startupPlan ? { startup: { command: startupPlan.launchCommand } } : {}) + ...buildStartupOpts(effectiveAgent, startupPlan, launchSource) }) if (!activation) { // Worktree vanished between create and activate — extremely unlikely but @@ -281,93 +333,10 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom // latency on agent readiness. Run the paste in the background so the // "Use" CTA's spinner ends when the worktree is ready, not when the TUI // input buffer is ready. - void pasteWorkItemDraftWhenAgentReady({ primaryTabId, startupPlan, content }) -} - -export type LaunchFromBranchArgs = { - repoId: string - baseBranch: string - /** Called when the flow cannot proceed without user input (setup policy is - * `ask`, or the selected repo cannot resolve). */ - openModalFallback: () => void -} - -/** - * Create a workspace from a specific branch with no linked work item. Skips - * the bracketed-paste draft step — there's no URL to hand the agent, so we - * just land the user in a fresh workspace rooted at the requested branch. - */ -export async function launchFromBranch(args: LaunchFromBranchArgs): Promise { - const { repoId, baseBranch, openModalFallback } = args - const store = useAppStore.getState() - const repo = store.repos.find((r) => r.id === repoId) - if (!repo) { - openModalFallback() - return - } - - const settings = store.settings - // Why: keep agent detection off the critical path while we resolve setup - // policy. Worktree creation only needs the startup command at activation. - const detectedAgentsPromise = store.ensureDetectedAgents() - - const setupResolution = await resolveSetupDecision(repoId, repo) - if (setupResolution.kind === 'needs-modal') { - openModalFallback() - return - } - - const trustDecision = await ensureHooksConfirmed(useAppStore.getState(), repoId, 'setup') - const finalSetupDecision: SetupDecision = - trustDecision === 'skip' ? 'skip' : setupResolution.decision - - // Why: branch-based launches don't carry a title hint, so fall back to the - // repo's creature-name generator — same distinct, readable default the - // quick-composer uses when the name field is blank. - const fallbackName = getSuggestedCreatureName( - repoId, - store.worktreesByRepo, - settings?.nestWorkspaces ?? true - ) - const workspaceName = getWorkspaceSeedName({ - explicitName: '', - prompt: '', - linkedIssueNumber: null, - linkedPR: null, - fallbackName + void pasteWorkItemDraftWhenAgentReady({ + primaryTabId, + startupPlan, + content, + ...(effectiveAgent ? { agentKind: tuiAgentToAgentKind(effectiveAgent) } : {}) }) - - try { - const result = await store.createWorktree(repoId, workspaceName, baseBranch, finalSetupDecision) - const detectedIds = new Set(await detectedAgentsPromise) - const effectiveAgent = pickAgent(settings?.defaultTuiAgent, detectedIds) - const startupPlan = - effectiveAgent === null - ? null - : buildAgentStartupPlan({ - agent: effectiveAgent, - prompt: '', - cmdOverrides: settings?.agentCmdOverrides ?? {}, - platform: CLIENT_PLATFORM, - allowEmptyPromptLaunch: true - }) - const activation = activateAndRevealWorktree(result.worktree.id, { - setup: result.setup, - ...(startupPlan ? { startup: { command: startupPlan.launchCommand } } : {}) - }) - if (!activation) { - toast.error('Workspace created but could not be activated.') - return - } - } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to create workspace.' - toast.error(message) - return - } - - store.setSidebarOpen(true) - if (settings?.rightSidebarOpenByDefault) { - store.setRightSidebarTab('explorer') - store.setRightSidebarOpen(true) - } } diff --git a/src/renderer/src/lib/repo-slug-index.ts b/src/renderer/src/lib/repo-slug-index.ts index 658e83b2d97..6c14526b093 100644 --- a/src/renderer/src/lib/repo-slug-index.ts +++ b/src/renderer/src/lib/repo-slug-index.ts @@ -69,14 +69,18 @@ async function buildIndex(repos: Repo[]): Promise { // negative-cached null) lingers forever. const liveIds = new Set(repos.map((r) => r.id)) for (const id of slugByRepoId.keys()) { - if (!liveIds.has(id)) {slugByRepoId.delete(id)} + if (!liveIds.has(id)) { + slugByRepoId.delete(id) + } } const next: SlugIndex = new Map() const results = await Promise.all( repos.map(async (r) => ({ repo: r, slug: await resolveRepoSlug(r) })) ) for (const { repo, slug } of results) { - if (slug) {next.set(slug, repo)} + if (slug) { + next.set(slug, repo) + } } return next } @@ -94,16 +98,21 @@ export function useRepoSlugIndex(): (slug: string | null | undefined) => Repo | useEffect(() => { const gen = ++generationRef.current void buildIndex(repos).then((next) => { - if (gen !== generationRef.current) {return} + if (gen !== generationRef.current) { + return + } setIndex(next) }) }, [repos]) return useMemo( - () => (slug: string | null | undefined): Repo | null => { - if (!slug) {return null} - return index.get(slug.toLowerCase()) ?? null - }, + () => + (slug: string | null | undefined): Repo | null => { + if (!slug) { + return null + } + return index.get(slug.toLowerCase()) ?? null + }, [index] ) } diff --git a/src/renderer/src/lib/telemetry.ts b/src/renderer/src/lib/telemetry.ts index 073de244488..2bf00b879d7 100644 --- a/src/renderer/src/lib/telemetry.ts +++ b/src/renderer/src/lib/telemetry.ts @@ -15,6 +15,12 @@ import type { EventName, EventProps } from '../../../shared/telemetry-events' import type { TelemetryConsentState } from '../../../shared/telemetry-consent-types' +// Re-exported so renderer call sites can import the mapper from this lib +// alongside `track`. The implementation lives in `src/shared/agent-kind.ts` +// because main-process telemetry emission needs the same mapping when it +// receives a `TuiAgent`-derived agent kind through the spawn IPC. +export { tuiAgentToAgentKind } from '../../../shared/agent-kind' + // Why: single source-of-truth for the privacy doc URL linked from the two // telemetry surfaces (FirstLaunchBanner, PrivacyPane). Keeping it here — in // the shared telemetry lib — prevents the surfaces from drifting if the doc diff --git a/src/renderer/src/lib/worktree-activation.test.ts b/src/renderer/src/lib/worktree-activation.test.ts index ecb917a500a..26199221f59 100644 --- a/src/renderer/src/lib/worktree-activation.test.ts +++ b/src/renderer/src/lib/worktree-activation.test.ts @@ -111,6 +111,34 @@ describe('ensureWorktreeHasInitialTerminal', () => { expect(store.queueTabIssueCommandSplit).not.toHaveBeenCalled() }) + it('forwards telemetry on the queued startup so main can fire agent_started', () => { + const store = createMockStore() + + ensureWorktreeHasInitialTerminal( + store, + 'wt-1', + { + command: 'claude', + telemetry: { + agent_kind: 'claude-code', + launch_source: 'new_workspace_composer', + request_kind: 'new' + } + }, + undefined, + undefined + ) + + expect(store.queueTabStartupCommand).toHaveBeenCalledWith('tab-1', { + command: 'claude', + telemetry: { + agent_kind: 'claude-code', + launch_source: 'new_workspace_composer', + request_kind: 'new' + } + }) + }) + it('does not create a terminal just because the legacy terminal slice is empty', () => { const store = createMockStore({ tabsByWorktree: { 'wt-1': [] }, diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index b9228bc68c0..f78de0d2aa2 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -1,4 +1,5 @@ import type { SetupSplitDirection, WorktreeSetupLaunch } from '../../../shared/types' +import type { EventProps } from '../../../shared/telemetry-events' import { shouldAutoCreateInitialTerminal } from '@/components/terminal/initial-terminal' import { buildSetupRunnerCommand } from './setup-runner' import { useAppStore } from '@/store' @@ -8,6 +9,11 @@ import { setWorktreeNavViewActivator } from '@/store/slices/worktree-nav-history' +/** Telemetry payload threaded from the launch site to `pty:spawn`. Main + * fires `agent_started` only after the spawn succeeds — see + * telemetry-plan.md§Agent launch semantics. */ +export type AgentStartedTelemetry = EventProps<'agent_started'> + // Why: issue commands can originate from two sources with different shapes — // (1) a repo-level runner script generated by main (WorktreeSetupLaunch), or // (2) a user-typed command template substituted in the TaskPage flow. @@ -30,7 +36,11 @@ type WorktreeActivationStore = { reconcileWorktreeTabModel: (worktreeId: string) => { renderableTabCount: number } queueTabStartupCommand: ( tabId: string, - startup: { command: string; env?: Record } + startup: { + command: string + env?: Record + telemetry?: AgentStartedTelemetry + } ) => void queueTabSetupSplit: ( tabId: string, @@ -65,7 +75,11 @@ export type ActivateAndRevealResult = { export function activateAndRevealWorktree( worktreeId: string, opts?: { - startup?: { command: string; env?: Record } + startup?: { + command: string + env?: Record + telemetry?: AgentStartedTelemetry + } setup?: WorktreeSetupLaunch issueCommand?: IssueCommandLaunch } @@ -133,7 +147,7 @@ export function activateAndRevealWorktree( export function ensureWorktreeHasInitialTerminal( store: WorktreeActivationStore, worktreeId: string, - startup?: { command: string; env?: Record }, + startup?: { command: string; env?: Record; telemetry?: AgentStartedTelemetry }, setup?: WorktreeSetupLaunch, issueCommand?: IssueCommandLaunch ): string | null { diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index e3b068fecf8..9531d5687ba 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -8,6 +8,7 @@ import type { Worktree, WorkspaceSessionState } from '../../../../shared/types' +import type { AgentStartedTelemetry } from '../../lib/worktree-activation' import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' import { clearTransientTerminalState, emptyLayoutSnapshot } from './terminal-helpers' import { isClaudeAgent, detectAgentStatusFromTitle } from '@/lib/agent-status' @@ -80,7 +81,17 @@ export type TerminalSlice = { expandedPaneByTabId: Record canExpandPaneByTabId: Record terminalLayoutsByTabId: Record - pendingStartupByTabId: Record }> + pendingStartupByTabId: Record< + string, + { + command: string + env?: Record + /** Telemetry metadata for the `agent_started` event. Threaded all the + * way to the `pty:spawn` IPC handler in main so the event fires only + * after spawn confirms — never on click-intent. */ + telemetry?: AgentStartedTelemetry + } + > /** Queued setup-split requests — when present, TerminalPane creates the * initial pane clean, then splits (vertical or horizontal per user setting) * and runs the command in the new pane so the main terminal stays @@ -163,11 +174,15 @@ export type TerminalSlice = { setTabLayout: (tabId: string, layout: TerminalLayoutSnapshot | null) => void queueTabStartupCommand: ( tabId: string, - startup: { command: string; env?: Record } + startup: { + command: string + env?: Record + telemetry?: AgentStartedTelemetry + } ) => void consumeTabStartupCommand: ( tabId: string - ) => { command: string; env?: Record } | null + ) => { command: string; env?: Record; telemetry?: AgentStartedTelemetry } | null queueTabSetupSplit: ( tabId: string, startup: { command: string; env?: Record; direction: SetupSplitDirection } diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index adf6a35c39c..1f4d4502675 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -2,6 +2,7 @@ import type { CreateWorktreeResult, CreateSparseCheckoutRequest, SetupDecision, + WorkspaceCreateTelemetrySource, Worktree, WorktreeMeta } from '../../../../shared/types' @@ -59,7 +60,11 @@ export type WorktreeSlice = { name: string, baseBranch?: string, setupDecision?: SetupDecision, - sparseCheckout?: CreateSparseCheckoutRequest + sparseCheckout?: CreateSparseCheckoutRequest, + /** Telemetry-only: which renderer surface initiated this create. Optional + * so existing callers default to `unknown`; specify when the surface + * matters for the activation funnel. */ + telemetrySource?: WorkspaceCreateTelemetrySource ) => Promise removeWorktree: ( worktreeId: string, diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index e59e277562f..1e8dc80920b 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -162,7 +162,14 @@ export const createWorktreeSlice: StateCreator set({ hasHydratedWorktreePurge: true }) }, - createWorktree: async (repoId, name, baseBranch, setupDecision = 'inherit', sparseCheckout) => { + createWorktree: async ( + repoId, + name, + baseBranch, + setupDecision = 'inherit', + sparseCheckout, + telemetrySource + ) => { const retryableConflictPatterns = [ /already exists locally/i, /already exists on a remote/i, @@ -180,7 +187,8 @@ export const createWorktreeSlice: StateCreator name: candidateName, baseBranch, setupDecision, - sparseCheckout + sparseCheckout, + ...(telemetrySource ? { telemetrySource } : {}) }) // Why: a file watcher (worktrees.onChanged) can fire between the // backend creating the worktree and this callback running, causing diff --git a/src/shared/agent-kind.ts b/src/shared/agent-kind.ts new file mode 100644 index 00000000000..04bd0aed2b1 --- /dev/null +++ b/src/shared/agent-kind.ts @@ -0,0 +1,34 @@ +// 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. +// +// Lives in `src/shared/` (not the renderer) because main-side telemetry +// emission (`agent_started` from the `pty:spawn` IPC handler) needs the +// same mapping. Centralizing here means a new TuiAgent member is one edit, +// not a sweep across renderer + main. + +import type { AgentKind } from './telemetry-events' +import type { TuiAgent } from './types' + +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' + } +} diff --git a/src/shared/github-project-types.ts b/src/shared/github-project-types.ts index f5d2f0eef16..90ea46f9e37 100644 --- a/src/shared/github-project-types.ts +++ b/src/shared/github-project-types.ts @@ -59,9 +59,7 @@ export type GitHubProjectField = kind: 'field' id: string name: string - dataType: - | Exclude - | (string & {}) + dataType: Exclude | (string & {}) } | { kind: 'single-select' diff --git a/src/shared/telemetry-events.test.ts b/src/shared/telemetry-events.test.ts index ff684957832..04bc23be3e0 100644 --- a/src/shared/telemetry-events.test.ts +++ b/src/shared/telemetry-events.test.ts @@ -1,12 +1,10 @@ // Schema round-trip coverage for the event map. Fail-closed invariants that // must hold: agent_error is enum-only (error_message / error_stack rejected -// by `.strict()`), error_name is whitelisted, unknown enum values fail, and -// any well-formed payload round-trips without coercion. +// by `.strict()`), unknown enum values fail, and any well-formed payload +// round-trips without coercion. import { describe, expect, it } from 'vitest' import { - AGENT_ERROR_NAME_WHITELIST, - agentErrorNameSchema, agentKindSchema, commonPropsSchema, errorClassSchema, @@ -18,38 +16,18 @@ import { describe('agent_error schema', () => { it('round-trips a minimal {error_class, agent_kind} payload', () => { const parsed = eventSchemas.agent_error.safeParse({ - error_class: 'auth_expired', + error_class: 'unknown', agent_kind: 'claude-code' }) expect(parsed.success).toBe(true) }) - it('round-trips every whitelisted error_name value', () => { - for (const name of AGENT_ERROR_NAME_WHITELIST) { - const parsed = eventSchemas.agent_error.safeParse({ - error_class: 'auth_expired', - agent_kind: 'claude-code', - error_name: name - }) - expect(parsed.success).toBe(true) - } - }) - - it('rejects error_name values outside the whitelist', () => { - const parsed = eventSchemas.agent_error.safeParse({ - error_class: 'auth_expired', - agent_kind: 'claude-code', - error_name: 'SomeNonWhitelistedName' - }) - expect(parsed.success).toBe(false) - }) - // Core invariant: `.strict()` rejects raw error strings. If this test ever // flips, the analytics lane is leaking UGC — revert the offending schema // change. it('rejects error_message via .strict()', () => { const parsed = eventSchemas.agent_error.safeParse({ - error_class: 'auth_expired', + error_class: 'unknown', agent_kind: 'claude-code', error_message: 'boom at /Users/alice/secret/path' }) @@ -58,13 +36,25 @@ describe('agent_error schema', () => { it('rejects error_stack via .strict()', () => { const parsed = eventSchemas.agent_error.safeParse({ - error_class: 'auth_expired', + error_class: 'unknown', agent_kind: 'claude-code', error_stack: 'Error: boom\n at /Users/alice/...' }) expect(parsed.success).toBe(false) }) + it('rejects error_name (deferred — schema is enum-only)', () => { + // `error_name` was part of an earlier draft. The trimmed schema is + // enum-only; if a future PR re-introduces it as additive-optional, + // this test should be replaced rather than relaxed silently. + const parsed = eventSchemas.agent_error.safeParse({ + error_class: 'unknown', + agent_kind: 'claude-code', + error_name: 'BinaryNotFound' + }) + expect(parsed.success).toBe(false) + }) + it('rejects unknown error_class enum values', () => { const parsed = eventSchemas.agent_error.safeParse({ error_class: 'made_up_class', @@ -75,7 +65,7 @@ describe('agent_error schema', () => { it('rejects unknown agent_kind enum values', () => { const parsed = eventSchemas.agent_error.safeParse({ - error_class: 'auth_expired', + error_class: 'unknown', agent_kind: 'made_up_agent' }) expect(parsed.success).toBe(false) @@ -213,10 +203,4 @@ describe('exported enum schemas', () => { expect(settingsChangedKeySchema.safeParse(key).success).toBe(true) } }) - - it('agentErrorNameSchema membership matches AGENT_ERROR_NAME_WHITELIST', () => { - for (const name of AGENT_ERROR_NAME_WHITELIST) { - expect(agentErrorNameSchema.safeParse(name).success).toBe(true) - } - }) }) diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts index bc5dcf2d7fb..91751968a90 100644 --- a/src/shared/telemetry-events.ts +++ b/src/shared/telemetry-events.ts @@ -35,43 +35,22 @@ export const agentKindSchema = z.enum([ ]) export type AgentKind = z.infer -export const errorClassSchema = z.enum([ - 'network_timeout', - 'auth_expired', - 'rate_limited', - 'provider_unavailable', - 'provider_error_generic', - 'binary_not_found', - 'binary_version_mismatch', - 'workspace_gone', - 'user_cancelled', - 'unknown' -]) +// Trimmed to the two values Orca's PTY-typed-command launch architecture can +// actually emit: +// - `binary_not_found` — `provider.spawn` ENOENT (the *shell* binary is +// missing). The agent CLI being missing is invisible: Orca spawns a +// healthy shell and types the command, and bash/zsh's "command not found" +// surfaces only as terminal output. +// - `unknown` — every other thrown error (paste-readiness timeout, env-build +// failures, unclassifiable shell-spawn errors). +// Provider-side errors (`auth_expired`, `rate_limited`, `network_timeout`, +// `provider_*`) happen inside the agent CLI subprocess and are not observable +// to Orca — see telemetry-plan.md §Decision: Defer per-incident error fields. +// Adding a new value is additive-safe; do it when the call site lands, not in +// anticipation. +export const errorClassSchema = z.enum(['binary_not_found', 'unknown']) export type ErrorClass = z.infer -// Closed whitelist of error `name` strings allowed on `agent_error`. This is -// the one free-ish string that can leave the machine on an agent_error event -// — the validator drops anything not in this set. -// -// A regex-shape check (e.g. `/^[A-Z][A-Za-z]{0,32}$/`) would permit -// identifier-shaped leaks like `PaymentFailedForUserAlice` or -// `TimeoutInRepoMyCompanyInternalMonorepo` — context-concatenation bugs -// under deadline pressure. A closed whitelist forces each new error name -// through review. Same pattern as `SETTINGS_CHANGED_WHITELIST`. -export const AGENT_ERROR_NAME_WHITELIST = [ - 'NetworkTimeout', - 'AuthExpired', - 'RateLimited', - 'ProviderUnavailable', - 'ProviderErrorGeneric', - 'BinaryNotFound', - 'BinaryVersionMismatch', - 'WorkspaceGone', - 'UserCancelled' -] as const -export const agentErrorNameSchema = z.enum(AGENT_ERROR_NAME_WHITELIST) -export type AgentErrorName = z.infer - export const repoMethodSchema = z.enum(['folder_picker', 'clone_url', 'drag_drop']) export type RepoMethod = z.infer @@ -161,22 +140,15 @@ const agentStartedSchema = z }) .strict() -// Enum-only by design for `error_class` + `agent_kind`. `error_name` is the -// one free-ish string that can leave the machine on this event, and it is -// drawn from the closed `AGENT_ERROR_NAME_WHITELIST` — adding a new value -// requires a PR to the whitelist, giving review a chance to catch -// context-concatenation patterns. -// -// `error_message` and `error_stack` are deliberately absent from this schema. -// `.strict()` rejects either key if a call site ever tries to attach one, -// which fails the validator and drops the event. Raw error strings carry -// arbitrary user/workspace/path content; keeping them off the wire is the -// only way to guarantee we never transmit them by accident. +// Enum-only by design for both fields. `error_message` and `error_stack` are +// deliberately absent — `.strict()` rejects either key if a call site ever +// tries to attach one, which fails the validator and drops the event. Raw +// error strings carry arbitrary user/workspace/path content; keeping them off +// the wire is the only way to guarantee we never transmit them by accident. const agentErrorSchema = z .object({ error_class: errorClassSchema, - agent_kind: agentKindSchema, - error_name: agentErrorNameSchema.optional() + agent_kind: agentKindSchema }) .strict() diff --git a/src/shared/types.ts b/src/shared/types.ts index 424f56b5d72..4fea0483f66 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1,7 +1,12 @@ /* eslint-disable max-lines */ import type { SshTarget } from './ssh-types' +import type { WorkspaceSource } from './telemetry-events' import type { GitHubProjectSettings } from './github-project-types' +// Re-exported for backward compat with renderer call sites that import +// `WorkspaceCreateTelemetrySource` from '../../../shared/types'. +export type { WorkspaceSource as WorkspaceCreateTelemetrySource } from './telemetry-events' + // ─── Repo ──────────────────────────────────────────────────────────── export type RepoKind = 'git' | 'folder' @@ -832,6 +837,15 @@ export type CreateWorktreeArgs = { baseBranch?: string setupDecision?: SetupDecision sparseCheckout?: CreateSparseCheckoutRequest + /** Telemetry-only: which UI surface initiated this create. Threaded from + * the renderer entry point so main can emit `workspace_created` with the + * correct `source`. `unknown` is a valid wire value — an unrecognized + * surface emits `source: 'unknown'` rather than dropping the event, so + * dashboards surface enum-coverage gaps as a slice rather than as + * missing data. Optional on the type so older renderer code paths that + * pre-date this prop default to `unknown` at the IPC boundary instead + * of failing typecheck. */ + telemetrySource?: WorkspaceSource } export type CreateWorktreeResult = { From 2b83dcb85d9e7b12d129a3050c604a65310cc37c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 18:58:25 +0000 Subject: [PATCH 08/28] release: v1.3.35-rc.4 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index bb66a7f2a06..834e1841b12 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.3.35-rc.3", + "version": "1.3.35-rc.4", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", 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 09/28] 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 10/28] 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 11/28] 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 12/28] 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 13/28] 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 14/28] 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 15/28] 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 16/28] 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 17/28] 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 18/28] 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 19/28] 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 20/28] 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 21/28] 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 22/28] 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} +