From 3ac182cffc845fbc80a4a38356706e73b45c6189 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 28 May 2026 12:34:31 +0800 Subject: [PATCH] Improve app tests (#1197) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(testing): require ports-and-adapters unit tests or real e2e — no in-between State the end-state explicitly so the test-suite cleanup has a written bar. * Make useProjects a ports-and-adapters unit test Extract the per-host workspace aggregation out of useProjects into a pure fetchAggregatedProjects(input) that takes a typed ProjectsRuntime adapter. The hook becomes a thin useQuery shim. The test loses jsdom, @testing-library/react, QueryClientProvider, and all vi.mock/vi.hoisted of @/runtime/host-runtime. It now exercises the real aggregation against an in-memory ProjectsRuntime adapter. * Make useLoadOlderAgentHistory a ports-and-adapters unit test Extract the load-older sequence into a pure async function that takes its client, in-flight tracker, toast, and logger as injected dependencies. The hook reads the session store at call time and wires the real adapters in. Tests now drive the pure function with typed fakes — no JSDOM, no @testing-library/react renderHook, no console spy. * Make useAgentHistory a ports-and-adapters unit test Drop the dead __private__ namespace from use-agent-history.ts, expose fetchAgentHistoryPage and an AgentHistoryClient port as normal exports. The hook keeps its useInfiniteQuery wiring; the page fetcher is now testable in isolation. The test loses jsdom, @testing-library/react, QueryClientProvider, the vi.hoisted/vi.mock of @/runtime/host-runtime, and the renderHook timing loops. It exercises the page fetcher directly against an in-memory AgentHistoryClient adapter that records each call. * Make usePrPaneData a ports-and-adapters unit test Extract the timeline fetch into a pure fetchPrPaneTimelinePage that takes a PrPaneTimelineClient adapter and an UnsupportedTimelineRegistry port. Extract the rest of the hook's wiring into pure exports: extractPrRepoIdentity, shouldFetchTimelineFrom, selectPrPaneState. The module-level unsupported-tuple Set becomes a swappable in-memory registry; production still wires the same module-level default. The test loses JSDOM, vi.hoisted + vi.mock of @/runtime/host-runtime, QueryClientProvider, createRoot, act, focusManager/onlineManager toggling, and a hand-rolled waitForExpectation polling loop. It drives each pure function and the timeline fetcher directly against typed fakes and asserts on recorded state. * Make useProvidersSnapshot a ports-and-adapters unit test Extract the network and cache work into pure functions taking a typed ProvidersSnapshotClient adapter: fetchProvidersSnapshot, refreshAndApplyProvidersSnapshot, applyProvidersSnapshotUpdate, and selectorOpenRefetchDecision. The hook still drives them through react-query and the host-runtime websocket subscription; production wires the same DaemonClient as before. The test loses @vitest-environment jsdom, vi.hoisted + vi.mock of @/runtime/host-runtime, renderHook + QueryClientProvider, act, and a hand-rolled listener-bucket trap that reached into the spied client.on subscriber. It drives each pure function directly against a typed FakeProvidersSnapshotClient and a real QueryClient. * Make useSettings a ports-and-adapters unit test Extract the pure load/save logic into use-settings.pure.ts taking a typed SettingsDeps adapter: a KeyValueStorage port and a DesktopSettingsBridge port. The hook file keeps the public 0-arg API (loadAppSettingsFromStorage, loadSettingsFromStorage, persistAppSettings, saveAppSettings) by wrapping the pure functions with productionDeps that wire AsyncStorage, isElectronRuntime, loadDesktopSettings, and migrateLegacyDesktopSettings as before. The test loses three vi.mock blocks (async-storage, @/desktop/host, @/desktop/settings/desktop-settings), two vi.hoisted blobs, and the vi.resetModules + await import("./use-settings") per-test pattern. It drives the pure functions directly against an in-memory InMemoryKeyValueStorage and a FakeDesktopBridge that records applied migrations, asserting on observable storage state rather than mock function calls. * Make useAgentCommandsQuery a ports-and-adapters unit test Extract the daemon call into fetchAgentCommands, a pure async function taking a typed AgentCommandsClient adapter. The hook still wraps it in useQuery and resolves the host-runtime client; production wires the same DaemonClient as before. The test loses @vitest-environment jsdom, vi.hoisted + vi.mock of @/runtime/host-runtime, renderHook + QueryClientProvider, and waitFor. It calls fetchAgentCommands directly against a typed FakeAgentCommandsClient. * Make useChangesPreferences a ports-and-adapters unit test Extract the AsyncStorage-touching load and save logic into use-changes-preferences.pure.ts taking a typed KeyValueStorage adapter. The hook file keeps the public API (useChangesPreferences, loadChangesPreferencesFromStorage) by wrapping the pure functions with a productionStorage wired to AsyncStorage as before. The test loses vi.hoisted + vi.mock of @react-native-async-storage/async-storage, vi.resetModules, and the per-test dynamic await import("./use-changes-preferences") pattern. It drives the pure functions directly against an in-memory InMemoryKeyValueStorage, asserting on observable storage entries rather than mock function calls. Adds coverage for saveChangesPreferences, including the no-prior-cache fallback path. * Make useArchiveAgent a ports-and-adapters unit test Extract the queryClient-only pure helpers (toArchiveKey, selectPendingArchiveAgentIds, setAgentArchiving, isAgentArchiving, removeAgentFromListPayload, markAgentArchivedInHistoryPayload, and the queryClient-mutating cache helpers) into use-archive-agent.pure.ts. The hook file keeps the React surface (usePendingArchiveAgentIds, useArchiveAgent, applyArchivedAgentCloseResults) by importing from the pure module, and the __private__ reach-around export is gone. The test loses @vitest-environment jsdom, @testing-library/react, the renderHook/act/waitFor imports, and the __private__ reach-around. It calls the pure helpers directly against a real QueryClient and the real session store. The renderHook test of usePendingArchiveAgentIds is dropped — it asserted on react-query's subscription mechanics rather than on our logic. * Make useSidebarWorkspacesList a ports-and-adapters unit test Extract the pure pieces — types, applyStoredOrdering, appendMissingOrderKeys, buildSidebarProjectsFromStructure, and the new computeSidebarOrderUpdates + deriveSidebarLoadingState helpers — into use-sidebar-workspaces-list.pure.ts. The hook reads its persistent sidebar order via the pure helper inside useEffect and derives its loading state via the pure helper, instead of inlining the logic across two effects. createSidebarWorkspaceEntry stays in the hook file since it pulls selectPrHintFromStatus, but the hook re-exports it for existing callers. The test loses @vitest-environment jsdom, react-dom/client, @testing-library/react, and the three Probe components that mounted React just to assert on effect mechanics. It calls computeSidebarOrderUpdates and deriveSidebarLoadingState directly, with no module mocks or hoisted globals. The "does not subscribe while disabled" assertion is dropped — it was testing useSyncExternalStore's subscribe gate, not our logic. * Make useAgentInitialization a ports-and-adapters unit test Extract ensureAgentIsInitialized and refreshAgent — the entire imperative bodies of the hook's two callbacks — into use-agent-initialization.pure.ts, taking setAgentInitializing as an injected port. Add createSetAgentInitializing as a factory that binds serverId to the zustand setInitializingAgents action. The hook itself collapses to ~20 lines of useMemo + useCallback bindings. The test drops @vitest-environment jsdom, @testing-library/react, renderHook, and act. It calls ensureAgentIsInitialized and refreshAgent directly with a bound setAgentInitializing fake. No React mounting, no module mocks, no hoisted globals. * Make useClientActivity a ports-and-adapters unit test Extract the activity-tracker state machine — lastActivityAt bookkeeping, heartbeat throttling, app-visibility transitions, system-idle monotonic update, and focused-agent change handling — into use-client-activity.pure.ts. createClientActivityTracker takes the heartbeat client, deviceType, and a now() port; the hook wires DOM / AppState / Electron-idle listeners to tracker methods. The test drops @vitest-environment jsdom, react-dom/client mounting, act, and four module mocks (@/constants/platform, @/desktop/electron/idle, react-native, @getpaseo/client/internal/daemon-client). It calls the tracker directly with a fake heartbeat client and a test clock; the fake records emitted heartbeats as observable state. * Make useHoverSafeZone a ports-and-adapters unit test Extract the safe-zone state machine — wasInside dedupe, bridge-rect geometry between trigger and content, and inside/outside transitions — into use-hover-safe-zone.pure.ts. createHoverSafeZoneTracker takes getTriggerRect / getContentRect / onEnterSafeZone / onLeaveSafeZone; the hook just wires document pointermove, window pointerout, and window blur listeners to tracker methods. The test drops @vitest-environment jsdom, react-dom/client mounting, act, @testing-library/react renderHook, the vi.mock("@/constants/platform") shim, the IS_REACT_ACT_ENVIRONMENT stub, and the getBoundingClientRect patching helper. It calls the tracker directly with fake rect getters and asserts on recorded enter/leave counts. * Make useArchiveSubagent a ports-and-adapters unit test * Make openImagePathsWithDesktopDialog a ports-and-adapters unit test Inject the DesktopDialogBridge into openImagePathsWithDesktopDialog instead of reaching for getDesktopHost() inside the function. useImageAttachmentPicker passes getDesktopHost()?.dialog at the call site; the native sibling matches the new signature. The test drops vi.mock("@/desktop/host", ...) and vi.hoisted() in favor of a typed in-memory fake dialog that records the options it was called with. No global module substitution, no spies — the test reads the fake's recorded state. * Make useIosHardwareKeyboardSubmit a ports-and-adapters unit test * Make UpdateCalloutSource a ports-and-adapters unit test Extract resolveUpdateCalloutDescriptor as a pure function that maps updater state to a structured callout descriptor. UpdateCalloutSource becomes a thin React shim that materializes the descriptor's icon and description as ReactNodes before registering with the sidebar callout API. Replaces a 259-line JSDOM + react-dom/client test that mocked five modules (unistyles theme, lucide icons, async-storage, openExternalUrl, useDesktopAppUpdater) and mounted SidebarCalloutProvider/Slot just to assert deterministic title/description/action/dismissal-key derivations. The new test exercises the resolver directly with zero React, zero DOM, zero mocks. * Delete dead useWorkspaceNavigation hook + collapse re-export indirection The hook had zero production callers — only its own test, which used vi.hoisted + vi.mock + jsdom + @testing-library/react/renderHook to verify a useCallback wrapper. The file also re-exported navigateToWorkspace from the navigation store, so five production importers and two sibling tests reached the store through a hook-module path that had nothing to do with hooks. Retargets every importer to @/stores/navigation-active-workspace-store directly and removes the indirection module + its slop test. * Make useCheckoutStatusQuery a ports-and-adapters unit test Extract peekOrFetchCheckoutStatus and applyCheckoutStatusUpdate to a sibling checkout-status-cache.ts so both pure functions operate on an injected QueryClient and CheckoutStatusClient, with no React or host runtime imports. useCheckoutStatusQuery becomes a thin shell that composes useQuery + useEffect and delegates the cwd-filter + cache write to applyCheckoutStatusUpdate. Replaces a 318-line JSDOM + react-dom/client + fake-timers test that mocked @/runtime/host-runtime via vi.hoisted, mounted a Probe component to read the hook's data, and captured the subscription handler in a hoisted Set. The new test exercises both functions directly against a real QueryClient — zero React, zero DOM, zero mocks, zero fake timers. * Make workspace-navigation a ports-and-adapters unit test * Make navigateToAgent a ports-and-adapters unit test * Make redirectIfArchivingActiveWorkspace a ports-and-adapters unit test * Make openProjectDirectly a ports-and-adapters unit test * Make navigation-active-workspace-store a ports-and-adapters unit test * Make desktop-attachment-store a ports-and-adapters unit test * Make readDesktopSystemIdleTimeMs a ports-and-adapters unit test Rename getDesktopSystemIdleTimeMs to readDesktopSystemIdleTimeMs and take the desktop IPC invoker as a parameter. The single caller in use-client-activity.ts now imports invokeDesktopCommand directly and passes it; the test wires a typed fake invoker. Drops vi.mock("@/desktop/electron/invoke"), vi.hoisted, and the four vi.spyOn(console, "warn") log assertions. Tests now assert the documented behaviour (returns ms, or null) against an in-memory fake. * Make useDesktopAppUpdater a ports-and-adapters unit test Extract the check/install state machine from the React hook into a pure createDesktopAppUpdater runtime that exposes getSnapshot/subscribe/ checkForUpdates/installUpdate. The hook now wires real production deps into the runtime and bridges its snapshot via useSyncExternalStore; React-driven concerns (pending-update interval, initial silent check) stay in the hook. Drops JSDOM, @testing-library/react, renderHook, the three vi.mock calls and the vi.hoisted state shim from the test. The new test wires a typed FakeDesktopAppUpdaterPort recording recordedChecks/recordedInstalls and exposes deferNextCheck/failNextCheck/nextInstallResult so the behaviour assertions read as plain English (status transitions through checking, available, pending, up-to-date, error; race cancellation drops older results; install errors get reported once). * Make sidebar-collapsed-sections-store a ports-and-adapters unit test * Make session-store-hooks a ports-and-adapters unit test * Make panel-store a ports-and-adapters unit test * Make workspace-tabs-store a ports-and-adapters unit test * Make desktop-preview-url a ports-and-adapters unit test * Make client-id a ports-and-adapters unit test * Make browser-store a ports-and-adapters unit test * Make local-file-attachment-store a ports-and-adapters unit test * Make draft-store a ports-and-adapters unit test * Make rich-clipboard a ports-and-adapters unit test * Make desktop-daemon-transport a ports-and-adapters unit test * Make image-attachment-picker.native a ports-and-adapters unit test Extract the pure normalize logic into image-attachment-picker.native.pure.ts and take the PNG exporter as a port. The .native.ts entry wires the real expo-image-manipulator adapter; the test wires a fake exporter and asserts on its recorded uris. Drops vi.mock("expo-image-manipulator") and the inline ImageManipulator stub from the test. Production callers of normalizePickedImageAssets are unchanged. * Make tool-call-icon a ports-and-adapters unit test Split the pure icon-identity decision into tool-call-icon-name.ts and keep the lucide/PaseoLogo component lookup in tool-call-icon.ts. The resolver returns a ToolCallIcon string ("bot", "brain", "paseo", ...); componentForToolCallIcon does the React component mapping; the existing resolveToolCallIcon is the composition. Drops vi.mock("lucide-react-native") from the test and the brittle "expect(icon).toBe(iconMocks.Bot)" pattern — the unit project couldn't evaluate lucide-react-native, which is why the mock existed in the first place. The test now asserts on string identifiers and imports only the pure module. buildToolCallPresentation's resolveIcon port is unchanged; the single caller (components/message.tsx) keeps passing resolveToolCallIcon and gets the same component back. * Make desktop-permissions a ports-and-adapters unit test The test was the heaviest globalThis-juggling test in the app package: vi.doMock("react-native") + vi.resetModules() per case to swap the platform, ensureWindow/setNavigator/restoreGlobals to swap globalThis.Notification, globalThis.navigator, and window.paseoDesktop. That whole setup existed because the production module reached into four ambient sources (Notification, navigator, getDesktopHost(), isWeb/isNative) without a port. Extract a DesktopPermissionEnvironment interface — { isWeb, getDesktopHost, getNotification, getNavigator } — and rebuild the module around a createDesktopPermissions(env) factory. The real environment binds to the actual globals at module load and the existing top-level exports (shouldShowDesktopPermissionSection, getDesktopPermissionSnapshot, requestDesktopPermission) are thin references onto it, so the only caller (use-desktop-permissions.ts) is unchanged. The test now constructs a fakeEnvironment per case and calls into createDesktopPermissions directly — no JSDOM, no vi.doMock, no vi.resetModules, no globalThis writes. The eight behaviors are preserved. * Make provider-icons a ports-and-adapters unit test Split the pure provider→icon-identity decision into provider-icon-name.ts and keep the lucide/SvgXml/catalog component lookup in provider-icons.ts. resolveProviderIconName returns a ProviderIconName ({kind:"builtin"|"catalog"|"bot", id?}), and getProviderIcon composes it with the catalog/builtin component maps. Drops vi.mock("lucide-react-native") from the test — the previous "expect(icon).toBe(iconMocks.Bot)" pattern only existed because the unit project couldn't evaluate lucide-react-native. The new test asserts on discriminated-union identifiers and imports only the pure module. The 13 outside callers of getProviderIcon are unchanged. * Make crypto polyfill a ports-and-adapters unit test * Delete redundant JSDOM Index route test packages/app/src/app/index.test.tsx mounted the Index component through JSDOM + createRoot + @testing-library/react, mocked five modules (expo-router, _layout, desktop-daemon, startup-splash-screen, navigation-active-workspace-store), and asserted that got rendered with the right href across six scenarios. Each of those six scenarios is a one-to-one duplicate of a pure case already covered in host-runtime-bootstrap.test.ts, which tests the same two decision functions (resolveStartupRedirectRoute, resolveStartupWorkspaceSelection) directly. The component is pure wiring: it reads four hooks, calls the two decision functions, and renders the result. There is no logic in Index to verify that the pure tests do not already cover. Drop the JSDOM file; the wiring is covered by app E2E. * Make review-draft-store a ports-and-adapters unit test * Make new-workspace-empty a ports-and-adapters unit test * Extract shared drag-reorder state machine for web sortable lists * Extract pure subagents track presentation helpers Move formatHeaderLabel and resolveRowLabel out of track.tsx into a colocated track-presentation.ts. Six header-copy tests that previously mounted React under JSDOM with eight vi.mocks now run as plain unit tests against the pure helpers. * Make terminal-file-drop a ports-and-adapters unit test * Extract pure import-session-sheet view-model helpers Move resolveProvidersToFetch, buildProviderLabelMap, aggregateSessionEntries, sumFilteredAlreadyImportedCount, collectErroredProviderLabels, getSessionTitle, getPromptPreview, and computeEmptyState out of import-session-sheet.tsx into a colocated import-session-sheet.pure.ts. Add 28 pure unit tests that exercise provider resolution, dedupe/sort, error label fallback, title fallback, and the empty-state state machine directly — no JSDOM, no vi.mock. The existing 859-line JSDOM import-session-sheet.test.tsx stays for now; next ticks can replace its status-message and empty-state cases with the pure coverage. * Make subagents track a ports-and-adapters unit test Move the row-presentation data builder from track.tsx into the colocated pure track-presentation.ts and cover statusBucket, titleState, and label in plain node tests. Delete the 235-line JSDOM track.test.tsx with its eight vi.mocks and createRoot harness — the remaining behaviours (empty returns null, useState toggle, onPress wiring) are React idioms covered by the framework, not domain logic. * Make isolated-bottom-sheet-modal a ports-and-adapters unit test * Stop UI from leaking through subagents barrel The subagents/index.ts barrel re-exported SubagentsTrack (the React component) alongside pure logic like selectSubagentsForParent. Any pure consumer of the barrel transitively pulled in lucide-react-native and react-native-unistyles, forcing tests that only touch pure logic to declare cosmetic vi.mock blocks for icons, theme, tooltip, and provider icons just to get the import graph to load. Drop SubagentsTrack from the barrel. The one external caller (panels/agent-panel.tsx) deep-imports from @/subagents/track instead. The workspace-subagents-integration test loses 56 lines of pretend-UI mocks; only the AsyncStorage mock remains because workspace-layout-store uses it directly through persist middleware. * Collapse use-hover-safe-zone.pure.ts into hover-safe-zone-tracker.ts The `.pure.ts` suffix mimics tooling-resolved variants (`.web.ts`, `.native.ts`, `.test.ts`) without being one. The file exports a single tracker; rename it after its role. Test sits next to its subject by name. * Promote use-settings to a directory module * Promote sidebar-collapsed-sections-store to a directory module * Promote navigate-to-agent to a directory module The `.pure.ts` filename suffix mimics tooling-resolved variants (`.web.ts`, `.native.ts`, `.test.ts`) without being one. Move the pure resolver and its wired wrapper into `utils/navigate-to-agent/`, where the directory carries the domain and `resolve.ts` names the role. The pure function is renamed `resolveNavigateToAgent` so it no longer collides with the wrapper's exported `navigateToAgent`. * Collapse use-client-activity.pure.ts into client-activity-tracker.ts * Collapse open-project.pure.ts into open-project.ts * Promote use-changes-preferences to a directory module Replaces the .pure.ts / .test-utils.ts double-suffix with a directory home that mirrors hooks/use-settings/: hooks/use-changes-preferences/ index.ts ← React hook; wires AsyncStorage storage.ts ← pure load/save storage.test.ts ← tests against storage fakes.ts ← in-memory KeyValueStorage adapter External callers continue importing @/hooks/use-changes-preferences unchanged (resolves to index.ts). * Promote draft-store to a directory module * Collapse use-archive-agent.pure.ts into use-archive-agent.ts * Rename import-session-sheet.pure.ts to import-session-sheet-view-model.ts * Rename image-attachment-picker.native.pure.ts to picked-image-normalizer.ts * Inline use-agent-initialization.pure.ts into use-agent-initialization.ts * Rename use-sidebar-workspaces-list.pure.ts to sidebar-workspaces-view-model.ts * Rename review/store.pure.ts to review/state.ts * Promote browser-store to a directory module * Promote navigation-active-workspace-store to a directory module * Promote panel-store to a directory module * Promote session-store-hooks to a directory module * Promote workspace-tabs-store to a directory module * Rename use-archive-subagent.pure.ts to archive-subagent.ts * Rename sidebar-workspace-archive-redirect.pure.ts to workspace-archive-redirect.ts * Rename workspace-navigation.pure.ts to prepare-workspace-tab.ts --- docs/testing.md | 9 + packages/app/src/app/index.test.tsx | 149 --- .../src/attachments/attachment-file-system.ts | 54 + .../local-file-attachment-store.test.ts | 67 +- .../local-file-attachment-store.ts | 45 +- .../native/native-file-attachment-store.ts | 2 + packages/app/src/attachments/store.ts | 4 +- .../test-attachment-file-system.ts | 95 ++ .../drag-reorder/drag-reducer.test.ts | 32 + .../components/drag-reorder/drag-reducer.ts | 19 + .../app/src/components/drag-reorder/index.ts | 12 + .../drag-reorder/pointer-activation.test.ts | 14 + .../drag-reorder/pointer-activation.ts | 19 + .../drag-reorder/reorder-items.test.ts | 62 + .../components/drag-reorder/reorder-items.ts | 24 + .../drag-reorder/use-drag-reorder-state.ts | 73 ++ .../components/draggable-list.web.test.tsx | 167 --- .../app/src/components/draggable-list.web.tsx | 74 +- .../import-session-sheet-view-model.test.ts | 273 +++++ .../import-session-sheet-view-model.ts | 145 +++ .../src/components/import-session-sheet.tsx | 147 +-- packages/app/src/components/message.tsx | 3 +- .../src/components/provider-icon-name.test.ts | 17 + .../app/src/components/provider-icon-name.ts | 31 + .../app/src/components/provider-icons.test.ts | 30 - packages/app/src/components/provider-icons.ts | 24 +- .../src/components/sidebar-workspace-list.tsx | 2 +- .../sortable-inline-list.web.test.tsx | 123 -- .../components/sortable-inline-list.web.tsx | 70 +- .../app/src/components/terminal-emulator.tsx | 6 +- .../ui/isolated-bottom-sheet-modal.test.tsx | 189 --- .../index.tsx} | 95 +- .../visibility-tracker.test.ts | 121 ++ .../visibility-tracker.ts | 77 ++ .../attachments/desktop-attachment-bridge.ts | 59 + .../desktop-attachment-store.test.ts | 133 +-- .../attachments/desktop-attachment-store.ts | 84 +- .../attachments/desktop-preview-url.test.ts | 153 ++- .../attachments/desktop-preview-url.ts | 91 +- .../fake-desktop-attachment-bridge.ts | 103 ++ .../daemon/desktop-daemon-transport.test.ts | 119 +- .../daemon/desktop-daemon-transport.ts | 72 +- .../daemon/local-daemon-transport-rpc.ts | 31 + .../daemon/test-local-daemon-transport-rpc.ts | 86 ++ .../app/src/desktop/electron/idle.test.ts | 81 +- packages/app/src/desktop/electron/idle.ts | 10 +- .../permissions/desktop-permissions.test.ts | 250 ++-- .../permissions/desktop-permissions.ts | 464 ++++---- .../updates/desktop-app-updater.test.ts | 262 +++++ .../desktop/updates/desktop-app-updater.ts | 275 +++++ .../updates/resolve-update-callout.test.ts | 107 ++ .../desktop/updates/resolve-update-callout.ts | 93 ++ .../fake-desktop-app-updater-port.ts | 124 ++ .../updates/update-callout-source.test.tsx | 259 ----- .../desktop/updates/update-callout-source.tsx | 108 +- .../updates/use-desktop-app-updater.test.tsx | 94 -- .../updates/use-desktop-app-updater.ts | 216 +--- .../app/src/git/checkout-status-cache.test.ts | 124 ++ packages/app/src/git/checkout-status-cache.ts | 48 + packages/app/src/git/use-status-query.test.ts | 318 ----- packages/app/src/git/use-status-query.ts | 46 +- .../src/hooks/client-activity-tracker.test.ts | 241 ++++ .../app/src/hooks/client-activity-tracker.ts | 104 ++ ...ardware-keyboard-submit-controller.test.ts | 133 +++ .../hardware-keyboard-submit-controller.ts | 34 + .../src/hooks/hover-safe-zone-tracker.test.ts | 108 ++ .../app/src/hooks/hover-safe-zone-tracker.ts | 74 ++ .../image-attachment-picker.native.test.ts | 75 -- .../hooks/image-attachment-picker.native.ts | 116 +- .../src/hooks/image-attachment-picker.test.ts | 61 +- .../app/src/hooks/image-attachment-picker.ts | 9 +- packages/app/src/hooks/open-project.ts | 43 + .../src/hooks/picked-image-normalizer.test.ts | 79 ++ .../app/src/hooks/picked-image-normalizer.ts | 110 ++ .../sidebar-workspaces-view-model.test.ts | 248 ++++ .../hooks/sidebar-workspaces-view-model.ts | 200 ++++ .../hooks/use-agent-commands-query.test.ts | 118 +- .../app/src/hooks/use-agent-commands-query.ts | 25 +- .../app/src/hooks/use-agent-history.test.ts | 209 ++++ .../app/src/hooks/use-agent-history.test.tsx | 267 ----- packages/app/src/hooks/use-agent-history.ts | 12 +- .../hooks/use-agent-initialization.test.ts | 77 +- .../app/src/hooks/use-agent-initialization.ts | 201 ++-- .../app/src/hooks/use-archive-agent.test.ts | 58 +- packages/app/src/hooks/use-archive-agent.ts | 87 +- .../src/hooks/use-changes-preferences.test.ts | 74 -- .../app/src/hooks/use-changes-preferences.ts | 89 -- .../hooks/use-changes-preferences/fakes.ts | 20 + .../hooks/use-changes-preferences/index.ts | 52 + .../use-changes-preferences/storage.test.ts | 89 ++ .../hooks/use-changes-preferences/storage.ts | 73 ++ .../src/hooks/use-client-activity.test.tsx | 244 ---- packages/app/src/hooks/use-client-activity.ts | 163 +-- .../src/hooks/use-hover-safe-zone.test.tsx | 157 --- packages/app/src/hooks/use-hover-safe-zone.ts | 85 +- .../src/hooks/use-image-attachment-picker.ts | 4 +- .../use-ios-hardware-keyboard-submit.test.tsx | 119 -- .../hooks/use-ios-hardware-keyboard-submit.ts | 31 +- .../app/src/hooks/use-keyboard-shortcuts.ts | 2 +- .../use-load-older-agent-history.test.ts | 268 +++-- .../src/hooks/use-load-older-agent-history.ts | 95 +- .../app/src/hooks/use-open-project.test.ts | 218 ++-- packages/app/src/hooks/use-open-project.ts | 55 +- .../app/src/hooks/use-pr-pane-data.test.ts | 1027 ++++++----------- packages/app/src/hooks/use-pr-pane-data.ts | 221 +++- packages/app/src/hooks/use-projects.test.ts | 331 +++--- packages/app/src/hooks/use-projects.ts | 140 ++- .../src/hooks/use-providers-snapshot.test.ts | 446 +++---- .../app/src/hooks/use-providers-snapshot.ts | 129 ++- packages/app/src/hooks/use-settings.test.ts | 249 ---- packages/app/src/hooks/use-settings.ts | 325 ------ packages/app/src/hooks/use-settings/fakes.ts | 57 + packages/app/src/hooks/use-settings/index.ts | 208 ++++ .../src/hooks/use-settings/storage.test.ts | 236 ++++ .../app/src/hooks/use-settings/storage.ts | 211 ++++ .../hooks/use-sidebar-workspaces-list.test.ts | 298 ----- .../src/hooks/use-sidebar-workspaces-list.ts | 228 +--- .../hooks/use-workspace-navigation.test.ts | 27 - .../app/src/hooks/use-workspace-navigation.ts | 19 - .../ios-hardware-keyboard-submit.ios.ts | 8 - .../native/ios-hardware-keyboard-submit.ts | 27 +- packages/app/src/panels/agent-panel.tsx | 3 +- packages/app/src/polyfills/crypto.test.ts | 40 - packages/app/src/polyfills/crypto.ts | 88 +- .../install-crypto-polyfills.test.ts | 78 ++ .../src/polyfills/install-crypto-polyfills.ts | 88 ++ packages/app/src/review/state.ts | 187 +++ packages/app/src/review/store.test.ts | 181 +-- packages/app/src/review/store.ts | 186 +-- .../src/screens/new-workspace-empty.test.ts | 34 +- .../app/src/screens/new-workspace-empty.ts | 6 +- .../app/src/screens/new-workspace-screen.tsx | 2 + packages/app/src/stores/browser-store.test.ts | 45 - packages/app/src/stores/browser-store.ts | 177 --- .../app/src/stores/browser-store/index.ts | 89 ++ .../src/stores/browser-store/state.test.ts | 148 +++ .../app/src/stores/browser-store/state.ts | 130 +++ .../{draft-store.ts => draft-store/index.ts} | 391 +------ .../migration.test.ts} | 86 +- .../app/src/stores/draft-store/migration.ts | 135 +++ .../state.test.ts} | 7 +- packages/app/src/stores/draft-store/state.ts | 192 +++ .../navigation-active-workspace-store.test.ts | 91 -- .../index.ts} | 93 +- .../navigation.test.ts | 132 +++ .../navigation.ts | 102 ++ .../{panel-store.ts => panel-store/index.ts} | 292 +---- .../state.test.ts} | 118 +- packages/app/src/stores/panel-store/state.ts | 253 ++++ .../src/stores/session-store-hooks.test.ts | 366 ------ .../src/stores/session-store-hooks/index.ts | 138 +++ .../session-store-hooks/selectors.test.ts | 423 +++++++ .../selectors.ts} | 284 ++--- .../sidebar-collapsed-sections-store.test.ts | 63 - .../sidebar-collapsed-sections-store.ts | 91 -- .../sidebar-collapsed-sections-store/index.ts | 37 + .../state.test.ts | 53 + .../sidebar-collapsed-sections-store/state.ts | 73 ++ .../workspace-subagents-integration.test.ts | 56 - .../src/stores/workspace-tabs-store.test.ts | 353 ------ .../app/src/stores/workspace-tabs-store.ts | 696 ----------- .../src/stores/workspace-tabs-store/index.ts | 124 ++ .../stores/workspace-tabs-store/state.test.ts | 364 ++++++ .../src/stores/workspace-tabs-store/state.ts | 757 ++++++++++++ .../src/subagents/archive-subagent.test.ts | 198 ++++ .../app/src/subagents/archive-subagent.ts | 66 ++ packages/app/src/subagents/index.ts | 2 - .../src/subagents/track-presentation.test.ts | 127 ++ .../app/src/subagents/track-presentation.ts | 56 + packages/app/src/subagents/track.test.tsx | 285 ----- packages/app/src/subagents/track.tsx | 42 +- .../subagents/use-archive-subagent.test.ts | 217 ---- .../app/src/subagents/use-archive-subagent.ts | 68 +- .../terminal/drop/terminal-file-drop.test.ts | 98 +- .../src/terminal/drop/terminal-file-drop.ts | 56 +- packages/app/src/utils/client-id.test.ts | 100 +- packages/app/src/utils/client-id.ts | 92 +- .../app/src/utils/navigate-to-agent.test.ts | 133 --- packages/app/src/utils/navigate-to-agent.ts | 35 - .../app/src/utils/navigate-to-agent/index.ts | 23 + .../utils/navigate-to-agent/resolve.test.ts | 97 ++ .../src/utils/navigate-to-agent/resolve.ts | 50 + .../app/src/utils/prepare-workspace-tab.ts | 68 ++ .../rich-clipboard-default-environment.ts | 30 + packages/app/src/utils/rich-clipboard.test.ts | 77 +- packages/app/src/utils/rich-clipboard.ts | 31 +- .../sidebar-workspace-archive-redirect.ts | 34 +- .../app/src/utils/tool-call-icon-name.test.ts | 56 + packages/app/src/utils/tool-call-icon-name.ts | 45 + packages/app/src/utils/tool-call-icon.test.ts | 71 -- packages/app/src/utils/tool-call-icon.ts | 50 +- .../workspace-archive-navigation.test.ts | 98 +- .../src/utils/workspace-archive-redirect.ts | 35 + .../src/utils/workspace-navigation.test.ts | 152 +-- .../app/src/utils/workspace-navigation.ts | 63 +- 195 files changed, 13063 insertions(+), 11542 deletions(-) delete mode 100644 packages/app/src/app/index.test.tsx create mode 100644 packages/app/src/attachments/attachment-file-system.ts create mode 100644 packages/app/src/attachments/test-attachment-file-system.ts create mode 100644 packages/app/src/components/drag-reorder/drag-reducer.test.ts create mode 100644 packages/app/src/components/drag-reorder/drag-reducer.ts create mode 100644 packages/app/src/components/drag-reorder/index.ts create mode 100644 packages/app/src/components/drag-reorder/pointer-activation.test.ts create mode 100644 packages/app/src/components/drag-reorder/pointer-activation.ts create mode 100644 packages/app/src/components/drag-reorder/reorder-items.test.ts create mode 100644 packages/app/src/components/drag-reorder/reorder-items.ts create mode 100644 packages/app/src/components/drag-reorder/use-drag-reorder-state.ts delete mode 100644 packages/app/src/components/draggable-list.web.test.tsx create mode 100644 packages/app/src/components/import-session-sheet-view-model.test.ts create mode 100644 packages/app/src/components/import-session-sheet-view-model.ts create mode 100644 packages/app/src/components/provider-icon-name.test.ts create mode 100644 packages/app/src/components/provider-icon-name.ts delete mode 100644 packages/app/src/components/provider-icons.test.ts delete mode 100644 packages/app/src/components/sortable-inline-list.web.test.tsx delete mode 100644 packages/app/src/components/ui/isolated-bottom-sheet-modal.test.tsx rename packages/app/src/components/ui/{isolated-bottom-sheet-modal.tsx => isolated-bottom-sheet-modal/index.tsx} (54%) create mode 100644 packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.test.ts create mode 100644 packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.ts create mode 100644 packages/app/src/desktop/attachments/desktop-attachment-bridge.ts create mode 100644 packages/app/src/desktop/attachments/test-utils/fake-desktop-attachment-bridge.ts create mode 100644 packages/app/src/desktop/daemon/local-daemon-transport-rpc.ts create mode 100644 packages/app/src/desktop/daemon/test-local-daemon-transport-rpc.ts create mode 100644 packages/app/src/desktop/updates/desktop-app-updater.test.ts create mode 100644 packages/app/src/desktop/updates/desktop-app-updater.ts create mode 100644 packages/app/src/desktop/updates/resolve-update-callout.test.ts create mode 100644 packages/app/src/desktop/updates/resolve-update-callout.ts create mode 100644 packages/app/src/desktop/updates/test-utils/fake-desktop-app-updater-port.ts delete mode 100644 packages/app/src/desktop/updates/update-callout-source.test.tsx delete mode 100644 packages/app/src/desktop/updates/use-desktop-app-updater.test.tsx create mode 100644 packages/app/src/git/checkout-status-cache.test.ts create mode 100644 packages/app/src/git/checkout-status-cache.ts delete mode 100644 packages/app/src/git/use-status-query.test.ts create mode 100644 packages/app/src/hooks/client-activity-tracker.test.ts create mode 100644 packages/app/src/hooks/client-activity-tracker.ts create mode 100644 packages/app/src/hooks/hardware-keyboard-submit-controller.test.ts create mode 100644 packages/app/src/hooks/hardware-keyboard-submit-controller.ts create mode 100644 packages/app/src/hooks/hover-safe-zone-tracker.test.ts create mode 100644 packages/app/src/hooks/hover-safe-zone-tracker.ts delete mode 100644 packages/app/src/hooks/image-attachment-picker.native.test.ts create mode 100644 packages/app/src/hooks/open-project.ts create mode 100644 packages/app/src/hooks/picked-image-normalizer.test.ts create mode 100644 packages/app/src/hooks/picked-image-normalizer.ts create mode 100644 packages/app/src/hooks/sidebar-workspaces-view-model.test.ts create mode 100644 packages/app/src/hooks/sidebar-workspaces-view-model.ts create mode 100644 packages/app/src/hooks/use-agent-history.test.ts delete mode 100644 packages/app/src/hooks/use-agent-history.test.tsx delete mode 100644 packages/app/src/hooks/use-changes-preferences.test.ts delete mode 100644 packages/app/src/hooks/use-changes-preferences.ts create mode 100644 packages/app/src/hooks/use-changes-preferences/fakes.ts create mode 100644 packages/app/src/hooks/use-changes-preferences/index.ts create mode 100644 packages/app/src/hooks/use-changes-preferences/storage.test.ts create mode 100644 packages/app/src/hooks/use-changes-preferences/storage.ts delete mode 100644 packages/app/src/hooks/use-client-activity.test.tsx delete mode 100644 packages/app/src/hooks/use-hover-safe-zone.test.tsx delete mode 100644 packages/app/src/hooks/use-ios-hardware-keyboard-submit.test.tsx delete mode 100644 packages/app/src/hooks/use-settings.test.ts delete mode 100644 packages/app/src/hooks/use-settings.ts create mode 100644 packages/app/src/hooks/use-settings/fakes.ts create mode 100644 packages/app/src/hooks/use-settings/index.ts create mode 100644 packages/app/src/hooks/use-settings/storage.test.ts create mode 100644 packages/app/src/hooks/use-settings/storage.ts delete mode 100644 packages/app/src/hooks/use-sidebar-workspaces-list.test.ts delete mode 100644 packages/app/src/hooks/use-workspace-navigation.test.ts delete mode 100644 packages/app/src/hooks/use-workspace-navigation.ts delete mode 100644 packages/app/src/polyfills/crypto.test.ts create mode 100644 packages/app/src/polyfills/install-crypto-polyfills.test.ts create mode 100644 packages/app/src/polyfills/install-crypto-polyfills.ts create mode 100644 packages/app/src/review/state.ts delete mode 100644 packages/app/src/stores/browser-store.test.ts delete mode 100644 packages/app/src/stores/browser-store.ts create mode 100644 packages/app/src/stores/browser-store/index.ts create mode 100644 packages/app/src/stores/browser-store/state.test.ts create mode 100644 packages/app/src/stores/browser-store/state.ts rename packages/app/src/stores/{draft-store.ts => draft-store/index.ts} (52%) rename packages/app/src/stores/{draft-store.migration.test.ts => draft-store/migration.test.ts} (63%) create mode 100644 packages/app/src/stores/draft-store/migration.ts rename packages/app/src/stores/{draft-store.lifecycle.test.ts => draft-store/state.test.ts} (87%) create mode 100644 packages/app/src/stores/draft-store/state.ts delete mode 100644 packages/app/src/stores/navigation-active-workspace-store.test.ts rename packages/app/src/stores/{navigation-active-workspace-store.ts => navigation-active-workspace-store/index.ts} (53%) create mode 100644 packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts create mode 100644 packages/app/src/stores/navigation-active-workspace-store/navigation.ts rename packages/app/src/stores/{panel-store.ts => panel-store/index.ts} (59%) rename packages/app/src/stores/{panel-store.test.ts => panel-store/state.test.ts} (62%) create mode 100644 packages/app/src/stores/panel-store/state.ts delete mode 100644 packages/app/src/stores/session-store-hooks.test.ts create mode 100644 packages/app/src/stores/session-store-hooks/index.ts create mode 100644 packages/app/src/stores/session-store-hooks/selectors.test.ts rename packages/app/src/stores/{session-store-hooks.ts => session-store-hooks/selectors.ts} (50%) delete mode 100644 packages/app/src/stores/sidebar-collapsed-sections-store.test.ts delete mode 100644 packages/app/src/stores/sidebar-collapsed-sections-store.ts create mode 100644 packages/app/src/stores/sidebar-collapsed-sections-store/index.ts create mode 100644 packages/app/src/stores/sidebar-collapsed-sections-store/state.test.ts create mode 100644 packages/app/src/stores/sidebar-collapsed-sections-store/state.ts delete mode 100644 packages/app/src/stores/workspace-tabs-store.test.ts delete mode 100644 packages/app/src/stores/workspace-tabs-store.ts create mode 100644 packages/app/src/stores/workspace-tabs-store/index.ts create mode 100644 packages/app/src/stores/workspace-tabs-store/state.test.ts create mode 100644 packages/app/src/stores/workspace-tabs-store/state.ts create mode 100644 packages/app/src/subagents/archive-subagent.test.ts create mode 100644 packages/app/src/subagents/archive-subagent.ts create mode 100644 packages/app/src/subagents/track-presentation.test.ts create mode 100644 packages/app/src/subagents/track-presentation.ts delete mode 100644 packages/app/src/subagents/track.test.tsx delete mode 100644 packages/app/src/subagents/use-archive-subagent.test.ts delete mode 100644 packages/app/src/utils/navigate-to-agent.test.ts delete mode 100644 packages/app/src/utils/navigate-to-agent.ts create mode 100644 packages/app/src/utils/navigate-to-agent/index.ts create mode 100644 packages/app/src/utils/navigate-to-agent/resolve.test.ts create mode 100644 packages/app/src/utils/navigate-to-agent/resolve.ts create mode 100644 packages/app/src/utils/prepare-workspace-tab.ts create mode 100644 packages/app/src/utils/rich-clipboard-default-environment.ts create mode 100644 packages/app/src/utils/tool-call-icon-name.test.ts create mode 100644 packages/app/src/utils/tool-call-icon-name.ts delete mode 100644 packages/app/src/utils/tool-call-icon.test.ts create mode 100644 packages/app/src/utils/workspace-archive-redirect.ts diff --git a/docs/testing.md b/docs/testing.md index f9488ec44..2c6a30705 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -157,3 +157,12 @@ If code isn't testable, refactor it. Signs: - Setup requires too much global state Aim for deep modules: small interface, deep implementation. Fewer methods = fewer tests needed, simpler params = simpler setup. + +## Two test categories, no others + +Every test in this repo lives in exactly one of these shapes: + +1. **Unit tests with ports and adapters** — production code receives its real-world dependencies (DB, HTTP, CLI process, clock, randomness, filesystem, other modules) through an injected interface. Tests wire a typed in-memory fake colocated with the production module. **No `vi.mock`, `vi.hoisted`, `vi.spyOn` of own exports, JSDOM, `@testing-library` component mounting, RN test renderer, monkey-patched globals, or fake-server fixtures.** If a test needs any of those, the production module is missing a port — fix the seam, then write the test against a fake adapter. +2. **Real end-to-end tests** — real daemon, real network, real browser (Playwright for app code) or a real isolated server instance (for daemon code). No JSDOM, no mocked transport. + +Anything in between — component tests in JSDOM, vitest tests that mock the module under test, tests that assert on private state — is slop on its way out. diff --git a/packages/app/src/app/index.test.tsx b/packages/app/src/app/index.test.tsx deleted file mode 100644 index 0740675ad..000000000 --- a/packages/app/src/app/index.test.tsx +++ /dev/null @@ -1,149 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import React from "react"; -import { act } from "@testing-library/react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { HostRuntimeBootstrapState } from "./_layout"; -import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; - -const { redirectMock, state } = vi.hoisted(() => { - const hoistedState = { - pathname: "/", - bootstrapState: { - splashError: null, - retry: vi.fn(), - hasGivenUpWaitingForHost: false, - storeReady: false, - } as HostRuntimeBootstrapState, - anyOnlineHostServerId: null as string | null, - isWorkspaceSelectionLoaded: true, - workspaceSelection: null as ActiveWorkspaceSelection | null, - }; - - return { - redirectMock: vi.fn(), - state: hoistedState, - }; -}); - -vi.mock("expo-router", () => ({ - Redirect: ({ href }: { href: string }) => { - redirectMock(href); - return React.createElement("div", { "data-testid": "redirect", "data-href": href }); - }, - usePathname: () => state.pathname, -})); - -vi.mock("@/app/_layout", () => ({ - useHostRuntimeBootstrapState: () => state.bootstrapState, - useEarliestOnlineHostServerId: () => state.anyOnlineHostServerId, -})); - -vi.mock("@/desktop/daemon/desktop-daemon", () => ({ - shouldUseDesktopDaemon: () => false, -})); - -vi.mock("@/screens/startup-splash-screen", () => ({ - StartupSplashScreen: () => React.createElement("div", { "data-testid": "startup-splash" }), -})); - -vi.mock("@/stores/navigation-active-workspace-store", () => ({ - useIsLastWorkspaceSelectionHydrated: () => state.isWorkspaceSelectionLoaded, - useLastWorkspaceSelection: () => state.workspaceSelection, -})); - -describe("Index route startup navigation", () => { - let container: HTMLDivElement; - let root: Root; - - beforeEach(() => { - vi.resetModules(); - state.pathname = "/"; - state.bootstrapState = { - splashError: null, - retry: vi.fn(), - hasGivenUpWaitingForHost: false, - storeReady: false, - }; - state.anyOnlineHostServerId = null; - state.isWorkspaceSelectionLoaded = true; - state.workspaceSelection = null; - redirectMock.mockReset(); - - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - }); - - afterEach(() => { - act(() => { - root.unmount(); - }); - container.remove(); - }); - - async function renderIndex() { - const { default: Index } = await import("./index"); - await act(async () => { - root.render(); - }); - } - - it("shows the startup splash while no host is online and the welcome timer has not fired", async () => { - await renderIndex(); - - expect(container.querySelector("[data-testid='startup-splash']")).not.toBeNull(); - expect(redirectMock).not.toHaveBeenCalled(); - }); - - it("shows the startup splash while the persisted workspace selection has not loaded", async () => { - state.anyOnlineHostServerId = "server-1"; - state.isWorkspaceSelectionLoaded = false; - - await renderIndex(); - - expect(container.querySelector("[data-testid='startup-splash']")).not.toBeNull(); - expect(redirectMock).not.toHaveBeenCalled(); - }); - - it("restores the persisted workspace when the online host matches its server id", async () => { - state.anyOnlineHostServerId = "server-1"; - state.workspaceSelection = { serverId: "server-1", workspaceId: "workspace-a" }; - - await renderIndex(); - - expect(redirectMock).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a"); - expect(container.querySelector("[data-testid='redirect']")).not.toBeNull(); - }); - - it("restores the persisted workspace even when the first online host is different", async () => { - state.anyOnlineHostServerId = "server-2"; - state.workspaceSelection = { serverId: "server-1", workspaceId: "workspace-a" }; - - await renderIndex(); - - expect(redirectMock).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a"); - }); - - it("navigates to the host root when no persisted workspace exists", async () => { - state.anyOnlineHostServerId = "server-2"; - state.workspaceSelection = null; - - await renderIndex(); - - expect(redirectMock).toHaveBeenCalledWith("/h/server-2"); - }); - - it("falls back to welcome when the give-up timer fires with no host online", async () => { - state.bootstrapState = { - ...state.bootstrapState, - hasGivenUpWaitingForHost: true, - }; - - await renderIndex(); - - expect(redirectMock).toHaveBeenCalledWith("/welcome"); - }); -}); diff --git a/packages/app/src/attachments/attachment-file-system.ts b/packages/app/src/attachments/attachment-file-system.ts new file mode 100644 index 000000000..30a22a776 --- /dev/null +++ b/packages/app/src/attachments/attachment-file-system.ts @@ -0,0 +1,54 @@ +import { File } from "expo-file-system"; +import * as FileSystem from "expo-file-system/legacy"; + +export type AttachmentFileInfo = + | { exists: true; isDirectory: boolean; size: number | null } + | { exists: false }; + +export interface AttachmentFileSystem { + readonly cacheDirectory: string | null; + getInfo(uri: string): Promise; + makeDirectory(uri: string, options: { intermediates: boolean }): Promise; + writeBytes(uri: string, bytes: Uint8Array): Promise; + copy(input: { from: string; to: string }): Promise; + readAsBase64(uri: string): Promise; + delete(uri: string, options: { idempotent: boolean }): Promise; + listDirectory(uri: string): Promise; +} + +export function createExpoAttachmentFileSystem(): AttachmentFileSystem { + return { + cacheDirectory: FileSystem.cacheDirectory, + async getInfo(uri) { + const info = await FileSystem.getInfoAsync(uri); + if (!info.exists) { + return { exists: false }; + } + const size = + typeof (info as { size?: number }).size === "number" + ? (info as { size: number }).size + : null; + return { exists: true, isDirectory: info.isDirectory ?? false, size }; + }, + async makeDirectory(uri, options) { + await FileSystem.makeDirectoryAsync(uri, options); + }, + async writeBytes(uri, bytes) { + new File(uri).write(bytes); + }, + async copy(input) { + await FileSystem.copyAsync(input); + }, + async readAsBase64(uri) { + return await FileSystem.readAsStringAsync(uri, { + encoding: FileSystem.EncodingType.Base64, + }); + }, + async delete(uri, options) { + await FileSystem.deleteAsync(uri, options); + }, + async listDirectory(uri) { + return await FileSystem.readDirectoryAsync(uri); + }, + }; +} diff --git a/packages/app/src/attachments/local-file-attachment-store.test.ts b/packages/app/src/attachments/local-file-attachment-store.test.ts index a9cc02bc0..48f974fa1 100644 --- a/packages/app/src/attachments/local-file-attachment-store.test.ts +++ b/packages/app/src/attachments/local-file-attachment-store.test.ts @@ -1,64 +1,14 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { createLocalFileAttachmentStore } from "./local-file-attachment-store"; - -const fileSystemMock = vi.hoisted(() => ({ - getInfoAsync: vi.fn(async (uri: string) => { - if (uri.endsWith("/preview-assets/")) { - return { exists: true, isDirectory: true }; - } - return { exists: true, isDirectory: false, size: 4 }; - }), - makeDirectoryAsync: vi.fn(async () => {}), - writeAsStringAsync: vi.fn(async () => {}), - copyAsync: vi.fn(async () => {}), - readAsStringAsync: vi.fn(async () => "AAECAw=="), - deleteAsync: vi.fn(async () => {}), - readDirectoryAsync: vi.fn(async () => []), - fileWrite: vi.fn((_uri: string, _content: Uint8Array) => {}), -})); - -vi.mock("expo-file-system", () => ({ - File: class { - uri: string; - - constructor(uri: string) { - this.uri = uri; - } - - write(content: Uint8Array) { - fileSystemMock.fileWrite(this.uri, content); - } - }, -})); - -vi.mock("expo-file-system/legacy", () => ({ - cacheDirectory: "file:///cache/", - EncodingType: { Base64: "base64" }, - getInfoAsync: fileSystemMock.getInfoAsync, - makeDirectoryAsync: fileSystemMock.makeDirectoryAsync, - writeAsStringAsync: fileSystemMock.writeAsStringAsync, - copyAsync: fileSystemMock.copyAsync, - readAsStringAsync: fileSystemMock.readAsStringAsync, - deleteAsync: fileSystemMock.deleteAsync, - readDirectoryAsync: fileSystemMock.readDirectoryAsync, -})); +import { createTestAttachmentFileSystem } from "./test-attachment-file-system"; describe("local file attachment store", () => { - beforeEach(() => { - fileSystemMock.getInfoAsync.mockClear(); - fileSystemMock.makeDirectoryAsync.mockClear(); - fileSystemMock.writeAsStringAsync.mockClear(); - fileSystemMock.copyAsync.mockClear(); - fileSystemMock.readAsStringAsync.mockClear(); - fileSystemMock.deleteAsync.mockClear(); - fileSystemMock.readDirectoryAsync.mockClear(); - fileSystemMock.fileWrite.mockClear(); - }); - it("writes raw byte sources directly to the managed file path", async () => { + const fileSystem = createTestAttachmentFileSystem(); const store = createLocalFileAttachmentStore({ storageType: "native-file", baseDirectoryName: "preview-assets", + fileSystem, resolvePreviewUrl: async (attachment) => `file://${attachment.storageKey}`, }); @@ -69,11 +19,6 @@ describe("local file attachment store", () => { source: { kind: "bytes", bytes: new Uint8Array([0, 1, 2, 3]) }, }); - expect(fileSystemMock.fileWrite).toHaveBeenCalledWith( - "file:///cache/preview-assets/preview_8_test.png", - new Uint8Array([0, 1, 2, 3]), - ); - expect(fileSystemMock.writeAsStringAsync).not.toHaveBeenCalled(); expect(attachment).toMatchObject({ id: "preview_8_test", mimeType: "image/png", @@ -82,5 +27,9 @@ describe("local file attachment store", () => { fileName: "result.png", byteSize: 4, }); + expect(fileSystem.files.get("file:///cache/preview-assets/preview_8_test.png")).toEqual( + new Uint8Array([0, 1, 2, 3]), + ); + expect(fileSystem.directories.has("file:///cache/preview-assets")).toBe(true); }); }); diff --git a/packages/app/src/attachments/local-file-attachment-store.ts b/packages/app/src/attachments/local-file-attachment-store.ts index 1ed2bd69e..b9e76a86e 100644 --- a/packages/app/src/attachments/local-file-attachment-store.ts +++ b/packages/app/src/attachments/local-file-attachment-store.ts @@ -1,5 +1,4 @@ -import { File } from "expo-file-system"; -import * as FileSystem from "expo-file-system/legacy"; +import type { AttachmentFileSystem } from "@/attachments/attachment-file-system"; import { type AttachmentStore, type AttachmentStorageType, @@ -37,12 +36,12 @@ function extensionForAttachment(params: { fileName?: string | null; mimeType: st return IMAGE_EXTENSION_BY_MIME_TYPE[params.mimeType] ?? ".img"; } -async function ensureDirectory(uri: string): Promise { - const info = await FileSystem.getInfoAsync(uri); +async function ensureDirectory(fileSystem: AttachmentFileSystem, uri: string): Promise { + const info = await fileSystem.getInfo(uri); if (info.exists && info.isDirectory) { return; } - await FileSystem.makeDirectoryAsync(uri, { intermediates: true }); + await fileSystem.makeDirectory(uri, { intermediates: true }); } async function dataUrlToBytes(dataUrl: string): Promise { @@ -55,6 +54,7 @@ async function blobToBytes(blob: Blob): Promise { } async function writeFromSource(input: { + fileSystem: AttachmentFileSystem; source: SaveAttachmentInput["source"]; targetUri: string; mimeType: string; @@ -64,7 +64,7 @@ async function writeFromSource(input: { if (from === input.targetUri) { return; } - await FileSystem.copyAsync({ from, to: input.targetUri }); + await input.fileSystem.copy({ from, to: input.targetUri }); return; } @@ -77,7 +77,7 @@ async function writeFromSource(input: { bytes = input.source.bytes; } - new File(input.targetUri).write(bytes); + await input.fileSystem.writeBytes(input.targetUri, bytes); } function attachmentUri(metadata: AttachmentMetadata): string { @@ -87,11 +87,13 @@ function attachmentUri(metadata: AttachmentMetadata): string { export function createLocalFileAttachmentStore(params: { storageType: Extract; baseDirectoryName: string; + fileSystem: AttachmentFileSystem; resolvePreviewUrl: (attachment: AttachmentMetadata) => Promise; releasePreviewUrl?: (input: { attachment: AttachmentMetadata; url: string }) => Promise; }): AttachmentStore { - const baseDirectory = FileSystem.cacheDirectory - ? `${FileSystem.cacheDirectory}${params.baseDirectoryName}/` + const { fileSystem } = params; + const baseDirectory = fileSystem.cacheDirectory + ? `${fileSystem.cacheDirectory}${params.baseDirectoryName}/` : null; async function resolveTarget(input: SaveAttachmentInput): Promise<{ @@ -103,10 +105,10 @@ export function createLocalFileAttachmentStore(params: { storageKey: string; }> { if (!baseDirectory) { - throw new Error("expo-file-system cacheDirectory is unavailable."); + throw new Error("Attachment file-system cacheDirectory is unavailable."); } - await ensureDirectory(baseDirectory); + await ensureDirectory(fileSystem, baseDirectory); const id = input.id ?? generateAttachmentId(); let mimeTypeFromSource: string | undefined; @@ -140,16 +142,14 @@ export function createLocalFileAttachmentStore(params: { async save(input): Promise { const target = await resolveTarget(input); await writeFromSource({ + fileSystem, source: input.source, targetUri: target.targetUri, mimeType: target.mimeType, }); - const info = await FileSystem.getInfoAsync(target.targetUri); - const byteSize = - info.exists && typeof (info as { size?: number }).size === "number" - ? (info as { size: number }).size - : null; + const info = await fileSystem.getInfo(target.targetUri); + const byteSize = info.exists ? info.size : null; return { id: target.id, mimeType: target.mimeType, @@ -162,10 +162,7 @@ export function createLocalFileAttachmentStore(params: { }, async encodeBase64({ attachment }): Promise { - const uri = attachmentUri(attachment); - return await FileSystem.readAsStringAsync(uri, { - encoding: FileSystem.EncodingType.Base64, - }); + return await fileSystem.readAsBase64(attachmentUri(attachment)); }, async resolvePreviewUrl({ attachment }): Promise { @@ -184,22 +181,22 @@ export function createLocalFileAttachmentStore(params: { : {}), async delete({ attachment }): Promise { - await FileSystem.deleteAsync(attachmentUri(attachment), { idempotent: true }); + await fileSystem.delete(attachmentUri(attachment), { idempotent: true }); }, async garbageCollect({ referencedIds }): Promise { if (!baseDirectory) { return; } - await ensureDirectory(baseDirectory); - const entries = await FileSystem.readDirectoryAsync(baseDirectory); + await ensureDirectory(fileSystem, baseDirectory); + const entries = await fileSystem.listDirectory(baseDirectory); await Promise.all( entries.map(async (entryName) => { const id = entryName.split(".", 1)[0] ?? ""; if (!id || referencedIds.has(id)) { return; } - await FileSystem.deleteAsync(`${baseDirectory}${entryName}`, { + await fileSystem.delete(`${baseDirectory}${entryName}`, { idempotent: true, }); }), diff --git a/packages/app/src/attachments/native/native-file-attachment-store.ts b/packages/app/src/attachments/native/native-file-attachment-store.ts index 78c3d97cd..0d5135563 100644 --- a/packages/app/src/attachments/native/native-file-attachment-store.ts +++ b/packages/app/src/attachments/native/native-file-attachment-store.ts @@ -1,3 +1,4 @@ +import { createExpoAttachmentFileSystem } from "@/attachments/attachment-file-system"; import { createLocalFileAttachmentStore } from "@/attachments/local-file-attachment-store"; import { isAbsolutePath } from "@/utils/path"; @@ -5,6 +6,7 @@ export function createNativeFileAttachmentStore() { return createLocalFileAttachmentStore({ storageType: "native-file", baseDirectoryName: "paseo-native-attachments", + fileSystem: createExpoAttachmentFileSystem(), resolvePreviewUrl: async (attachment) => { if (attachment.storageKey.startsWith("file://")) { return attachment.storageKey; diff --git a/packages/app/src/attachments/store.ts b/packages/app/src/attachments/store.ts index ffcfc6e50..8371d3e45 100644 --- a/packages/app/src/attachments/store.ts +++ b/packages/app/src/attachments/store.ts @@ -9,7 +9,9 @@ async function createAttachmentStore(): Promise { if (isElectronRuntime()) { const { createDesktopAttachmentStore } = await import("../desktop/attachments/desktop-attachment-store"); - return createDesktopAttachmentStore(); + const { createDesktopAttachmentBridge } = + await import("../desktop/attachments/desktop-attachment-bridge"); + return createDesktopAttachmentStore(createDesktopAttachmentBridge()); } const { createIndexedDbAttachmentStore } = await import("./web/indexeddb-attachment-store"); diff --git a/packages/app/src/attachments/test-attachment-file-system.ts b/packages/app/src/attachments/test-attachment-file-system.ts new file mode 100644 index 000000000..f035da96a --- /dev/null +++ b/packages/app/src/attachments/test-attachment-file-system.ts @@ -0,0 +1,95 @@ +import type { AttachmentFileInfo, AttachmentFileSystem } from "./attachment-file-system"; + +export interface TestAttachmentFileSystem extends AttachmentFileSystem { + readonly files: ReadonlyMap; + readonly directories: ReadonlySet; + setFile(uri: string, bytes: Uint8Array): void; + setDirectory(uri: string): void; +} + +export function createTestAttachmentFileSystem(options?: { + cacheDirectory?: string | null; +}): TestAttachmentFileSystem { + const files = new Map(); + const directories = new Set(); + const cacheDirectory = + options && Object.hasOwn(options, "cacheDirectory") + ? (options.cacheDirectory ?? null) + : "file:///cache/"; + + function describe(uri: string): AttachmentFileInfo { + if (directories.has(uri) || directories.has(stripTrailingSlash(uri))) { + return { exists: true, isDirectory: true, size: null }; + } + const bytes = files.get(uri); + if (bytes) { + return { exists: true, isDirectory: false, size: bytes.byteLength }; + } + return { exists: false }; + } + + return { + files, + directories, + cacheDirectory, + setFile(uri, bytes) { + files.set(uri, bytes); + }, + setDirectory(uri) { + directories.add(stripTrailingSlash(uri)); + }, + async getInfo(uri) { + return describe(uri); + }, + async makeDirectory(uri) { + directories.add(stripTrailingSlash(uri)); + }, + async writeBytes(uri, bytes) { + files.set(uri, bytes); + }, + async copy({ from, to }) { + const bytes = files.get(from); + if (!bytes) { + throw new Error(`copy: source does not exist: ${from}`); + } + files.set(to, bytes); + }, + async readAsBase64(uri) { + const bytes = files.get(uri); + if (!bytes) { + throw new Error(`readAsBase64: file does not exist: ${uri}`); + } + return toBase64(bytes); + }, + async delete(uri, deleteOptions) { + if (!files.delete(uri) && !deleteOptions.idempotent) { + throw new Error(`delete: file does not exist: ${uri}`); + } + }, + async listDirectory(uri) { + const prefix = uri.endsWith("/") ? uri : `${uri}/`; + const entries: string[] = []; + for (const path of files.keys()) { + if (path.startsWith(prefix)) { + entries.push(path.slice(prefix.length)); + } + } + return entries; + }, + }; +} + +function stripTrailingSlash(uri: string): string { + return uri.endsWith("/") ? uri.slice(0, -1) : uri; +} + +function toBase64(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + if (typeof btoa === "function") { + return btoa(binary); + } + return Buffer.from(binary, "binary").toString("base64"); +} diff --git a/packages/app/src/components/drag-reorder/drag-reducer.test.ts b/packages/app/src/components/drag-reorder/drag-reducer.test.ts new file mode 100644 index 000000000..623f7cdca --- /dev/null +++ b/packages/app/src/components/drag-reorder/drag-reducer.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { dragStateInitial, dragStateReducer } from "./drag-reducer"; + +describe("dragStateReducer", () => { + it("starts tracking the active item with a snapshot of the data", () => { + const next = dragStateReducer(dragStateInitial(), { + type: "start", + id: "alpha", + data: ["alpha", "beta"], + }); + + expect(next).toEqual({ activeId: "alpha", dragItems: ["alpha", "beta"] }); + }); + + it("clears the active item and the snapshot", () => { + const next = dragStateReducer( + { activeId: "alpha", dragItems: ["alpha", "beta"] }, + { type: "clear" }, + ); + + expect(next).toEqual({ activeId: null, dragItems: null }); + }); + + it("replaces an in-flight drag when a new one starts", () => { + const next = dragStateReducer( + { activeId: "alpha", dragItems: ["alpha", "beta"] }, + { type: "start", id: "beta", data: ["beta", "gamma"] }, + ); + + expect(next).toEqual({ activeId: "beta", dragItems: ["beta", "gamma"] }); + }); +}); diff --git a/packages/app/src/components/drag-reorder/drag-reducer.ts b/packages/app/src/components/drag-reorder/drag-reducer.ts new file mode 100644 index 000000000..b9b508f2d --- /dev/null +++ b/packages/app/src/components/drag-reorder/drag-reducer.ts @@ -0,0 +1,19 @@ +export interface DragState { + activeId: string | null; + dragItems: T[] | null; +} + +export type DragAction = { type: "start"; id: string; data: T[] } | { type: "clear" }; + +export function dragStateInitial(): DragState { + return { activeId: null, dragItems: null }; +} + +export function dragStateReducer(state: DragState, action: DragAction): DragState { + switch (action.type) { + case "start": + return { activeId: action.id, dragItems: action.data }; + case "clear": + return { activeId: null, dragItems: null }; + } +} diff --git a/packages/app/src/components/drag-reorder/index.ts b/packages/app/src/components/drag-reorder/index.ts new file mode 100644 index 000000000..4016bd1e6 --- /dev/null +++ b/packages/app/src/components/drag-reorder/index.ts @@ -0,0 +1,12 @@ +export { reorderItemsOnDragEnd } from "./reorder-items"; +export type { DragEndInput } from "./reorder-items"; +export { + getPointerActivationConstraint, + type PointerActivationConfig, + type PointerActivationConstraint, +} from "./pointer-activation"; +export { + useDragReorderState, + type DragReorderHandlers, + type DragReorderState, +} from "./use-drag-reorder-state"; diff --git a/packages/app/src/components/drag-reorder/pointer-activation.test.ts b/packages/app/src/components/drag-reorder/pointer-activation.test.ts new file mode 100644 index 000000000..d1c6fa041 --- /dev/null +++ b/packages/app/src/components/drag-reorder/pointer-activation.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { getPointerActivationConstraint } from "./pointer-activation"; + +const config = { defaultDistance: 6, holdDelayMs: 250, holdTolerance: 8 }; + +describe("getPointerActivationConstraint", () => { + it("uses distance activation for default draggable rows", () => { + expect(getPointerActivationConstraint(false, config)).toEqual({ distance: 6 }); + }); + + it("requires a held pointer before activating handle-based drags", () => { + expect(getPointerActivationConstraint(true, config)).toEqual({ delay: 250, tolerance: 8 }); + }); +}); diff --git a/packages/app/src/components/drag-reorder/pointer-activation.ts b/packages/app/src/components/drag-reorder/pointer-activation.ts new file mode 100644 index 000000000..cb68e2bda --- /dev/null +++ b/packages/app/src/components/drag-reorder/pointer-activation.ts @@ -0,0 +1,19 @@ +export type PointerActivationConstraint = + | { distance: number } + | { delay: number; tolerance: number }; + +export interface PointerActivationConfig { + defaultDistance: number; + holdDelayMs: number; + holdTolerance: number; +} + +export function getPointerActivationConstraint( + useDragHandle: boolean, + config: PointerActivationConfig, +): PointerActivationConstraint { + if (useDragHandle) { + return { delay: config.holdDelayMs, tolerance: config.holdTolerance }; + } + return { distance: config.defaultDistance }; +} diff --git a/packages/app/src/components/drag-reorder/reorder-items.test.ts b/packages/app/src/components/drag-reorder/reorder-items.test.ts new file mode 100644 index 000000000..29992cf24 --- /dev/null +++ b/packages/app/src/components/drag-reorder/reorder-items.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { reorderItemsOnDragEnd } from "./reorder-items"; + +const items = ["alpha", "beta", "gamma"]; +const byValue = (item: string): string => item; + +describe("reorderItemsOnDragEnd", () => { + it("moves the active item to the over position", () => { + expect( + reorderItemsOnDragEnd({ + items, + activeId: "alpha", + overId: "gamma", + keyExtractor: byValue, + }), + ).toEqual(["beta", "gamma", "alpha"]); + }); + + it("is a no-op when the drop target is missing", () => { + expect( + reorderItemsOnDragEnd({ + items, + activeId: "alpha", + overId: null, + keyExtractor: byValue, + }), + ).toBeNull(); + }); + + it("is a no-op when the active and over items are the same", () => { + expect( + reorderItemsOnDragEnd({ + items, + activeId: "beta", + overId: "beta", + keyExtractor: byValue, + }), + ).toBeNull(); + }); + + it("is a no-op when the active id is not in the list", () => { + expect( + reorderItemsOnDragEnd({ + items, + activeId: "delta", + overId: "beta", + keyExtractor: byValue, + }), + ).toBeNull(); + }); + + it("is a no-op when the over id is not in the list", () => { + expect( + reorderItemsOnDragEnd({ + items, + activeId: "alpha", + overId: "delta", + keyExtractor: byValue, + }), + ).toBeNull(); + }); +}); diff --git a/packages/app/src/components/drag-reorder/reorder-items.ts b/packages/app/src/components/drag-reorder/reorder-items.ts new file mode 100644 index 000000000..b3de8afec --- /dev/null +++ b/packages/app/src/components/drag-reorder/reorder-items.ts @@ -0,0 +1,24 @@ +import { arrayMove } from "@dnd-kit/sortable"; + +export interface DragEndInput { + items: T[]; + activeId: string; + overId: string | null | undefined; + keyExtractor: (item: T, index: number) => string; +} + +export function reorderItemsOnDragEnd({ + items, + activeId, + overId, + keyExtractor, +}: DragEndInput): T[] | null { + if (!overId || activeId === overId) return null; + + const oldIndex = items.findIndex((item, i) => keyExtractor(item, i) === activeId); + const newIndex = items.findIndex((item, i) => keyExtractor(item, i) === overId); + + if (oldIndex < 0 || newIndex < 0 || oldIndex === newIndex) return null; + + return arrayMove(items, oldIndex, newIndex); +} diff --git a/packages/app/src/components/drag-reorder/use-drag-reorder-state.ts b/packages/app/src/components/drag-reorder/use-drag-reorder-state.ts new file mode 100644 index 000000000..400beeb28 --- /dev/null +++ b/packages/app/src/components/drag-reorder/use-drag-reorder-state.ts @@ -0,0 +1,73 @@ +import { useCallback, useReducer } from "react"; +import type { DragEndEvent, DragStartEvent } from "@dnd-kit/core"; +import { dragStateInitial, dragStateReducer } from "./drag-reducer"; +import { reorderItemsOnDragEnd } from "./reorder-items"; + +export interface DragReorderHandlers { + onDragStart: (event: DragStartEvent) => void; + onDragCancel: () => void; + onDragEnd: (event: DragEndEvent) => void; +} + +export interface DragReorderState { + activeId: string | null; + items: T[]; + handlers: DragReorderHandlers; +} + +export function useDragReorderState({ + data, + keyExtractor, + onDragEnd, + onDragBegin, + disabled = false, +}: { + data: T[]; + keyExtractor: (item: T, index: number) => string; + onDragEnd?: (items: T[]) => void; + onDragBegin?: () => void; + disabled?: boolean; +}): DragReorderState { + const [state, dispatch] = useReducer(dragStateReducer, undefined, dragStateInitial); + + const handleDragStart = useCallback( + (event: DragStartEvent) => { + if (disabled) return; + dispatch({ type: "start", id: String(event.active.id), data }); + onDragBegin?.(); + }, + [data, disabled, onDragBegin], + ); + + const clearDragState = useCallback(() => { + dispatch({ type: "clear" }); + }, []); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event; + const items = state.dragItems ?? data; + dispatch({ type: "clear" }); + if (disabled) return; + + const reordered = reorderItemsOnDragEnd({ + items, + activeId: String(active.id), + overId: over ? String(over.id) : null, + keyExtractor, + }); + if (reordered) onDragEnd?.(reordered); + }, + [data, disabled, keyExtractor, onDragEnd, state.dragItems], + ); + + return { + activeId: state.activeId, + items: state.dragItems ?? data, + handlers: { + onDragStart: handleDragStart, + onDragCancel: clearDragState, + onDragEnd: handleDragEnd, + }, + }; +} diff --git a/packages/app/src/components/draggable-list.web.test.tsx b/packages/app/src/components/draggable-list.web.test.tsx deleted file mode 100644 index 29fc391c4..000000000 --- a/packages/app/src/components/draggable-list.web.test.tsx +++ /dev/null @@ -1,167 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import React from "react"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { DraggableList } from "./draggable-list.web"; - -interface DndContextProps { - onDragStart?: (event: { active: { id: string } }) => void; - onDragCancel?: () => void; -} - -let latestDndContextProps: DndContextProps | null = null; -const dndKitMocks = vi.hoisted(() => ({ - useSensor: vi.fn(() => ({})), -})); - -vi.mock("@dnd-kit/core", () => ({ - DndContext: ({ children, ...props }: React.PropsWithChildren) => { - latestDndContextProps = props; - return
{children}
; - }, - closestCenter: vi.fn(), - KeyboardSensor: vi.fn(), - PointerSensor: vi.fn(), - useSensor: dndKitMocks.useSensor, - useSensors: vi.fn(() => []), -})); - -vi.mock("@dnd-kit/sortable", () => ({ - SortableContext: ({ children }: React.PropsWithChildren) => children, - arrayMove: (items: T[], from: number, to: number) => { - const next = [...items]; - const [item] = next.splice(from, 1); - if (item !== undefined) { - next.splice(to, 0, item); - } - return next; - }, - sortableKeyboardCoordinates: vi.fn(), - useSortable: () => ({ - attributes: {}, - listeners: {}, - setNodeRef: vi.fn(), - setActivatorNodeRef: vi.fn(), - transform: null, - transition: undefined, - isDragging: false, - }), - verticalListSortingStrategy: {}, -})); - -vi.mock("./use-web-scrollbar", () => ({ - useWebScrollViewScrollbar: () => ({ - onLayout: vi.fn(), - onContentSizeChange: vi.fn(), - onScroll: vi.fn(), - overlay: null, - }), -})); - -let root: Root | null = null; -let container: HTMLElement | null = null; - -beforeEach(() => { - vi.stubGlobal("React", React); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - vi.stubGlobal( - "ResizeObserver", - class ResizeObserver { - observe() {} - unobserve() {} - disconnect() {} - }, - ); - latestDndContextProps = null; - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); -}); - -afterEach(() => { - if (root) { - act(() => { - root?.unmount(); - }); - } - root = null; - container?.remove(); - container = null; - vi.unstubAllGlobals(); -}); - -const DATA: string[] = ["alpha", "beta"]; - -function keyExtractor(item: string): string { - return item; -} - -function renderItem({ item, isActive }: { item: string; isActive: boolean }) { - return ( -
- {item} -
- ); -} - -function renderList({ useDragHandle = false }: { useDragHandle?: boolean } = {}): void { - act(() => { - root?.render( - , - ); - }); -} - -function getItemActiveState(item: string): string | null { - return ( - container?.querySelector(`[data-testid="item-${item}"]`)?.getAttribute("data-active") ?? null - ); -} - -describe("DraggableList web", () => { - it("uses distance activation for default draggable rows", () => { - renderList(); - - expect(dndKitMocks.useSensor).toHaveBeenCalledWith( - expect.any(Function), - expect.objectContaining({ - activationConstraint: { distance: 6 }, - }), - ); - }); - - it("requires a held pointer before activating handle-based drags", () => { - renderList({ useDragHandle: true }); - - expect(dndKitMocks.useSensor).toHaveBeenCalledWith( - expect.any(Function), - expect.objectContaining({ - activationConstraint: { delay: 250, tolerance: 8 }, - }), - ); - }); - - it("clears active drag state when a drag is cancelled", () => { - renderList(); - - act(() => { - latestDndContextProps?.onDragStart?.({ active: { id: "alpha" } }); - }); - expect(getItemActiveState("alpha")).toBe("true"); - - act(() => { - latestDndContextProps?.onDragCancel?.(); - }); - expect(getItemActiveState("alpha")).toBe("false"); - }); -}); diff --git a/packages/app/src/components/draggable-list.web.tsx b/packages/app/src/components/draggable-list.web.tsx index a6dd57f7f..e9f673c67 100644 --- a/packages/app/src/components/draggable-list.web.tsx +++ b/packages/app/src/components/draggable-list.web.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useRef, useState, type ReactElement } from "react"; +import { useCallback, useMemo, useRef, type ReactElement } from "react"; import { ScrollView, View } from "react-native"; import { DndContext, @@ -8,19 +8,17 @@ import { type Modifier, useSensor, useSensors, - type DragEndEvent, - type DragStartEvent, } from "@dnd-kit/core"; import { SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, useSortable, - arrayMove, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import type { DraggableListProps, DraggableRenderItemInfo } from "./draggable-list.types"; import { useWebScrollViewScrollbar } from "./use-web-scrollbar"; +import { getPointerActivationConstraint, useDragReorderState } from "./drag-reorder"; export type { DraggableListProps, DraggableRenderItemInfo }; @@ -30,8 +28,11 @@ const restrictToVerticalAxis: Modifier = ({ transform }) => ({ }); const DND_MODIFIERS = [restrictToVerticalAxis]; -const DEFAULT_POINTER_ACTIVATION_CONSTRAINT = { distance: 6 }; -const HANDLE_POINTER_ACTIVATION_CONSTRAINT = { delay: 250, tolerance: 8 }; +const POINTER_ACTIVATION_CONFIG = { + defaultDistance: 6, + holdDelayMs: 250, + holdTolerance: 8, +}; interface SortableItemProps { id: string; @@ -137,17 +138,21 @@ export function DraggableList({ onDragBegin, nestable: _nestable = false, }: DraggableListProps) { - const [activeId, setActiveId] = useState(null); - const [dragItems, setDragItems] = useState(null); - const items = dragItems ?? data; + const { activeId, items, handlers } = useDragReorderState({ + data, + keyExtractor, + onDragEnd, + onDragBegin, + }); const showCustomScrollbar = enableDesktopWebScrollbar && scrollEnabled; const scrollViewRef = useRef(null); const scrollbar = useWebScrollViewScrollbar(scrollViewRef, { enabled: showCustomScrollbar, }); - const pointerActivationConstraint = useDragHandle - ? HANDLE_POINTER_ACTIVATION_CONSTRAINT - : DEFAULT_POINTER_ACTIVATION_CONSTRAINT; + const pointerActivationConstraint = getPointerActivationConstraint( + useDragHandle, + POINTER_ACTIVATION_CONFIG, + ); const sensors = useSensors( useSensor(PointerSensor, { @@ -158,39 +163,6 @@ export function DraggableList({ }), ); - const handleDragStart = useCallback( - (event: DragStartEvent) => { - setDragItems(data); - setActiveId(String(event.active.id)); - onDragBegin?.(); - }, - [data, onDragBegin], - ); - - const clearDragState = useCallback(() => { - setActiveId(null); - setDragItems(null); - }, []); - - const handleDragEnd = useCallback( - (event: DragEndEvent) => { - const { active, over } = event; - - clearDragState(); - - if (over && active.id !== over.id) { - const oldIndex = items.findIndex((item, i) => keyExtractor(item, i) === active.id); - const newIndex = items.findIndex((item, i) => keyExtractor(item, i) === over.id); - - if (oldIndex >= 0 && newIndex >= 0 && oldIndex !== newIndex) { - const newItems = arrayMove(items, oldIndex, newIndex); - onDragEnd(newItems); - } - } - }, - [clearDragState, items, keyExtractor, onDragEnd], - ); - const ids = useMemo( () => items.map((item, index) => keyExtractor(item, index)), [items, keyExtractor], @@ -224,9 +196,9 @@ export function DraggableList({ sensors={sensors} collisionDetection={closestCenter} modifiers={DND_MODIFIERS} - onDragStart={handleDragStart} - onDragCancel={clearDragState} - onDragEnd={handleDragEnd} + onDragStart={handlers.onDragStart} + onDragCancel={handlers.onDragCancel} + onDragEnd={handlers.onDragEnd} > {items.map((item, index) => { @@ -255,9 +227,9 @@ export function DraggableList({ sensors={sensors} collisionDetection={closestCenter} modifiers={DND_MODIFIERS} - onDragStart={handleDragStart} - onDragCancel={clearDragState} - onDragEnd={handleDragEnd} + onDragStart={handlers.onDragStart} + onDragCancel={handlers.onDragCancel} + onDragEnd={handlers.onDragEnd} > {items.map((item, index) => { diff --git a/packages/app/src/components/import-session-sheet-view-model.test.ts b/packages/app/src/components/import-session-sheet-view-model.test.ts new file mode 100644 index 000000000..dd79b45c7 --- /dev/null +++ b/packages/app/src/components/import-session-sheet-view-model.test.ts @@ -0,0 +1,273 @@ +import { describe, expect, it } from "vitest"; +import type { FetchRecentProviderSessionEntry } from "@getpaseo/client/internal/daemon-client"; +import { + aggregateSessionEntries, + ALL_FILTER_VALUE, + buildProviderLabelMap, + collectErroredProviderLabels, + computeEmptyState, + getPromptPreview, + getSessionTitle, + resolveProvidersToFetch, + type SessionsQueryResult, + sumFilteredAlreadyImportedCount, +} from "@/components/import-session-sheet-view-model"; + +function entry( + overrides: Partial = {}, +): FetchRecentProviderSessionEntry { + return { + providerId: "claude", + providerLabel: "Claude Code", + providerHandleId: "thread-1", + cwd: "/repo/paseo", + title: null, + firstPromptPreview: null, + lastPromptPreview: null, + lastActivityAt: "2026-04-30T10:00:00.000Z", + ...overrides, + }; +} + +function settled( + data: SessionsQueryResult["data"], + flags?: Partial>, +): SessionsQueryResult { + return { + data, + isError: false, + isLoading: false, + isPending: false, + ...flags, + }; +} + +describe("resolveProvidersToFetch", () => { + it("returns null when the daemon does not support provider snapshots", () => { + expect(resolveProvidersToFetch(false, [{ provider: "claude" }])).toBeNull(); + }); + + it("returns null while snapshot entries have not loaded yet", () => { + expect(resolveProvidersToFetch(true, undefined)).toBeNull(); + }); + + it("returns only enabled importable providers", () => { + const providers = resolveProvidersToFetch(true, [ + { provider: "claude" }, + { provider: "codex" }, + { provider: "opencode", enabled: false }, + { provider: "z-ai" }, + ]); + expect(providers).toEqual(["claude", "codex"]); + }); + + it("returns an empty array when snapshot has no enabled importable providers", () => { + const providers = resolveProvidersToFetch(true, [ + { provider: "claude", enabled: false }, + { provider: "z-ai" }, + ]); + expect(providers).toEqual([]); + }); +}); + +describe("buildProviderLabelMap", () => { + it("returns an empty map when snapshot entries are missing", () => { + expect(buildProviderLabelMap(undefined).size).toBe(0); + }); + + it("indexes labels by provider id, skipping entries without a label", () => { + const labels = buildProviderLabelMap([ + { provider: "claude", label: "Claude Code" }, + { provider: "codex" }, + { provider: "z-ai", label: "Z.AI" }, + ]); + expect(labels.get("claude")).toBe("Claude Code"); + expect(labels.get("codex")).toBeUndefined(); + expect(labels.get("z-ai")).toBe("Z.AI"); + }); +}); + +describe("aggregateSessionEntries", () => { + it("returns an empty array when no queries have data", () => { + expect(aggregateSessionEntries([settled(undefined)])).toEqual([]); + }); + + it("dedupes by providerId+providerHandleId across query results", () => { + const result = aggregateSessionEntries([ + settled({ + entries: [ + entry({ providerHandleId: "thread-1", lastActivityAt: "2026-04-30T10:00:00.000Z" }), + ], + }), + settled({ + entries: [ + entry({ providerHandleId: "thread-1", lastActivityAt: "2026-04-30T11:00:00.000Z" }), + entry({ providerHandleId: "thread-2", lastActivityAt: "2026-04-30T09:00:00.000Z" }), + ], + }), + ]); + expect(result.map((e) => e.providerHandleId)).toEqual(["thread-1", "thread-2"]); + }); + + it("sorts collected entries by lastActivityAt descending", () => { + const result = aggregateSessionEntries([ + settled({ + entries: [ + entry({ providerHandleId: "old", lastActivityAt: "2026-04-29T10:00:00.000Z" }), + entry({ providerHandleId: "new", lastActivityAt: "2026-04-30T10:00:00.000Z" }), + ], + }), + ]); + expect(result.map((e) => e.providerHandleId)).toEqual(["new", "old"]); + }); +}); + +describe("sumFilteredAlreadyImportedCount", () => { + it("returns 0 when no queries report a filtered count", () => { + expect(sumFilteredAlreadyImportedCount([settled({ entries: [] })])).toBe(0); + }); + + it("sums the filtered already-imported counts across queries", () => { + const total = sumFilteredAlreadyImportedCount([ + settled({ entries: [], filteredAlreadyImportedCount: 2 }), + settled({ entries: [], filteredAlreadyImportedCount: 3 }), + settled(undefined), + ]); + expect(total).toBe(5); + }); +}); + +describe("collectErroredProviderLabels", () => { + it("returns no labels when no providers are being fetched", () => { + expect(collectErroredProviderLabels(null, [], new Map())).toEqual([]); + }); + + it("returns labels for each errored provider, falling back to provider id", () => { + const labels = collectErroredProviderLabels( + ["claude", "codex"], + [settled(undefined, { isError: true }), settled({ entries: [] })], + new Map([["claude", "Claude Code"]]), + ); + expect(labels).toEqual(["Claude Code"]); + }); + + it("uses provider id when the label map has no entry", () => { + const labels = collectErroredProviderLabels( + ["codex"], + [settled(undefined, { isError: true })], + new Map(), + ); + expect(labels).toEqual(["codex"]); + }); +}); + +describe("getSessionTitle", () => { + it("prefers the trimmed title", () => { + expect(getSessionTitle(entry({ title: " Importable " }))).toBe("Importable"); + }); + + it("falls back to the trimmed first prompt preview when title is empty", () => { + expect(getSessionTitle(entry({ title: " ", firstPromptPreview: " Hello " }))).toBe("Hello"); + }); + + it("falls back to Untitled session when both title and first prompt are blank", () => { + expect(getSessionTitle(entry({ title: null, firstPromptPreview: " " }))).toBe( + "Untitled session", + ); + }); +}); + +describe("getPromptPreview", () => { + it("prefers the trimmed last prompt preview", () => { + expect( + getPromptPreview( + entry({ lastPromptPreview: " later ", firstPromptPreview: " earlier " }), + ), + ).toBe("later"); + }); + + it("falls back to the first prompt preview when last is blank", () => { + expect( + getPromptPreview(entry({ lastPromptPreview: " ", firstPromptPreview: " earlier " })), + ).toBe("earlier"); + }); + + it("falls back to a placeholder when both prompts are blank", () => { + expect(getPromptPreview(entry({ lastPromptPreview: null, firstPromptPreview: null }))).toBe( + "No prompt preview", + ); + }); +}); + +describe("computeEmptyState", () => { + const baseInputs = { + isLoadingSessions: false, + allQueriesErrored: false, + isQueryingProviders: true, + allQueriesSettled: true, + selectedProvider: ALL_FILTER_VALUE, + aggregatedCount: 0, + visibleCount: 0, + totalAlreadyImportedCount: 0, + providerLabelById: new Map(), + }; + + it("hides the empty state while sessions are still loading", () => { + const result = computeEmptyState({ ...baseInputs, isLoadingSessions: true }); + expect(result.showEmptyState).toBe(false); + }); + + it("hides the empty state when every query errored", () => { + const result = computeEmptyState({ ...baseInputs, allQueriesErrored: true }); + expect(result.showEmptyState).toBe(false); + }); + + it("hides the empty state until every provider query has settled", () => { + const result = computeEmptyState({ ...baseInputs, allQueriesSettled: false }); + expect(result.showEmptyState).toBe(false); + }); + + it("hides the empty state when there are visible entries", () => { + const result = computeEmptyState({ + ...baseInputs, + aggregatedCount: 2, + visibleCount: 2, + }); + expect(result.showEmptyState).toBe(false); + }); + + it("shows the default no-sessions message when nothing is loaded and nothing is filtered", () => { + const result = computeEmptyState(baseInputs); + expect(result).toEqual({ + showEmptyState: true, + emptyStateTitle: "No recent sessions to import.", + }); + }); + + it("shows the already-imported message when imported entries were filtered out", () => { + const result = computeEmptyState({ + ...baseInputs, + totalAlreadyImportedCount: 4, + }); + expect(result.emptyStateTitle).toBe("All recent sessions are already imported."); + }); + + it("shows a provider-scoped message when a filter hides aggregated entries", () => { + const result = computeEmptyState({ + ...baseInputs, + selectedProvider: "claude", + aggregatedCount: 3, + providerLabelById: new Map([["claude", "Claude Code"]]), + }); + expect(result.emptyStateTitle).toBe("No Claude Code sessions found."); + }); + + it("falls back to the provider id when the filtered provider lacks a label", () => { + const result = computeEmptyState({ + ...baseInputs, + selectedProvider: "z-ai", + aggregatedCount: 1, + }); + expect(result.emptyStateTitle).toBe("No z-ai sessions found."); + }); +}); diff --git a/packages/app/src/components/import-session-sheet-view-model.ts b/packages/app/src/components/import-session-sheet-view-model.ts new file mode 100644 index 000000000..31d81038e --- /dev/null +++ b/packages/app/src/components/import-session-sheet-view-model.ts @@ -0,0 +1,145 @@ +import type { FetchRecentProviderSessionEntry } from "@getpaseo/client/internal/daemon-client"; +import type { AgentProvider } from "@getpaseo/protocol/agent-types"; +import { IMPORTABLE_PROVIDERS } from "@getpaseo/protocol/importable-providers"; + +export const IMPORTABLE_PROVIDER_IDS: Set = new Set(IMPORTABLE_PROVIDERS); +export const PER_PROVIDER_LIMIT = 15; +export const ALL_FILTER_VALUE = "__all__"; + +export interface SessionsQueryResult { + data: + | { + entries: FetchRecentProviderSessionEntry[]; + filteredAlreadyImportedCount?: number; + } + | undefined; + isError: boolean; + isLoading: boolean; + isPending: boolean; +} + +export function resolveProvidersToFetch( + supportsSnapshot: boolean, + snapshotEntries: ReadonlyArray<{ provider: string; enabled?: boolean }> | undefined, +): AgentProvider[] | null { + // COMPAT(providersSnapshot): the import-recent-sessions feature ships alongside + // providersSnapshot (v0.1.48, 2026-04-05). Daemons older than that lack both — + // we render an "update host" empty state instead of degrading. Drop this gate + // when the supported daemon floor is >= v0.1.48 (target: 2026-10-05). + if (!supportsSnapshot) return null; + if (!snapshotEntries) return null; + return snapshotEntries + .filter((entry) => IMPORTABLE_PROVIDER_IDS.has(entry.provider) && entry.enabled !== false) + .map((entry) => entry.provider); +} + +export function buildProviderLabelMap( + snapshotEntries: ReadonlyArray<{ provider: string; label?: string }> | undefined, +): Map { + const map = new Map(); + if (!snapshotEntries) return map; + for (const entry of snapshotEntries) { + if (entry.label) { + map.set(entry.provider, entry.label); + } + } + return map; +} + +export function aggregateSessionEntries( + queries: ReadonlyArray, +): FetchRecentProviderSessionEntry[] { + const seen = new Set(); + const collected: FetchRecentProviderSessionEntry[] = []; + for (const query of queries) { + if (!query.data) continue; + for (const entry of query.data.entries) { + const key = `${entry.providerId}:${entry.providerHandleId}`; + if (seen.has(key)) continue; + seen.add(key); + collected.push(entry); + } + } + collected.sort( + (a, b) => new Date(b.lastActivityAt).getTime() - new Date(a.lastActivityAt).getTime(), + ); + return collected; +} + +export function sumFilteredAlreadyImportedCount( + queries: ReadonlyArray, +): number { + let total = 0; + for (const query of queries) { + total += query.data?.filteredAlreadyImportedCount ?? 0; + } + return total; +} + +export function collectErroredProviderLabels( + providersToFetch: AgentProvider[] | null, + queries: ReadonlyArray, + providerLabelById: ReadonlyMap, +): string[] { + if (providersToFetch === null) return []; + const labels: string[] = []; + for (let index = 0; index < queries.length; index++) { + if (queries[index]?.isError) { + const provider = providersToFetch[index]; + labels.push(providerLabelById.get(provider) ?? provider); + } + } + return labels; +} + +export function getSessionTitle(entry: FetchRecentProviderSessionEntry): string { + const title = entry.title?.trim(); + if (title) { + return title; + } + const firstPromptPreview = entry.firstPromptPreview?.trim(); + if (firstPromptPreview) { + return firstPromptPreview; + } + return "Untitled session"; +} + +export function getPromptPreview(entry: FetchRecentProviderSessionEntry): string { + return entry.lastPromptPreview?.trim() || entry.firstPromptPreview?.trim() || "No prompt preview"; +} + +export interface EmptyStateInputs { + isLoadingSessions: boolean; + allQueriesErrored: boolean; + isQueryingProviders: boolean; + allQueriesSettled: boolean; + selectedProvider: string; + aggregatedCount: number; + visibleCount: number; + totalAlreadyImportedCount: number; + providerLabelById: ReadonlyMap; +} + +export function computeEmptyState(input: EmptyStateInputs): { + showEmptyState: boolean; + emptyStateTitle: string; +} { + const showEmptyState = + !input.isLoadingSessions && + !input.allQueriesErrored && + input.isQueryingProviders && + input.allQueriesSettled && + input.visibleCount === 0; + if (!showEmptyState) { + return { showEmptyState, emptyStateTitle: "" }; + } + const isFilteredEmpty = input.selectedProvider !== ALL_FILTER_VALUE && input.aggregatedCount > 0; + if (isFilteredEmpty) { + const label = input.providerLabelById.get(input.selectedProvider) ?? input.selectedProvider; + return { showEmptyState, emptyStateTitle: `No ${label} sessions found.` }; + } + if (input.totalAlreadyImportedCount > 0) { + return { showEmptyState, emptyStateTitle: "All recent sessions are already imported." }; + } + return { showEmptyState, emptyStateTitle: "No recent sessions to import." }; +} diff --git a/packages/app/src/components/import-session-sheet.tsx b/packages/app/src/components/import-session-sheet.tsx index b737a10f1..b85976c88 100644 --- a/packages/app/src/components/import-session-sheet.tsx +++ b/packages/app/src/components/import-session-sheet.tsx @@ -6,7 +6,6 @@ import type { FetchRecentProviderSessionEntry, } from "@getpaseo/client/internal/daemon-client"; import type { AgentProvider } from "@getpaseo/protocol/agent-types"; -import { IMPORTABLE_PROVIDERS } from "@getpaseo/protocol/importable-providers"; import { Inbox, RotateCw } from "lucide-react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; @@ -15,12 +14,21 @@ import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/s import { getProviderIcon } from "@/components/provider-icons"; import { formatTimeAgo } from "@/utils/time"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; +import { + aggregateSessionEntries, + ALL_FILTER_VALUE, + buildProviderLabelMap, + collectErroredProviderLabels, + computeEmptyState, + getPromptPreview, + getSessionTitle, + PER_PROVIDER_LIMIT, + resolveProvidersToFetch, + sumFilteredAlreadyImportedCount, +} from "@/components/import-session-sheet-view-model"; -const IMPORTABLE_PROVIDER_IDS: Set = new Set(IMPORTABLE_PROVIDERS); -const PER_PROVIDER_LIMIT = 15; const IMPORT_SHEET_SNAP_POINTS = ["70%", "92%"]; const DISABLED_ACCESSIBILITY_STATE = { disabled: true }; -const ALL_FILTER_VALUE = "__all__"; type RecentProviderSessionsClient = Pick< DaemonClient, @@ -49,41 +57,6 @@ interface SessionsQueryConfig { queryFn: () => Promise; } -interface SessionsQueryResult { - data: RecentSessionsResponse | undefined; - isError: boolean; - isLoading: boolean; - isPending: boolean; -} - -function resolveProvidersToFetch( - supportsSnapshot: boolean, - snapshotEntries: ReadonlyArray<{ provider: string; enabled?: boolean }> | undefined, -): AgentProvider[] | null { - // COMPAT(providersSnapshot): the import-recent-sessions feature ships alongside - // providersSnapshot (v0.1.48, 2026-04-05). Daemons older than that lack both — - // we render an "update host" empty state instead of degrading. Drop this gate - // when the supported daemon floor is >= v0.1.48 (target: 2026-10-05). - if (!supportsSnapshot) return null; - if (!snapshotEntries) return null; - return snapshotEntries - .filter((entry) => IMPORTABLE_PROVIDER_IDS.has(entry.provider) && entry.enabled !== false) - .map((entry) => entry.provider); -} - -function buildProviderLabelMap( - snapshotEntries: ReadonlyArray<{ provider: string; label?: string }> | undefined, -): Map { - const map = new Map(); - if (!snapshotEntries) return map; - for (const entry of snapshotEntries) { - if (entry.label) { - map.set(entry.provider, entry.label); - } - } - return map; -} - function buildSessionsQueriesConfig(args: { providersToFetch: AgentProvider[] | null; sessionsQueryRoot: ReadonlyArray; @@ -110,66 +83,6 @@ function buildSessionsQueriesConfig(args: { })); } -function aggregateSessionEntries( - queries: ReadonlyArray, -): FetchRecentProviderSessionEntry[] { - const seen = new Set(); - const collected: FetchRecentProviderSessionEntry[] = []; - for (const query of queries) { - if (!query.data) continue; - for (const entry of query.data.entries) { - const key = `${entry.providerId}:${entry.providerHandleId}`; - if (seen.has(key)) continue; - seen.add(key); - collected.push(entry); - } - } - collected.sort( - (a, b) => new Date(b.lastActivityAt).getTime() - new Date(a.lastActivityAt).getTime(), - ); - return collected; -} - -function sumFilteredAlreadyImportedCount(queries: ReadonlyArray): number { - let total = 0; - for (const query of queries) { - total += query.data?.filteredAlreadyImportedCount ?? 0; - } - return total; -} - -function collectErroredProviderLabels( - providersToFetch: AgentProvider[] | null, - queries: ReadonlyArray, - providerLabelById: ReadonlyMap, -): string[] { - if (providersToFetch === null) return []; - const labels: string[] = []; - for (let index = 0; index < queries.length; index++) { - if (queries[index]?.isError) { - const provider = providersToFetch[index]; - labels.push(providerLabelById.get(provider) ?? provider); - } - } - return labels; -} - -function getSessionTitle(entry: FetchRecentProviderSessionEntry): string { - const title = entry.title?.trim(); - if (title) { - return title; - } - const firstPromptPreview = entry.firstPromptPreview?.trim(); - if (firstPromptPreview) { - return firstPromptPreview; - } - return "Untitled session"; -} - -function getPromptPreview(entry: FetchRecentProviderSessionEntry): string { - return entry.lastPromptPreview?.trim() || entry.firstPromptPreview?.trim() || "No prompt preview"; -} - interface SheetStatusMessagesProps { isClientReady: boolean; isSnapshotUnsupported: boolean; @@ -222,42 +135,6 @@ function SheetStatusMessages({ ); } -interface EmptyStateInputs { - isLoadingSessions: boolean; - allQueriesErrored: boolean; - isQueryingProviders: boolean; - allQueriesSettled: boolean; - selectedProvider: string; - aggregatedCount: number; - visibleCount: number; - totalAlreadyImportedCount: number; - providerLabelById: ReadonlyMap; -} - -function computeEmptyState(input: EmptyStateInputs): { - showEmptyState: boolean; - emptyStateTitle: string; -} { - const showEmptyState = - !input.isLoadingSessions && - !input.allQueriesErrored && - input.isQueryingProviders && - input.allQueriesSettled && - input.visibleCount === 0; - if (!showEmptyState) { - return { showEmptyState, emptyStateTitle: "" }; - } - const isFilteredEmpty = input.selectedProvider !== ALL_FILTER_VALUE && input.aggregatedCount > 0; - if (isFilteredEmpty) { - const label = input.providerLabelById.get(input.selectedProvider) ?? input.selectedProvider; - return { showEmptyState, emptyStateTitle: `No ${label} sessions found.` }; - } - if (input.totalAlreadyImportedCount > 0) { - return { showEmptyState, emptyStateTitle: "All recent sessions are already imported." }; - } - return { showEmptyState, emptyStateTitle: "No recent sessions to import." }; -} - function RefreshAction({ isRefreshing, onPress }: { isRefreshing: boolean; onPress: () => void }) { const { theme } = useUnistyles(); const pressableStyle = useCallback( diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 4a59a6f2f..aa8cc3ca3 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -74,6 +74,7 @@ import { HighlightedCodeBlock } from "@/components/highlighted-code-block"; import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks"; import { formatDuration, formatMessageTimestamp } from "@/utils/time"; import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard"; +import { getDefaultMarkdownClipboardEnvironment } from "@/utils/rich-clipboard-default-environment"; import { getAssistantImageLoadStateFromMetadata, getAssistantImageMetadata, @@ -1149,7 +1150,7 @@ export const TurnCopyButton = memo(function TurnCopyButton({ return; } - await writeMarkdownToRichClipboard(content); + await writeMarkdownToRichClipboard(content, getDefaultMarkdownClipboardEnvironment()); setCopied(true); if (copyTimeoutRef.current) { diff --git a/packages/app/src/components/provider-icon-name.test.ts b/packages/app/src/components/provider-icon-name.test.ts new file mode 100644 index 000000000..f2149d1a9 --- /dev/null +++ b/packages/app/src/components/provider-icon-name.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { resolveProviderIconName } from "./provider-icon-name"; + +describe("resolveProviderIconName", () => { + it("returns the built-in identifier for known provider ids", () => { + expect(resolveProviderIconName("kiro")).toEqual({ kind: "builtin", id: "kiro" }); + expect(resolveProviderIconName("claude")).toEqual({ kind: "builtin", id: "claude" }); + }); + + it("returns the catalog identifier for ACP catalog provider ids that ship an icon", () => { + expect(resolveProviderIconName("amp-acp")).toEqual({ kind: "catalog", id: "amp-acp" }); + }); + + it("falls back to the bot icon for unknown custom providers", () => { + expect(resolveProviderIconName("custom-claude-profile")).toEqual({ kind: "bot" }); + }); +}); diff --git a/packages/app/src/components/provider-icon-name.ts b/packages/app/src/components/provider-icon-name.ts new file mode 100644 index 000000000..2b04168da --- /dev/null +++ b/packages/app/src/components/provider-icon-name.ts @@ -0,0 +1,31 @@ +import { ACP_PROVIDER_CATALOG } from "@/data/acp-provider-catalog"; + +export type BuiltinProviderIconName = "claude" | "codex" | "copilot" | "kiro" | "opencode" | "pi"; + +export type ProviderIconName = + | { kind: "builtin"; id: BuiltinProviderIconName } + | { kind: "catalog"; id: string } + | { kind: "bot" }; + +const BUILTIN_PROVIDER_IDS: ReadonlySet = new Set([ + "claude", + "codex", + "copilot", + "kiro", + "opencode", + "pi", +]); + +const CATALOG_ICON_PROVIDER_IDS: ReadonlySet = new Set( + ACP_PROVIDER_CATALOG.flatMap((entry) => (entry.iconSvg ? [entry.id] : [])), +); + +export function resolveProviderIconName(provider: string): ProviderIconName { + if (BUILTIN_PROVIDER_IDS.has(provider as BuiltinProviderIconName)) { + return { kind: "builtin", id: provider as BuiltinProviderIconName }; + } + if (CATALOG_ICON_PROVIDER_IDS.has(provider)) { + return { kind: "catalog", id: provider }; + } + return { kind: "bot" }; +} diff --git a/packages/app/src/components/provider-icons.test.ts b/packages/app/src/components/provider-icons.test.ts deleted file mode 100644 index 681f44ecf..000000000 --- a/packages/app/src/components/provider-icons.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -vi.mock("lucide-react-native", () => ({ - Bot: function Bot() { - return null; - }, - PackagePlus: function PackagePlus() { - return null; - }, -})); - -import { Bot, PackagePlus } from "lucide-react-native"; -import { getProviderIcon } from "./provider-icons"; - -describe("getProviderIcon", () => { - it("keeps built-in provider icons", () => { - expect(getProviderIcon("kiro")).toBe(PackagePlus); - }); - - it("uses vendored ACP catalog icons for catalog provider ids", () => { - const icon = getProviderIcon("amp-acp"); - - expect(icon).not.toBe(Bot); - expect(getProviderIcon("amp-acp")).toBe(icon); - }); - - it("falls back to the robot icon for unknown custom providers", () => { - expect(getProviderIcon("custom-claude-profile")).toBe(Bot); - }); -}); diff --git a/packages/app/src/components/provider-icons.ts b/packages/app/src/components/provider-icons.ts index 941c861ad..fea4e41a8 100644 --- a/packages/app/src/components/provider-icons.ts +++ b/packages/app/src/components/provider-icons.ts @@ -7,6 +7,10 @@ import { CopilotIcon } from "@/components/icons/copilot-icon"; import { OpenCodeIcon } from "@/components/icons/opencode-icon"; import { PiIcon } from "@/components/icons/pi-icon"; import { ACP_PROVIDER_CATALOG } from "@/data/acp-provider-catalog"; +import { + resolveProviderIconName, + type BuiltinProviderIconName, +} from "@/components/provider-icon-name"; export interface ProviderIconProps { size: number; @@ -15,7 +19,7 @@ export interface ProviderIconProps { export type ProviderIconComponent = ComponentType; -const PROVIDER_ICONS: Record = { +const BUILTIN_PROVIDER_ICONS: Record = { claude: ClaudeIcon as unknown as ProviderIconComponent, codex: CodexIcon as unknown as ProviderIconComponent, copilot: CopilotIcon as unknown as ProviderIconComponent, @@ -42,22 +46,24 @@ function createCatalogIcon(provider: string, iconSvg: string): ProviderIconCompo return CatalogProviderIcon; } -function getCatalogProviderIcon(provider: string): ProviderIconComponent | undefined { +function getCatalogProviderIcon(provider: string): ProviderIconComponent { const cached = catalogIconComponents.get(provider); if (cached) { return cached; } - - const iconSvg = CATALOG_ICON_SVGS.get(provider); - if (!iconSvg) { - return undefined; - } - + const iconSvg = CATALOG_ICON_SVGS.get(provider) ?? ""; const icon = createCatalogIcon(provider, iconSvg); catalogIconComponents.set(provider, icon); return icon; } export function getProviderIcon(provider: string): ProviderIconComponent { - return PROVIDER_ICONS[provider] ?? getCatalogProviderIcon(provider) ?? Bot; + const name = resolveProviderIconName(provider); + if (name.kind === "builtin") { + return BUILTIN_PROVIDER_ICONS[name.id]; + } + if (name.kind === "catalog") { + return getCatalogProviderIcon(name.id); + } + return Bot; } diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 7f9d504d5..22fc777e4 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -27,7 +27,7 @@ import { type Ref, } from "react"; import { router, usePathname, type Href } from "expo-router"; -import { navigateToWorkspace } from "@/hooks/use-workspace-navigation"; +import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import type { Theme } from "@/styles/theme"; import { type GestureType } from "react-native-gesture-handler"; diff --git a/packages/app/src/components/sortable-inline-list.web.test.tsx b/packages/app/src/components/sortable-inline-list.web.test.tsx deleted file mode 100644 index 5196ea7ae..000000000 --- a/packages/app/src/components/sortable-inline-list.web.test.tsx +++ /dev/null @@ -1,123 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import React from "react"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { SortableInlineList } from "./sortable-inline-list.web"; - -interface DndContextProps { - onDragStart?: (event: { active: { id: string } }) => void; - onDragCancel?: () => void; -} - -let latestDndContextProps: DndContextProps | null = null; - -vi.mock("@dnd-kit/core", () => ({ - DndContext: ({ children, ...props }: React.PropsWithChildren) => { - latestDndContextProps = props; - return
{children}
; - }, - closestCenter: vi.fn(), - KeyboardSensor: vi.fn(), - PointerSensor: vi.fn(), - useSensor: vi.fn(() => ({})), - useSensors: vi.fn(() => []), -})); - -vi.mock("@dnd-kit/sortable", () => ({ - SortableContext: ({ children }: React.PropsWithChildren) => children, - arrayMove: (items: T[], from: number, to: number) => { - const next = [...items]; - const [item] = next.splice(from, 1); - if (item !== undefined) { - next.splice(to, 0, item); - } - return next; - }, - horizontalListSortingStrategy: {}, - sortableKeyboardCoordinates: vi.fn(), - useSortable: () => ({ - attributes: {}, - listeners: {}, - setNodeRef: vi.fn(), - setActivatorNodeRef: vi.fn(), - transform: null, - transition: undefined, - isDragging: false, - }), -})); - -let root: Root | null = null; -let container: HTMLElement | null = null; - -beforeEach(() => { - vi.stubGlobal("React", React); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - latestDndContextProps = null; - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); -}); - -afterEach(() => { - if (root) { - act(() => { - root?.unmount(); - }); - } - root = null; - container?.remove(); - container = null; - vi.unstubAllGlobals(); -}); - -const DATA: string[] = ["alpha", "beta"]; - -function keyExtractor(item: string): string { - return item; -} - -function renderItem({ item, isActive }: { item: string; isActive: boolean }) { - return ( -
- {item} -
- ); -} - -function renderList(): void { - act(() => { - root?.render( - , - ); - }); -} - -function getItemActiveState(item: string): string | null { - return ( - container?.querySelector(`[data-testid="item-${item}"]`)?.getAttribute("data-active") ?? null - ); -} - -describe("SortableInlineList web", () => { - it("clears active drag state when a drag is cancelled", () => { - renderList(); - - act(() => { - latestDndContextProps?.onDragStart?.({ active: { id: "alpha" } }); - }); - expect(getItemActiveState("alpha")).toBe("true"); - - act(() => { - latestDndContextProps?.onDragCancel?.(); - }); - expect(getItemActiveState("alpha")).toBe("false"); - }); -}); diff --git a/packages/app/src/components/sortable-inline-list.web.tsx b/packages/app/src/components/sortable-inline-list.web.tsx index 22c125cbc..133cd408d 100644 --- a/packages/app/src/components/sortable-inline-list.web.tsx +++ b/packages/app/src/components/sortable-inline-list.web.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState, type ReactElement } from "react"; +import { useCallback, useMemo, type ReactElement } from "react"; import { DndContext, closestCenter, @@ -7,18 +7,16 @@ import { type Modifier, useSensor, useSensors, - type DragEndEvent, - type DragStartEvent, } from "@dnd-kit/core"; import { SortableContext, sortableKeyboardCoordinates, horizontalListSortingStrategy, useSortable, - arrayMove, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import type { DraggableRenderItemInfo } from "./draggable-list.types"; +import { useDragReorderState } from "./drag-reorder"; const restrictToHorizontalAxis: Modifier = ({ transform }) => ({ ...transform, @@ -138,9 +136,19 @@ export function SortableInlineList({ activeId?: string | null; getItemData?: (item: T, index: number) => Record; }): ReactElement { - const [activeId, setActiveId] = useState(null); - const [dragItems, setDragItems] = useState(null); - const items = externalDndContext ? data : (dragItems ?? data); + const { + activeId: internalActiveId, + items: managedItems, + handlers, + } = useDragReorderState({ + data, + keyExtractor, + onDragEnd, + onDragBegin, + disabled, + }); + const items = externalDndContext ? data : managedItems; + const activeId = externalDndContext ? externalActiveId : internalActiveId; const sensors = useSensors( useSensor(PointerSensor, { @@ -153,46 +161,6 @@ export function SortableInlineList({ }), ); - const handleDragStart = useCallback( - (event: DragStartEvent) => { - if (disabled) { - return; - } - setDragItems(data); - setActiveId(String(event.active.id)); - onDragBegin?.(); - }, - [data, disabled, onDragBegin], - ); - - const clearDragState = useCallback(() => { - setActiveId(null); - setDragItems(null); - }, []); - - const handleDragEnd = useCallback( - (event: DragEndEvent) => { - const { active, over } = event; - - clearDragState(); - - if (disabled) { - return; - } - - if (over && active.id !== over.id) { - const oldIndex = items.findIndex((item, i) => keyExtractor(item, i) === active.id); - const newIndex = items.findIndex((item, i) => keyExtractor(item, i) === over.id); - - if (oldIndex >= 0 && newIndex >= 0 && oldIndex !== newIndex) { - const newItems = arrayMove(items, oldIndex, newIndex); - onDragEnd?.(newItems); - } - } - }, - [clearDragState, disabled, items, keyExtractor, onDragEnd], - ); - const ids = useMemo( () => items.map((item, index) => keyExtractor(item, index)), [items, keyExtractor], @@ -209,7 +177,7 @@ export function SortableInlineList({ item={item} index={index} renderItem={renderItem} - activeId={externalDndContext ? externalActiveId : activeId} + activeId={activeId} useDragHandle={useDragHandle} disabled={disabled} itemData={getItemData?.(item, index)} @@ -229,9 +197,9 @@ export function SortableInlineList({ sensors={sensors} collisionDetection={closestCenter} modifiers={DND_MODIFIERS} - onDragStart={handleDragStart} - onDragCancel={clearDragState} - onDragEnd={handleDragEnd} + onDragStart={handlers.onDragStart} + onDragCancel={handlers.onDragCancel} + onDragEnd={handlers.onDragEnd} > {renderedItems} diff --git a/packages/app/src/components/terminal-emulator.tsx b/packages/app/src/components/terminal-emulator.tsx index 22e9d33e2..952619945 100644 --- a/packages/app/src/components/terminal-emulator.tsx +++ b/packages/app/src/components/terminal-emulator.tsx @@ -41,6 +41,7 @@ import { isTerminalFileDrag, prepareDroppedPathsForTerminal, } from "../terminal/drop/terminal-file-drop"; +import { getDesktopHost } from "@/desktop/host"; export interface TerminalEmulatorHandle { writeOutput: (data: TerminalOutputData) => void; @@ -833,13 +834,14 @@ export default function TerminalEmulator({ event.stopPropagation(); clearTerminalDropActive(); - const paths = extractTerminalDropPaths(event.dataTransfer); + const bridge = getDesktopHost(); + const paths = extractTerminalDropPaths(event.dataTransfer, bridge); if (paths.length === 0) { return; } runtimeRef.current?.focus(); - mountCallbacksRef.current.onInput?.(prepareDroppedPathsForTerminal(paths)); + mountCallbacksRef.current.onInput?.(prepareDroppedPathsForTerminal(paths, bridge)); }; root.addEventListener("dragenter", handleDragEnter, { capture: true }); diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal.test.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal.test.tsx deleted file mode 100644 index 19a3bf034..000000000 --- a/packages/app/src/components/ui/isolated-bottom-sheet-modal.test.tsx +++ /dev/null @@ -1,189 +0,0 @@ -// @vitest-environment jsdom -import { cleanup, render } from "@testing-library/react"; -import React from "react"; -import type { ReactNode } from "react"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { - IsolatedBottomSheetModal, - useIsolatedBottomSheetVisibility, -} from "./isolated-bottom-sheet-modal"; - -const SNAP_POINTS_50: (string | number)[] = ["50%"]; -const SNAP_POINTS_60: (string | number)[] = ["60%"]; -const SNAP_POINTS_90: (string | number)[] = ["90%"]; - -function noop(): void {} - -interface CapturedModalProps { - onChange?: (index: number) => void; - onDismiss?: () => void; -} - -const { modalMethods, modalProps, shouldExposeModalRef } = vi.hoisted(() => ({ - modalMethods: { - present: vi.fn(), - close: vi.fn(), - snapToIndex: vi.fn(), - dismiss: vi.fn(), - }, - modalProps: vi.fn(), - shouldExposeModalRef: { current: true }, -})); - -vi.mock("@gorhom/bottom-sheet", async () => { - const React = await import("react"); - const MockBottomSheetModal = React.forwardRef( - (props: Record, ref: React.ForwardedRef) => { - modalProps(props); - React.useImperativeHandle(ref, () => (shouldExposeModalRef.current ? modalMethods : null)); - return React.createElement( - "div", - { "data-testid": "bottom-sheet" }, - props.children as ReactNode, - ); - }, - ); - - return { - BottomSheetModal: MockBottomSheetModal, - BottomSheetModalProvider: ({ children }: { children: ReactNode }) => - React.createElement("div", { "data-testid": "bottom-sheet-provider" }, children), - }; -}); - -vi.mock("@gorhom/portal", async () => { - const React = await import("react"); - return { - Portal: ({ children, hostName }: { children: ReactNode; hostName?: string }) => - React.createElement("div", { "data-host": hostName, "data-testid": "app-portal" }, children), - }; -}); - -function Harness({ visible, onClose }: { visible: boolean; onClose: () => void }) { - const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({ - visible, - onClose, - }); - - return ( - -
Sheet content
-
- ); -} - -function latestModalProps(): CapturedModalProps { - const props = modalProps.mock.lastCall?.[0]; - expect(props).toBeDefined(); - return props as CapturedModalProps; -} - -describe("IsolatedBottomSheetModal", () => { - afterEach(() => { - cleanup(); - vi.clearAllMocks(); - shouldExposeModalRef.current = true; - }); - - it("forces sheet isolation and uses the modal dismissal lifecycle", () => { - const onClose = vi.fn(); - const { getByTestId, rerender } = render(); - - expect(getByTestId("app-portal").getAttribute("data-host")).toBe("root"); - expect(modalProps).toHaveBeenLastCalledWith( - expect.objectContaining({ - enableDismissOnClose: true, - stackBehavior: "replace", - }), - ); - expect(modalMethods.present).not.toHaveBeenCalled(); - - rerender(); - expect(modalMethods.present).toHaveBeenCalledTimes(1); - - rerender(); - expect(modalMethods.dismiss).toHaveBeenCalledTimes(1); - expect(modalMethods.close).not.toHaveBeenCalled(); - - rerender(); - expect(modalMethods.present).toHaveBeenCalledTimes(2); - expect(modalMethods.snapToIndex).not.toHaveBeenCalled(); - }); - - it("only reports a user close when the sheet was visible", () => { - const onClose = vi.fn(); - const { rerender } = render(); - - latestModalProps().onChange?.(-1); - expect(onClose).toHaveBeenCalledTimes(1); - - rerender(); - latestModalProps().onChange?.(-1); - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it("reports a dismiss while visible as a close request", () => { - const onClose = vi.fn(); - render(); - - latestModalProps().onDismiss?.(); - - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it("deduplicates close notifications from change and dismiss callbacks", () => { - const onClose = vi.fn(); - render(); - - const props = latestModalProps(); - props.onChange?.(-1); - props.onDismiss?.(); - - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it("presents when the sheet ref becomes available after opening", () => { - shouldExposeModalRef.current = false; - const onClose = vi.fn(); - const { rerender } = render(); - - expect(modalMethods.present).not.toHaveBeenCalled(); - expect(modalMethods.snapToIndex).not.toHaveBeenCalled(); - - shouldExposeModalRef.current = true; - rerender(); - - expect(modalMethods.present).toHaveBeenCalledTimes(1); - expect(modalMethods.snapToIndex).not.toHaveBeenCalled(); - }); - - it("allows nested sheets inside a parent sheet without creating a sibling provider", () => { - const { getAllByTestId } = render( - - -
Nested model picker
-
-
, - ); - - expect(getAllByTestId("bottom-sheet-provider")).toHaveLength(1); - expect(modalProps).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - stackBehavior: "replace", - }), - ); - expect(modalProps).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - stackBehavior: "push", - }), - ); - }); -}); diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx similarity index 54% rename from packages/app/src/components/ui/isolated-bottom-sheet-modal.tsx rename to packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx index 6236974a0..6b3f20c91 100644 --- a/packages/app/src/components/ui/isolated-bottom-sheet-modal.tsx +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx @@ -5,8 +5,12 @@ import { } from "@gorhom/bottom-sheet"; import { Portal } from "@gorhom/portal"; import React, { createContext, useContext } from "react"; -import { forwardRef, useCallback, useEffect, useRef } from "react"; +import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react"; import type { ElementRef } from "react"; +import { + type BottomSheetController, + createBottomSheetVisibilityTracker, +} from "./visibility-tracker"; type GorhomBottomSheetModalMethods = ElementRef; @@ -68,88 +72,31 @@ export function useIsolatedBottomSheetVisibility({ isEnabled?: boolean; onClose: () => void; }) { - const sheetRef = useRef(null); - const visibleRef = useRef(visible); - const isEnabledRef = useRef(isEnabled); - const isPresentedRef = useRef(false); - const hasNotifiedCloseRef = useRef(false); + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; - visibleRef.current = visible; - isEnabledRef.current = isEnabled; - - const presentSheet = useCallback((sheet: IsolatedBottomSheetModalRef) => { - if (isPresentedRef.current) { - return; - } - - isPresentedRef.current = true; - hasNotifiedCloseRef.current = false; - sheet.present(); - }, []); - - const dismissSheet = useCallback((sheet: IsolatedBottomSheetModalRef) => { - if (!isPresentedRef.current) { - return; - } - - isPresentedRef.current = false; - sheet.dismiss(); - }, []); - - const notifyClose = useCallback(() => { - if (hasNotifiedCloseRef.current) { - return; - } - - hasNotifiedCloseRef.current = true; - onClose(); - }, [onClose]); - - const handleSheetDismiss = useCallback(() => { - isPresentedRef.current = false; - if (visibleRef.current) { - notifyClose(); - return; - } - hasNotifiedCloseRef.current = false; - }, [notifyClose]); - - const handleSheetChange = useCallback( - (index: number) => { - if (index === -1 && visibleRef.current) { - notifyClose(); - } - }, - [notifyClose], + const tracker = useMemo( + () => createBottomSheetVisibilityTracker({ onClose: () => onCloseRef.current() }), + [], ); const setSheetRef = useCallback( (instance: IsolatedBottomSheetModalRef | null) => { - sheetRef.current = instance; - if (instance && visibleRef.current && isEnabledRef.current !== false) { - presentSheet(instance); - } + tracker.attachController(instance as BottomSheetController | null); }, - [presentSheet], + [tracker], ); + const handleSheetChange = useCallback( + (index: number) => tracker.handleSheetIndexChange(index), + [tracker], + ); + + const handleSheetDismiss = useCallback(() => tracker.handleSheetDismiss(), [tracker]); + useEffect(() => { - if (isEnabled === false) return; - - const sheet = sheetRef.current; - if (visible) { - if (!sheet) { - return; - } - - presentSheet(sheet); - return; - } - - if (sheet) { - dismissSheet(sheet); - } - }, [dismissSheet, isEnabled, presentSheet, visible]); + tracker.syncDesired({ visible, isEnabled }); + }, [isEnabled, tracker, visible]); return { sheetRef: setSheetRef, diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.test.ts b/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.test.ts new file mode 100644 index 000000000..762403a1d --- /dev/null +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "vitest"; +import { + type BottomSheetController, + createBottomSheetVisibilityTracker, +} from "./visibility-tracker"; + +interface FakeBottomSheetEvent { + type: "present" | "dismiss"; +} + +class FakeBottomSheet implements BottomSheetController { + events: FakeBottomSheetEvent[] = []; + + present(): void { + this.events.push({ type: "present" }); + } + + dismiss(): void { + this.events.push({ type: "dismiss" }); + } +} + +function setup() { + const sheet = new FakeBottomSheet(); + let closeCount = 0; + const tracker = createBottomSheetVisibilityTracker({ + onClose: () => { + closeCount += 1; + }, + }); + return { + sheet, + tracker, + closeCount: () => closeCount, + }; +} + +describe("bottom sheet visibility tracker", () => { + it("presents once when the sheet becomes visible and dismisses when it goes back to hidden", () => { + const { sheet, tracker } = setup(); + tracker.attachController(sheet); + + tracker.syncDesired({ visible: false }); + expect(sheet.events).toEqual([]); + + tracker.syncDesired({ visible: true }); + expect(sheet.events).toEqual([{ type: "present" }]); + + tracker.syncDesired({ visible: true }); + expect(sheet.events).toEqual([{ type: "present" }]); + + tracker.syncDesired({ visible: false }); + expect(sheet.events).toEqual([{ type: "present" }, { type: "dismiss" }]); + }); + + it("waits to present until the sheet controller becomes available", () => { + const { sheet, tracker } = setup(); + tracker.syncDesired({ visible: true }); + expect(sheet.events).toEqual([]); + + tracker.attachController(sheet); + expect(sheet.events).toEqual([{ type: "present" }]); + }); + + it("does not present while disabled", () => { + const { sheet, tracker } = setup(); + tracker.attachController(sheet); + + tracker.syncDesired({ visible: true, isEnabled: false }); + expect(sheet.events).toEqual([]); + }); + + it("only reports a user close when the sheet was visible", () => { + const { sheet, tracker, closeCount } = setup(); + tracker.attachController(sheet); + tracker.syncDesired({ visible: true }); + + tracker.handleSheetIndexChange(-1); + expect(closeCount()).toBe(1); + + tracker.syncDesired({ visible: false }); + tracker.handleSheetIndexChange(-1); + expect(closeCount()).toBe(1); + }); + + it("reports a dismiss while visible as a close request", () => { + const { sheet, tracker, closeCount } = setup(); + tracker.attachController(sheet); + tracker.syncDesired({ visible: true }); + + tracker.handleSheetDismiss(); + + expect(closeCount()).toBe(1); + }); + + it("deduplicates close notifications from change and dismiss callbacks", () => { + const { sheet, tracker, closeCount } = setup(); + tracker.attachController(sheet); + tracker.syncDesired({ visible: true }); + + tracker.handleSheetIndexChange(-1); + tracker.handleSheetDismiss(); + + expect(closeCount()).toBe(1); + }); + + it("allows a new close notification after re-presenting the sheet", () => { + const { sheet, tracker, closeCount } = setup(); + tracker.attachController(sheet); + tracker.syncDesired({ visible: true }); + + tracker.handleSheetIndexChange(-1); + expect(closeCount()).toBe(1); + + tracker.syncDesired({ visible: false }); + tracker.syncDesired({ visible: true }); + + tracker.handleSheetIndexChange(-1); + expect(closeCount()).toBe(2); + }); +}); diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.ts b/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.ts new file mode 100644 index 000000000..a78a34e4a --- /dev/null +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.ts @@ -0,0 +1,77 @@ +export interface BottomSheetController { + present(): void; + dismiss(): void; +} + +export interface BottomSheetVisibilityInput { + visible: boolean; + isEnabled?: boolean; +} + +export interface BottomSheetVisibilityTracker { + attachController(controller: BottomSheetController | null): void; + syncDesired(input: BottomSheetVisibilityInput): void; + handleSheetIndexChange(index: number): void; + handleSheetDismiss(): void; +} + +export function createBottomSheetVisibilityTracker(opts: { + onClose: () => void; +}): BottomSheetVisibilityTracker { + let controller: BottomSheetController | null = null; + let visible = false; + let isEnabled: boolean | undefined; + let isPresented = false; + let hasNotifiedClose = false; + + function present(): void { + if (!controller || isPresented) return; + isPresented = true; + hasNotifiedClose = false; + controller.present(); + } + + function dismiss(): void { + if (!controller || !isPresented) return; + isPresented = false; + controller.dismiss(); + } + + function notifyClose(): void { + if (hasNotifiedClose) return; + hasNotifiedClose = true; + opts.onClose(); + } + + return { + attachController(next) { + controller = next; + if (next && visible && isEnabled !== false) { + present(); + } + }, + syncDesired(next) { + visible = next.visible; + isEnabled = next.isEnabled; + if (isEnabled === false) return; + if (visible) { + present(); + return; + } + dismiss(); + }, + handleSheetIndexChange(index) { + if (index === -1 && visible) { + notifyClose(); + } + }, + handleSheetDismiss() { + isPresented = false; + if (visible) { + notifyClose(); + return; + } + hasNotifiedClose = false; + }, + }; +} diff --git a/packages/app/src/desktop/attachments/desktop-attachment-bridge.ts b/packages/app/src/desktop/attachments/desktop-attachment-bridge.ts new file mode 100644 index 000000000..63c869a59 --- /dev/null +++ b/packages/app/src/desktop/attachments/desktop-attachment-bridge.ts @@ -0,0 +1,59 @@ +import type { AttachmentMetadata } from "@/attachments/types"; +import { + copyDesktopAttachmentFile, + deleteDesktopAttachmentFile, + garbageCollectDesktopAttachmentFiles, + writeDesktopAttachmentBase64, + writeDesktopAttachmentBytes, +} from "@/desktop/attachments/desktop-file-commands"; +import { + createBrowserObjectUrlMinter, + createDesktopFileReader, + createDesktopPreviewUrlResolver, + readDesktopFileBase64, +} from "@/desktop/attachments/desktop-preview-url"; + +export interface DesktopAttachmentFileResult { + path: string; + byteSize: number; +} + +export interface DesktopAttachmentBridge { + copyFile(input: { + attachmentId: string; + sourcePath: string; + extension?: string | null; + }): Promise; + writeBase64(input: { + attachmentId: string; + base64: string; + extension?: string | null; + }): Promise; + writeBytes(input: { + attachmentId: string; + bytes: Uint8Array; + extension?: string | null; + }): Promise; + deleteFile(input: { path: string }): Promise; + garbageCollect(input: { referencedIds: readonly string[] }): Promise; + readFileBase64(path: string): Promise; + resolvePreviewUrl(attachment: AttachmentMetadata): Promise; + releasePreviewUrl(input: { url: string }): Promise; +} + +export function createDesktopAttachmentBridge(): DesktopAttachmentBridge { + const previewUrls = createDesktopPreviewUrlResolver({ + reader: createDesktopFileReader(), + objectUrls: createBrowserObjectUrlMinter(), + }); + return { + copyFile: copyDesktopAttachmentFile, + writeBase64: writeDesktopAttachmentBase64, + writeBytes: writeDesktopAttachmentBytes, + deleteFile: deleteDesktopAttachmentFile, + garbageCollect: garbageCollectDesktopAttachmentFiles, + readFileBase64: readDesktopFileBase64, + resolvePreviewUrl: (attachment) => previewUrls.resolve(attachment), + releasePreviewUrl: (input) => previewUrls.release(input), + }; +} diff --git a/packages/app/src/desktop/attachments/desktop-attachment-store.test.ts b/packages/app/src/desktop/attachments/desktop-attachment-store.test.ts index 4903380e1..aef1099a6 100644 --- a/packages/app/src/desktop/attachments/desktop-attachment-store.test.ts +++ b/packages/app/src/desktop/attachments/desktop-attachment-store.test.ts @@ -1,63 +1,12 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { createDesktopAttachmentStore } from "./desktop-attachment-store"; - -const { - copyDesktopAttachmentFileMock, - writeDesktopAttachmentBase64Mock, - writeDesktopAttachmentBytesMock, - deleteDesktopAttachmentFileMock, - garbageCollectDesktopAttachmentFilesMock, - readDesktopFileBase64Mock, - resolveDesktopPreviewUrlMock, - releaseDesktopPreviewUrlMock, -} = vi.hoisted(() => ({ - copyDesktopAttachmentFileMock: vi.fn(async () => ({ - path: "/managed/att_1.png", - byteSize: 4, - })), - writeDesktopAttachmentBase64Mock: vi.fn(async () => ({ - path: "/managed/att_2.png", - byteSize: 4, - })), - writeDesktopAttachmentBytesMock: vi.fn(async () => ({ - path: "/managed/att_bytes.png", - byteSize: 4, - })), - deleteDesktopAttachmentFileMock: vi.fn(async () => true), - garbageCollectDesktopAttachmentFilesMock: vi.fn(async () => 0), - readDesktopFileBase64Mock: vi.fn(async () => "AAECAw=="), - resolveDesktopPreviewUrlMock: vi.fn(async () => "blob:test"), - releaseDesktopPreviewUrlMock: vi.fn(async () => {}), -})); - -vi.mock("./desktop-file-commands", () => ({ - copyDesktopAttachmentFile: copyDesktopAttachmentFileMock, - writeDesktopAttachmentBase64: writeDesktopAttachmentBase64Mock, - writeDesktopAttachmentBytes: writeDesktopAttachmentBytesMock, - deleteDesktopAttachmentFile: deleteDesktopAttachmentFileMock, - garbageCollectDesktopAttachmentFiles: garbageCollectDesktopAttachmentFilesMock, -})); - -vi.mock("./desktop-preview-url", () => ({ - readDesktopFileBase64: readDesktopFileBase64Mock, - resolveDesktopPreviewUrl: resolveDesktopPreviewUrlMock, - releaseDesktopPreviewUrl: releaseDesktopPreviewUrlMock, -})); +import { createFakeDesktopAttachmentBridge } from "./test-utils/fake-desktop-attachment-bridge"; describe("desktop attachment store", () => { - beforeEach(() => { - copyDesktopAttachmentFileMock.mockClear(); - writeDesktopAttachmentBase64Mock.mockClear(); - writeDesktopAttachmentBytesMock.mockClear(); - deleteDesktopAttachmentFileMock.mockClear(); - garbageCollectDesktopAttachmentFilesMock.mockClear(); - readDesktopFileBase64Mock.mockClear(); - resolveDesktopPreviewUrlMock.mockClear(); - releaseDesktopPreviewUrlMock.mockClear(); - }); - it("saves dropped file paths as desktop-file metadata", async () => { - const store = createDesktopAttachmentStore(); + const fake = createFakeDesktopAttachmentBridge(); + const store = createDesktopAttachmentStore(fake.bridge); + const attachment = await store.save({ id: "att_1", mimeType: "image/png", @@ -67,17 +16,25 @@ describe("desktop attachment store", () => { }, }); - expect(copyDesktopAttachmentFileMock).toHaveBeenCalledWith({ - attachmentId: "att_1", - sourcePath: "/Users/test/Desktop/image.png", - extension: ".png", + expect(fake.savedEntries).toEqual([ + { + attachmentId: "att_1", + path: "/managed/att_1.png", + byteSize: 4, + extension: ".png", + source: { kind: "copy", sourcePath: "/Users/test/Desktop/image.png" }, + }, + ]); + expect(attachment).toMatchObject({ + storageType: "desktop-file", + storageKey: "/managed/att_1.png", }); - expect(attachment.storageType).toBe("desktop-file"); - expect(attachment.storageKey).toBe("/managed/att_1.png"); }); it("saves blob/data-url sources via desktop filesystem writes", async () => { - const store = createDesktopAttachmentStore(); + const fake = createFakeDesktopAttachmentBridge(); + const store = createDesktopAttachmentStore(fake.bridge); + await store.save({ id: "att_2", source: { @@ -86,16 +43,22 @@ describe("desktop attachment store", () => { }, }); - expect(writeDesktopAttachmentBase64Mock).toHaveBeenCalledWith({ - attachmentId: "att_2", - base64: "AAECAw==", - extension: ".png", - }); + expect(fake.savedEntries).toEqual([ + { + attachmentId: "att_2", + path: "/managed/att_2.png", + byteSize: 4, + extension: ".png", + source: { kind: "base64", base64: "AAECAw==" }, + }, + ]); }); it("saves raw byte sources via desktop filesystem writes", async () => { - const store = createDesktopAttachmentStore(); + const fake = createFakeDesktopAttachmentBridge(); + const store = createDesktopAttachmentStore(fake.bridge); const bytes = new Uint8Array([0, 1, 2, 3]); + const attachment = await store.save({ id: "att_bytes", mimeType: "image/png", @@ -106,12 +69,15 @@ describe("desktop attachment store", () => { }, }); - expect(writeDesktopAttachmentBytesMock).toHaveBeenCalledWith({ - attachmentId: "att_bytes", - bytes, - extension: ".png", - }); - expect(writeDesktopAttachmentBase64Mock).not.toHaveBeenCalled(); + expect(fake.savedEntries).toEqual([ + { + attachmentId: "att_bytes", + path: "/managed/att_bytes.png", + byteSize: 4, + extension: ".png", + source: { kind: "bytes", bytes }, + }, + ]); expect(attachment).toMatchObject({ id: "att_bytes", mimeType: "image/png", @@ -123,7 +89,8 @@ describe("desktop attachment store", () => { }); it("delegates encode/preview/delete/gc to desktop command path", async () => { - const store = createDesktopAttachmentStore(); + const fake = createFakeDesktopAttachmentBridge(); + const store = createDesktopAttachmentStore(fake.bridge); const attachment = { id: "att_3", mimeType: "image/jpeg", @@ -138,14 +105,10 @@ describe("desktop attachment store", () => { await store.delete({ attachment }); await store.garbageCollect({ referencedIds: new Set(["att_3"]) }); - expect(readDesktopFileBase64Mock).toHaveBeenCalledWith("/managed/att_3.jpg"); - expect(resolveDesktopPreviewUrlMock).toHaveBeenCalledWith(attachment); - expect(releaseDesktopPreviewUrlMock).toHaveBeenCalledWith({ url: "blob:test" }); - expect(deleteDesktopAttachmentFileMock).toHaveBeenCalledWith({ - path: "/managed/att_3.jpg", - }); - expect(garbageCollectDesktopAttachmentFilesMock).toHaveBeenCalledWith({ - referencedIds: ["att_3"], - }); + expect(fake.readBase64Calls).toEqual(["/managed/att_3.jpg"]); + expect(fake.resolvedPreviewUrls).toEqual([attachment]); + expect(fake.releasedPreviewUrls).toEqual(["blob:test"]); + expect(fake.deletedPaths).toEqual(["/managed/att_3.jpg"]); + expect(fake.garbageCollections).toEqual([{ referencedIds: ["att_3"] }]); }); }); diff --git a/packages/app/src/desktop/attachments/desktop-attachment-store.ts b/packages/app/src/desktop/attachments/desktop-attachment-store.ts index 579590e75..967f31469 100644 --- a/packages/app/src/desktop/attachments/desktop-attachment-store.ts +++ b/packages/app/src/desktop/attachments/desktop-attachment-store.ts @@ -7,18 +7,7 @@ import { normalizeMimeType, parseDataUrl, } from "@/attachments/utils"; -import { - copyDesktopAttachmentFile, - deleteDesktopAttachmentFile, - garbageCollectDesktopAttachmentFiles, - writeDesktopAttachmentBase64, - writeDesktopAttachmentBytes, -} from "@/desktop/attachments/desktop-file-commands"; -import { - readDesktopFileBase64, - releaseDesktopPreviewUrl, - resolveDesktopPreviewUrl, -} from "@/desktop/attachments/desktop-preview-url"; +import type { DesktopAttachmentBridge } from "./desktop-attachment-bridge"; const IMAGE_EXTENSION_BY_MIME_TYPE: Record = { "image/png": ".png", @@ -77,12 +66,15 @@ function toDesktopMetadata(input: { }; } -async function saveDesktopAttachmentFromFileUri(input: { - id: string; - uri: string; - mimeType?: string; - fileName?: string | null; -}): Promise { +async function saveDesktopAttachmentFromFileUri( + bridge: DesktopAttachmentBridge, + input: { + id: string; + uri: string; + mimeType?: string; + fileName?: string | null; + }, +): Promise { const sourcePath = fileUriToPath(input.uri); const fileName = input.fileName ?? inferFileNameFromPath(sourcePath); const mimeType = normalizeMimeType(input.mimeType); @@ -92,7 +84,7 @@ async function saveDesktopAttachmentFromFileUri(input: { mimeType, }); - const result = await copyDesktopAttachmentFile({ + const result = await bridge.copyFile({ attachmentId: input.id, sourcePath, extension, @@ -107,18 +99,21 @@ async function saveDesktopAttachmentFromFileUri(input: { }); } -async function saveDesktopAttachmentFromBase64(input: { - id: string; - base64: string; - mimeType: string; - fileName?: string | null; -}): Promise { +async function saveDesktopAttachmentFromBase64( + bridge: DesktopAttachmentBridge, + input: { + id: string; + base64: string; + mimeType: string; + fileName?: string | null; + }, +): Promise { const extension = extensionForAttachment({ fileName: input.fileName, mimeType: input.mimeType, }); - const result = await writeDesktopAttachmentBase64({ + const result = await bridge.writeBase64({ attachmentId: input.id, base64: input.base64, extension, @@ -133,18 +128,21 @@ async function saveDesktopAttachmentFromBase64(input: { }); } -async function saveDesktopAttachmentFromBytes(input: { - id: string; - bytes: Uint8Array; - mimeType: string; - fileName?: string | null; -}): Promise { +async function saveDesktopAttachmentFromBytes( + bridge: DesktopAttachmentBridge, + input: { + id: string; + bytes: Uint8Array; + mimeType: string; + fileName?: string | null; + }, +): Promise { const extension = extensionForAttachment({ fileName: input.fileName, mimeType: input.mimeType, }); - const result = await writeDesktopAttachmentBytes({ + const result = await bridge.writeBytes({ attachmentId: input.id, bytes: input.bytes, extension, @@ -165,7 +163,7 @@ function assertDesktopAttachment(attachment: AttachmentMetadata): void { } } -export function createDesktopAttachmentStore(): AttachmentStore { +export function createDesktopAttachmentStore(bridge: DesktopAttachmentBridge): AttachmentStore { return { storageType: "desktop-file", @@ -174,7 +172,7 @@ export function createDesktopAttachmentStore(): AttachmentStore { const fileName = input.fileName ?? null; if (input.source.kind === "file_uri") { - return await saveDesktopAttachmentFromFileUri({ + return await saveDesktopAttachmentFromFileUri(bridge, { id, uri: input.source.uri, mimeType: input.mimeType, @@ -185,7 +183,7 @@ export function createDesktopAttachmentStore(): AttachmentStore { if (input.source.kind === "data_url") { const parsed = parseDataUrl(input.source.dataUrl); const mimeType = normalizeMimeType(input.mimeType ?? parsed.mimeType); - return await saveDesktopAttachmentFromBase64({ + return await saveDesktopAttachmentFromBase64(bridge, { id, base64: parsed.base64, mimeType, @@ -195,7 +193,7 @@ export function createDesktopAttachmentStore(): AttachmentStore { if (input.source.kind === "bytes") { const mimeType = normalizeMimeType(input.mimeType); - return await saveDesktopAttachmentFromBytes({ + return await saveDesktopAttachmentFromBytes(bridge, { id, bytes: input.source.bytes, mimeType, @@ -205,7 +203,7 @@ export function createDesktopAttachmentStore(): AttachmentStore { const mimeType = normalizeMimeType(input.mimeType ?? input.source.blob.type); const base64 = await blobToBase64(input.source.blob); - return await saveDesktopAttachmentFromBase64({ + return await saveDesktopAttachmentFromBase64(bridge, { id, base64, mimeType, @@ -215,26 +213,26 @@ export function createDesktopAttachmentStore(): AttachmentStore { async encodeBase64({ attachment }): Promise { assertDesktopAttachment(attachment); - return await readDesktopFileBase64(attachment.storageKey); + return await bridge.readFileBase64(attachment.storageKey); }, async resolvePreviewUrl({ attachment }): Promise { assertDesktopAttachment(attachment); - return await resolveDesktopPreviewUrl(attachment); + return await bridge.resolvePreviewUrl(attachment); }, async releasePreviewUrl({ attachment, url }): Promise { assertDesktopAttachment(attachment); - await releaseDesktopPreviewUrl({ url }); + await bridge.releasePreviewUrl({ url }); }, async delete({ attachment }): Promise { assertDesktopAttachment(attachment); - await deleteDesktopAttachmentFile({ path: attachment.storageKey }); + await bridge.deleteFile({ path: attachment.storageKey }); }, async garbageCollect({ referencedIds }): Promise { - await garbageCollectDesktopAttachmentFiles({ + await bridge.garbageCollect({ referencedIds: Array.from(referencedIds), }); }, diff --git a/packages/app/src/desktop/attachments/desktop-preview-url.test.ts b/packages/app/src/desktop/attachments/desktop-preview-url.test.ts index a18cd4ca7..6c0c4352a 100644 --- a/packages/app/src/desktop/attachments/desktop-preview-url.test.ts +++ b/packages/app/src/desktop/attachments/desktop-preview-url.test.ts @@ -1,66 +1,119 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -const { invokeDesktopCommandMock } = vi.hoisted(() => ({ - invokeDesktopCommandMock: vi.fn(async () => "AAECAw=="), -})); - -vi.mock("@/desktop/electron/invoke", () => ({ - invokeDesktopCommand: invokeDesktopCommandMock, -})); - +import { describe, expect, it } from "vitest"; +import type { AttachmentMetadata } from "@/attachments/types"; import { - __desktopPreviewUrlTestUtils, - releaseDesktopPreviewUrl, - resolveDesktopPreviewUrl, + createDesktopPreviewUrlResolver, + type DesktopFileReader, + type ObjectUrlMinter, } from "./desktop-preview-url"; +class FakeDesktopReader implements DesktopFileReader { + readonly reads: string[] = []; + constructor(private readonly files: Record) {} + + async readFileBase64(storageKey: string): Promise { + this.reads.push(storageKey); + const base64 = this.files[storageKey]; + if (base64 === undefined) { + throw new Error(`FakeDesktopReader: no file registered for ${storageKey}`); + } + return base64; + } +} + +interface FakeMintedUrl { + url: string; + mimeType: string; + base64: string; +} + +class FakeObjectUrls implements ObjectUrlMinter { + readonly minted: FakeMintedUrl[] = []; + readonly revoked: string[] = []; + private nextId = 1; + private readonly supportsCreate: boolean; + + constructor(options: { supportsCreate?: boolean } = {}) { + this.supportsCreate = options.supportsCreate ?? true; + } + + tryCreate(input: { mimeType: string; base64: string }): string | null { + if (!this.supportsCreate) { + return null; + } + const url = `blob:fake-${this.nextId++}`; + this.minted.push({ url, ...input }); + return url; + } + + revoke(url: string): void { + this.revoked.push(url); + } +} + +function attachment(overrides: Partial = {}): AttachmentMetadata { + return { + id: "att-1", + mimeType: "image/png", + storageType: "desktop-file", + storageKey: "/tmp/att-1.png", + fileName: null, + byteSize: null, + createdAt: 0, + ...overrides, + }; +} + describe("desktop preview URLs", () => { - const createObjectURL = vi.fn(() => "blob:desktop-preview-1"); - const revokeObjectURL = vi.fn(); + it("mints an object URL from the desktop file's base64 bytes", async () => { + const reader = new FakeDesktopReader({ "/tmp/att-1.png": "AAECAw==" }); + const objectUrls = new FakeObjectUrls(); + const resolver = createDesktopPreviewUrlResolver({ reader, objectUrls }); - beforeEach(() => { - vi.stubGlobal("URL", { - createObjectURL, - revokeObjectURL, - }); + const url = await resolver.resolve(attachment()); + + expect(url).toBe("blob:fake-1"); + expect(reader.reads).toEqual(["/tmp/att-1.png"]); + expect(objectUrls.minted).toEqual([ + { url: "blob:fake-1", mimeType: "image/png", base64: "AAECAw==" }, + ]); }); - afterEach(() => { - createObjectURL.mockClear(); - revokeObjectURL.mockClear(); - invokeDesktopCommandMock.mockClear(); - __desktopPreviewUrlTestUtils.clearActiveObjectUrls(); - vi.unstubAllGlobals(); + it("falls back to a data URL when the host cannot mint object URLs", async () => { + const reader = new FakeDesktopReader({ "/tmp/att-2.jpg": "AAECAw==" }); + const objectUrls = new FakeObjectUrls({ supportsCreate: false }); + const resolver = createDesktopPreviewUrlResolver({ reader, objectUrls }); + + const url = await resolver.resolve( + attachment({ id: "att-2", mimeType: "image/jpeg", storageKey: "/tmp/att-2.jpg" }), + ); + + expect(url).toBe("data:image/jpeg;base64,AAECAw=="); + expect(objectUrls.revoked).toEqual([]); }); - it("resolves renderer-safe blob URLs for desktop attachments", async () => { - const url = await resolveDesktopPreviewUrl({ - id: "att-1", - mimeType: "image/png", - storageType: "desktop-file", - storageKey: "/tmp/att-1.png", - createdAt: Date.now(), - }); + it("revokes only object URLs it minted", async () => { + const reader = new FakeDesktopReader({ "/tmp/att-3.jpg": "AAECAw==" }); + const objectUrls = new FakeObjectUrls(); + const resolver = createDesktopPreviewUrlResolver({ reader, objectUrls }); - expect(invokeDesktopCommandMock).toHaveBeenCalledWith("read_file_base64", { - path: "/tmp/att-1.png", - }); - expect(url).toBe("blob:desktop-preview-1"); - expect(url.startsWith("asset://")).toBe(false); - expect(createObjectURL).toHaveBeenCalledTimes(1); + const url = await resolver.resolve( + attachment({ id: "att-3", mimeType: "image/jpeg", storageKey: "/tmp/att-3.jpg" }), + ); + await resolver.release({ url }); + await resolver.release({ url: "blob:never-minted" }); + + expect(objectUrls.revoked).toEqual([url]); }); - it("releases blob URLs via URL.revokeObjectURL", async () => { - const url = await resolveDesktopPreviewUrl({ - id: "att-2", - mimeType: "image/jpeg", - storageType: "desktop-file", - storageKey: "/tmp/att-2.jpg", - createdAt: Date.now(), - }); + it("only revokes a minted URL once across repeated release calls", async () => { + const reader = new FakeDesktopReader({ "/tmp/att-4.png": "AAECAw==" }); + const objectUrls = new FakeObjectUrls(); + const resolver = createDesktopPreviewUrlResolver({ reader, objectUrls }); - await releaseDesktopPreviewUrl({ url }); + const url = await resolver.resolve(attachment({ id: "att-4", storageKey: "/tmp/att-4.png" })); + await resolver.release({ url }); + await resolver.release({ url }); - expect(revokeObjectURL).toHaveBeenCalledWith("blob:desktop-preview-1"); + expect(objectUrls.revoked).toEqual([url]); }); }); diff --git a/packages/app/src/desktop/attachments/desktop-preview-url.ts b/packages/app/src/desktop/attachments/desktop-preview-url.ts index d3efb3122..942b8033b 100644 --- a/packages/app/src/desktop/attachments/desktop-preview-url.ts +++ b/packages/app/src/desktop/attachments/desktop-preview-url.ts @@ -3,11 +3,19 @@ import { fileUriToPath } from "@/attachments/utils"; import { invokeDesktopCommand } from "@/desktop/electron/invoke"; import { Buffer } from "buffer"; -function base64ToUint8Array(base64: string): Uint8Array { - return new Uint8Array(Buffer.from(base64, "base64")); +export interface DesktopFileReader { + readFileBase64(storageKey: string): Promise; } -const activeObjectUrls = new Set(); +export interface ObjectUrlMinter { + tryCreate(input: { mimeType: string; base64: string }): string | null; + revoke(url: string): void; +} + +export interface DesktopPreviewUrlResolver { + resolve(attachment: AttachmentMetadata): Promise; + release(input: { url: string }): Promise; +} export async function readDesktopFileBase64(pathOrUri: string): Promise { return await invokeDesktopCommand("read_file_base64", { @@ -15,36 +23,55 @@ export async function readDesktopFileBase64(pathOrUri: string): Promise }); } -export async function resolveDesktopPreviewUrl(attachment: AttachmentMetadata): Promise { - const base64 = await readDesktopFileBase64(attachment.storageKey); - - if (typeof URL !== "undefined" && typeof URL.createObjectURL === "function") { - const bytes = base64ToUint8Array(base64); - const buffer = new ArrayBuffer(bytes.byteLength); - new Uint8Array(buffer).set(bytes); - const blob = new Blob([buffer], { type: attachment.mimeType }); - const objectUrl = URL.createObjectURL(blob); - activeObjectUrls.add(objectUrl); - return objectUrl; - } - - return `data:${attachment.mimeType};base64,${base64}`; +export function createDesktopFileReader(): DesktopFileReader { + return { readFileBase64: readDesktopFileBase64 }; } -export async function releaseDesktopPreviewUrl(input: { url: string }): Promise { - if (!activeObjectUrls.has(input.url)) { - return; - } - activeObjectUrls.delete(input.url); - - if (typeof URL !== "undefined" && typeof URL.revokeObjectURL === "function") { - URL.revokeObjectURL(input.url); - } +export function createBrowserObjectUrlMinter(): ObjectUrlMinter { + return { + tryCreate({ mimeType, base64 }) { + if (typeof URL === "undefined" || typeof URL.createObjectURL !== "function") { + return null; + } + const bytes = base64ToUint8Array(base64); + const buffer = new ArrayBuffer(bytes.byteLength); + new Uint8Array(buffer).set(bytes); + const blob = new Blob([buffer], { type: mimeType }); + return URL.createObjectURL(blob); + }, + revoke(url) { + if (typeof URL !== "undefined" && typeof URL.revokeObjectURL === "function") { + URL.revokeObjectURL(url); + } + }, + }; } -export const __desktopPreviewUrlTestUtils = { - base64ToUint8Array, - clearActiveObjectUrls: () => { - activeObjectUrls.clear(); - }, -}; +export function createDesktopPreviewUrlResolver(deps: { + reader: DesktopFileReader; + objectUrls: ObjectUrlMinter; +}): DesktopPreviewUrlResolver { + const tracked = new Set(); + return { + async resolve(attachment) { + const base64 = await deps.reader.readFileBase64(attachment.storageKey); + const url = deps.objectUrls.tryCreate({ mimeType: attachment.mimeType, base64 }); + if (url === null) { + return `data:${attachment.mimeType};base64,${base64}`; + } + tracked.add(url); + return url; + }, + async release({ url }) { + if (!tracked.has(url)) { + return; + } + tracked.delete(url); + deps.objectUrls.revoke(url); + }, + }; +} + +function base64ToUint8Array(base64: string): Uint8Array { + return new Uint8Array(Buffer.from(base64, "base64")); +} diff --git a/packages/app/src/desktop/attachments/test-utils/fake-desktop-attachment-bridge.ts b/packages/app/src/desktop/attachments/test-utils/fake-desktop-attachment-bridge.ts new file mode 100644 index 000000000..447424008 --- /dev/null +++ b/packages/app/src/desktop/attachments/test-utils/fake-desktop-attachment-bridge.ts @@ -0,0 +1,103 @@ +import type { AttachmentMetadata } from "@/attachments/types"; +import type { + DesktopAttachmentBridge, + DesktopAttachmentFileResult, +} from "@/desktop/attachments/desktop-attachment-bridge"; + +export interface FakeDesktopAttachmentEntry { + attachmentId: string; + path: string; + byteSize: number; + extension: string | null; + source: + | { kind: "copy"; sourcePath: string } + | { kind: "base64"; base64: string } + | { kind: "bytes"; bytes: Uint8Array }; +} + +export interface FakeDesktopAttachmentBridge { + bridge: DesktopAttachmentBridge; + savedEntries: FakeDesktopAttachmentEntry[]; + deletedPaths: string[]; + garbageCollections: Array<{ referencedIds: readonly string[] }>; + readBase64Calls: string[]; + resolvedPreviewUrls: AttachmentMetadata[]; + releasedPreviewUrls: string[]; +} + +export function createFakeDesktopAttachmentBridge(): FakeDesktopAttachmentBridge { + const savedEntries: FakeDesktopAttachmentEntry[] = []; + const deletedPaths: string[] = []; + const garbageCollections: Array<{ referencedIds: readonly string[] }> = []; + const readBase64Calls: string[] = []; + const resolvedPreviewUrls: AttachmentMetadata[] = []; + const releasedPreviewUrls: string[] = []; + + function buildPath(attachmentId: string, extension: string | null | undefined): string { + return `/managed/${attachmentId}${extension ?? ""}`; + } + + function record(entry: FakeDesktopAttachmentEntry): DesktopAttachmentFileResult { + savedEntries.push(entry); + return { path: entry.path, byteSize: entry.byteSize }; + } + + const bridge: DesktopAttachmentBridge = { + async copyFile({ attachmentId, sourcePath, extension }) { + return record({ + attachmentId, + path: buildPath(attachmentId, extension), + byteSize: 4, + extension: extension ?? null, + source: { kind: "copy", sourcePath }, + }); + }, + async writeBase64({ attachmentId, base64, extension }) { + return record({ + attachmentId, + path: buildPath(attachmentId, extension), + byteSize: 4, + extension: extension ?? null, + source: { kind: "base64", base64 }, + }); + }, + async writeBytes({ attachmentId, bytes, extension }) { + return record({ + attachmentId, + path: buildPath(attachmentId, extension), + byteSize: bytes.byteLength, + extension: extension ?? null, + source: { kind: "bytes", bytes }, + }); + }, + async deleteFile({ path }) { + deletedPaths.push(path); + return true; + }, + async garbageCollect({ referencedIds }) { + garbageCollections.push({ referencedIds }); + return 0; + }, + async readFileBase64(path) { + readBase64Calls.push(path); + return "AAECAw=="; + }, + async resolvePreviewUrl(attachment) { + resolvedPreviewUrls.push(attachment); + return "blob:test"; + }, + async releasePreviewUrl({ url }) { + releasedPreviewUrls.push(url); + }, + }; + + return { + bridge, + savedEntries, + deletedPaths, + garbageCollections, + readBase64Calls, + resolvedPreviewUrls, + releasedPreviewUrls, + }; +} diff --git a/packages/app/src/desktop/daemon/desktop-daemon-transport.test.ts b/packages/app/src/desktop/daemon/desktop-daemon-transport.test.ts index f999a1a13..a9fe2497c 100644 --- a/packages/app/src/desktop/daemon/desktop-daemon-transport.test.ts +++ b/packages/app/src/desktop/daemon/desktop-daemon-transport.test.ts @@ -1,135 +1,46 @@ -import { describe, expect, it, vi, beforeEach } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import { createDesktopLocalDaemonTransportFactory } from "./desktop-daemon-transport"; +import { createFakeLocalDaemonTransportRpc } from "./test-local-daemon-transport-rpc"; -const desktopDaemonMock = vi.hoisted(() => { - let eventHandler: - | ((payload: { - sessionId: string; - kind: "open" | "message" | "close" | "error"; - text?: string | null; - binaryBase64?: string | null; - code?: number | null; - reason?: string | null; - error?: string | null; - }) => void) - | null = null; - - const openLocalTransportSession = vi.fn<(...args: unknown[]) => Promise>(); - const listenToLocalTransportEvents = vi.fn( - async ( - handler: typeof eventHandler extends ((...args: infer A) => unknown) | null - ? (...args: A) => void - : never, - ) => { - eventHandler = handler; - return () => { - eventHandler = null; - }; - }, - ); - const sendLocalTransportMessage = vi.fn(async () => undefined); - const closeLocalTransportSession = vi.fn(async () => undefined); - - return { - openLocalTransportSession, - listenToLocalTransportEvents, - sendLocalTransportMessage, - closeLocalTransportSession, - emitEvent(payload: { - sessionId: string; - kind: "open" | "message" | "close" | "error"; - text?: string | null; - binaryBase64?: string | null; - code?: number | null; - reason?: string | null; - error?: string | null; - }) { - eventHandler?.(payload); - }, - }; -}); - -vi.mock("./desktop-daemon", () => ({ - openLocalTransportSession: desktopDaemonMock.openLocalTransportSession, - listenToLocalTransportEvents: desktopDaemonMock.listenToLocalTransportEvents, - sendLocalTransportMessage: desktopDaemonMock.sendLocalTransportMessage, - closeLocalTransportSession: desktopDaemonMock.closeLocalTransportSession, -})); +const LOCAL_URL = "paseo+local://socket?path=%2Ftmp%2Fpaseo.sock"; describe("desktop-daemon-transport", () => { - beforeEach(() => { - desktopDaemonMock.openLocalTransportSession.mockReset(); - desktopDaemonMock.listenToLocalTransportEvents.mockClear(); - desktopDaemonMock.sendLocalTransportMessage.mockClear(); - desktopDaemonMock.closeLocalTransportSession.mockClear(); - }); - it("emits open after the session resolves even if the rust open event raced earlier", async () => { - let resolveSession!: (sessionId: string) => void; - desktopDaemonMock.openLocalTransportSession.mockImplementation( - () => - new Promise((resolve) => { - resolveSession = resolve; - }), - ); - - const mod = await import("./desktop-daemon-transport"); - const transportFactory = mod.createDesktopLocalDaemonTransportFactory(); + const rpc = createFakeLocalDaemonTransportRpc(); + const transportFactory = createDesktopLocalDaemonTransportFactory(rpc); expect(transportFactory).not.toBeNull(); - const transport = transportFactory!({ - url: "paseo+local://socket?path=%2Ftmp%2Fpaseo.sock", - }); + const transport = transportFactory!({ url: LOCAL_URL }); const onOpen = vi.fn(); transport.onOpen(onOpen); - desktopDaemonMock.emitEvent({ - sessionId: "local-session-1", - kind: "open", - }); - + rpc.emitEvent({ sessionId: "local-session-1", kind: "open" }); expect(onOpen).not.toHaveBeenCalled(); - resolveSession("local-session-1"); + rpc.resolveOpen("local-session-1"); await Promise.resolve(); expect(onOpen).toHaveBeenCalledTimes(1); }); it("cleans up late async setup after the transport is closed", async () => { - let resolveSession!: (sessionId: string) => void; - let resolveListen!: (cleanup: () => void) => void; + const rpc = createFakeLocalDaemonTransportRpc(); const cleanup = vi.fn(); - desktopDaemonMock.openLocalTransportSession.mockImplementation( - () => - new Promise((resolve) => { - resolveSession = resolve; - }), - ); - desktopDaemonMock.listenToLocalTransportEvents.mockImplementation( - () => - new Promise<() => void>((resolve) => { - resolveListen = resolve; - }), - ); - - const mod = await import("./desktop-daemon-transport"); - const transportFactory = mod.createDesktopLocalDaemonTransportFactory(); + const transportFactory = createDesktopLocalDaemonTransportFactory(rpc); expect(transportFactory).not.toBeNull(); - const transport = transportFactory!({ - url: "paseo+local://socket?path=%2Ftmp%2Fpaseo.sock", - }); + const transport = transportFactory!({ url: LOCAL_URL }); transport.close(); - resolveSession("local-session-2"); - resolveListen(cleanup); + rpc.resolveOpen("local-session-2"); + rpc.resolveListen(cleanup); await Promise.resolve(); await Promise.resolve(); - expect(desktopDaemonMock.closeLocalTransportSession).toHaveBeenCalledWith("local-session-2"); + expect(rpc.closedSessions).toEqual(["local-session-2"]); expect(cleanup).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/app/src/desktop/daemon/desktop-daemon-transport.ts b/packages/app/src/desktop/daemon/desktop-daemon-transport.ts index b17ee6cc2..1d7894821 100644 --- a/packages/app/src/desktop/daemon/desktop-daemon-transport.ts +++ b/packages/app/src/desktop/daemon/desktop-daemon-transport.ts @@ -2,13 +2,11 @@ import type { DaemonTransport, DaemonTransportFactory, } from "@getpaseo/client/internal/daemon-client"; +import type { LocalTransportTarget } from "./desktop-daemon"; import { - closeLocalTransportSession, - listenToLocalTransportEvents, - openLocalTransportSession, - sendLocalTransportMessage, - type LocalTransportTarget, -} from "./desktop-daemon"; + defaultLocalDaemonTransportRpc, + type LocalDaemonTransportRpc, +} from "./local-daemon-transport-rpc"; const LOCAL_TRANSPORT_SCHEME = "paseo+local:"; @@ -52,7 +50,9 @@ function parseLocalDaemonTransportUrl(url: string): LocalTransportTarget { }; } -export function createDesktopLocalDaemonTransportFactory(): DaemonTransportFactory | null { +export function createDesktopLocalDaemonTransportFactory( + rpc: LocalDaemonTransportRpc = defaultLocalDaemonTransportRpc, +): DaemonTransportFactory | null { return ({ url }) => { const target = parseLocalDaemonTransportUrl(url); let sessionId: string | null = null; @@ -90,30 +90,31 @@ export function createDesktopLocalDaemonTransportFactory(): DaemonTransportFacto } }; - void listenToLocalTransportEvents((payload) => { - if (disposed || !sessionId || payload.sessionId !== sessionId) { - return; - } - if (payload.kind === "open") { - emitOpen(); - return; - } - if (payload.kind === "message") { - if (payload.text) { - emitMessage({ data: payload.text }); + void rpc + .listenToEvents((payload) => { + if (disposed || !sessionId || payload.sessionId !== sessionId) { return; } - if (payload.binaryBase64) { - emitMessage({ data: decodeBase64ToBytes(payload.binaryBase64) }); + if (payload.kind === "open") { + emitOpen(); + return; } - return; - } - if (payload.kind === "close") { - emitClose(payload); - return; - } - emitError(payload.error ?? "Local daemon transport error"); - }) + if (payload.kind === "message") { + if (payload.text) { + emitMessage({ data: payload.text }); + return; + } + if (payload.binaryBase64) { + emitMessage({ data: decodeBase64ToBytes(payload.binaryBase64) }); + } + return; + } + if (payload.kind === "close") { + emitClose(payload); + return; + } + emitError(payload.error ?? "Local daemon transport error"); + }) .then((cleanup) => { if (disposed) { cleanup(); @@ -126,10 +127,11 @@ export function createDesktopLocalDaemonTransportFactory(): DaemonTransportFacto emitError(error); }); - void openLocalTransportSession(target) + void rpc + .openSession(target) .then((id) => { if (disposed) { - void closeLocalTransportSession(id).catch((error) => emitError(error)); + void rpc.closeSession(id).catch((error) => emitError(error)); return; } sessionId = id; @@ -146,24 +148,20 @@ export function createDesktopLocalDaemonTransportFactory(): DaemonTransportFacto return; } if (typeof data === "string") { - void sendLocalTransportMessage({ sessionId, text: data }).catch((error) => - emitError(error), - ); + void rpc.sendMessage({ sessionId, text: data }).catch((error) => emitError(error)); return; } const binaryBase64 = encodeBinaryToBase64( data instanceof ArrayBuffer ? data : new Uint8Array(data), ); - void sendLocalTransportMessage({ sessionId, binaryBase64 }).catch((error) => - emitError(error), - ); + void rpc.sendMessage({ sessionId, binaryBase64 }).catch((error) => emitError(error)); }, close: () => { disposed = true; const currentSessionId = sessionId; sessionId = null; if (currentSessionId) { - void closeLocalTransportSession(currentSessionId).catch((error) => emitError(error)); + void rpc.closeSession(currentSessionId).catch((error) => emitError(error)); } unlisten?.(); unlisten = null; diff --git a/packages/app/src/desktop/daemon/local-daemon-transport-rpc.ts b/packages/app/src/desktop/daemon/local-daemon-transport-rpc.ts new file mode 100644 index 000000000..54961107e --- /dev/null +++ b/packages/app/src/desktop/daemon/local-daemon-transport-rpc.ts @@ -0,0 +1,31 @@ +import { + closeLocalTransportSession, + listenToLocalTransportEvents, + openLocalTransportSession, + sendLocalTransportMessage, + type LocalTransportTarget, +} from "./desktop-daemon"; + +export interface LocalDaemonTransportEvent { + sessionId: string; + kind: "open" | "message" | "close" | "error"; + text?: string | null; + binaryBase64?: string | null; + code?: number | null; + reason?: string | null; + error?: string | null; +} + +export interface LocalDaemonTransportRpc { + openSession(target: LocalTransportTarget): Promise; + listenToEvents(handler: (event: LocalDaemonTransportEvent) => void): Promise<() => void>; + sendMessage(input: { sessionId: string; text?: string; binaryBase64?: string }): Promise; + closeSession(sessionId: string): Promise; +} + +export const defaultLocalDaemonTransportRpc: LocalDaemonTransportRpc = { + openSession: openLocalTransportSession, + listenToEvents: listenToLocalTransportEvents, + sendMessage: sendLocalTransportMessage, + closeSession: closeLocalTransportSession, +}; diff --git a/packages/app/src/desktop/daemon/test-local-daemon-transport-rpc.ts b/packages/app/src/desktop/daemon/test-local-daemon-transport-rpc.ts new file mode 100644 index 000000000..8f2214b03 --- /dev/null +++ b/packages/app/src/desktop/daemon/test-local-daemon-transport-rpc.ts @@ -0,0 +1,86 @@ +import type { LocalTransportTarget } from "./desktop-daemon"; +import type { + LocalDaemonTransportEvent, + LocalDaemonTransportRpc, +} from "./local-daemon-transport-rpc"; + +export interface RecordedSend { + sessionId: string; + text?: string; + binaryBase64?: string; +} + +export interface FakeLocalDaemonTransportRpc extends LocalDaemonTransportRpc { + readonly openCalls: LocalTransportTarget[]; + readonly recordedSends: RecordedSend[]; + readonly closedSessions: string[]; + resolveOpen(sessionId: string): void; + rejectOpen(error: Error): void; + resolveListen(cleanup: () => void): void; + rejectListen(error: Error): void; + emitEvent(event: LocalDaemonTransportEvent): void; +} + +export function createFakeLocalDaemonTransportRpc(): FakeLocalDaemonTransportRpc { + const openCalls: LocalTransportTarget[] = []; + const recordedSends: RecordedSend[] = []; + const closedSessions: string[] = []; + let eventHandler: ((event: LocalDaemonTransportEvent) => void) | null = null; + let resolveOpenSession: ((sessionId: string) => void) | null = null; + let rejectOpenSession: ((error: Error) => void) | null = null; + let resolveListenPromise: ((cleanup: () => void) => void) | null = null; + let rejectListenPromise: ((error: Error) => void) | null = null; + + return { + openCalls, + recordedSends, + closedSessions, + openSession(target) { + openCalls.push(target); + return new Promise((resolve, reject) => { + resolveOpenSession = resolve; + rejectOpenSession = reject; + }); + }, + listenToEvents(handler) { + eventHandler = handler; + return new Promise<() => void>((resolve, reject) => { + resolveListenPromise = resolve; + rejectListenPromise = reject; + }); + }, + async sendMessage(input) { + recordedSends.push(input); + }, + async closeSession(sessionId) { + closedSessions.push(sessionId); + }, + resolveOpen(sessionId) { + if (!resolveOpenSession) { + throw new Error("openSession was not called"); + } + resolveOpenSession(sessionId); + }, + rejectOpen(error) { + if (!rejectOpenSession) { + throw new Error("openSession was not called"); + } + rejectOpenSession(error); + }, + resolveListen(cleanup) { + if (!resolveListenPromise) { + throw new Error("listenToEvents was not called"); + } + resolveListenPromise(cleanup); + }, + rejectListen(error) { + if (!rejectListenPromise) { + throw new Error("listenToEvents was not called"); + } + rejectListenPromise(error); + }, + emitEvent(event) { + eventHandler?.(event); + }, + }; +} diff --git a/packages/app/src/desktop/electron/idle.test.ts b/packages/app/src/desktop/electron/idle.test.ts index b3721d758..9308fca69 100644 --- a/packages/app/src/desktop/electron/idle.test.ts +++ b/packages/app/src/desktop/electron/idle.test.ts @@ -1,79 +1,54 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; +import { readDesktopSystemIdleTimeMs, type DesktopIpcInvoker } from "./idle"; -const { invokeDesktopCommandMock } = vi.hoisted(() => ({ - invokeDesktopCommandMock: vi.fn<() => Promise>(async () => 12_000), -})); +function fakeInvoker(result: () => Promise): DesktopIpcInvoker { + return () => result() as Promise; +} -vi.mock("@/desktop/electron/invoke", () => ({ - invokeDesktopCommand: invokeDesktopCommandMock, -})); +describe("readDesktopSystemIdleTimeMs", () => { + it("returns the millisecond value reported by the desktop idle command", async () => { + const invokedCommands: string[] = []; + const invoke: DesktopIpcInvoker = async (command: string) => { + invokedCommands.push(command); + return 4_200 as T; + }; -import { getDesktopSystemIdleTimeMs } from "./idle"; + const idleTimeMs = await readDesktopSystemIdleTimeMs(invoke); -describe("getDesktopSystemIdleTimeMs", () => { - afterEach(() => { - invokeDesktopCommandMock.mockReset(); - vi.restoreAllMocks(); - }); - - it("invokes the desktop idle command and returns the millisecond value", async () => { - invokeDesktopCommandMock.mockResolvedValueOnce(4_200); - - const idleTimeMs = await getDesktopSystemIdleTimeMs(); - - expect(invokeDesktopCommandMock).toHaveBeenCalledWith("desktop_get_system_idle_time"); expect(idleTimeMs).toBe(4_200); + expect(invokedCommands).toEqual(["desktop_get_system_idle_time"]); }); - it("returns null and logs once when IPC rejects", async () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const error = new Error("ipc failed"); - invokeDesktopCommandMock.mockRejectedValueOnce(error); - - const idleTimeMs = await getDesktopSystemIdleTimeMs(); + it("returns null when the desktop IPC rejects", async () => { + const idleTimeMs = await readDesktopSystemIdleTimeMs( + fakeInvoker(async () => { + throw new Error("ipc failed"); + }), + ); expect(idleTimeMs).toBeNull(); - expect(warn).toHaveBeenCalledTimes(1); - expect(warn).toHaveBeenCalledWith("[DesktopIdle] Failed to read system idle time", error); }); - it("returns null and logs once when IPC returns null", async () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - invokeDesktopCommandMock.mockResolvedValueOnce(null); - - const idleTimeMs = await getDesktopSystemIdleTimeMs(); + it("returns null when the desktop IPC returns null", async () => { + const idleTimeMs = await readDesktopSystemIdleTimeMs(fakeInvoker(async () => null)); expect(idleTimeMs).toBeNull(); - expect(warn).toHaveBeenCalledTimes(1); - expect(warn).toHaveBeenCalledWith("[DesktopIdle] Invalid system idle time", null); }); - it("returns null and logs once when IPC returns NaN", async () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - invokeDesktopCommandMock.mockResolvedValueOnce(Number.NaN); - - const idleTimeMs = await getDesktopSystemIdleTimeMs(); + it("returns null when the desktop IPC returns NaN", async () => { + const idleTimeMs = await readDesktopSystemIdleTimeMs(fakeInvoker(async () => Number.NaN)); expect(idleTimeMs).toBeNull(); - expect(warn).toHaveBeenCalledTimes(1); - expect(warn).toHaveBeenCalledWith("[DesktopIdle] Invalid system idle time", Number.NaN); }); - it("returns null and logs once when IPC returns a negative value", async () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - invokeDesktopCommandMock.mockResolvedValueOnce(-1); - - const idleTimeMs = await getDesktopSystemIdleTimeMs(); + it("returns null when the desktop IPC returns a negative value", async () => { + const idleTimeMs = await readDesktopSystemIdleTimeMs(fakeInvoker(async () => -1)); expect(idleTimeMs).toBeNull(); - expect(warn).toHaveBeenCalledTimes(1); - expect(warn).toHaveBeenCalledWith("[DesktopIdle] Invalid system idle time", -1); }); - it("returns 0 when IPC returns zero", async () => { - invokeDesktopCommandMock.mockResolvedValueOnce(0); - - const idleTimeMs = await getDesktopSystemIdleTimeMs(); + it("returns zero when the desktop IPC returns zero", async () => { + const idleTimeMs = await readDesktopSystemIdleTimeMs(fakeInvoker(async () => 0)); expect(idleTimeMs).toBe(0); }); diff --git a/packages/app/src/desktop/electron/idle.ts b/packages/app/src/desktop/electron/idle.ts index a3386f268..3582b033a 100644 --- a/packages/app/src/desktop/electron/idle.ts +++ b/packages/app/src/desktop/electron/idle.ts @@ -1,14 +1,16 @@ -import { invokeDesktopCommand } from "@/desktop/electron/invoke"; - const DESKTOP_SYSTEM_IDLE_COMMAND = "desktop_get_system_idle_time"; +export type DesktopIpcInvoker = (command: string) => Promise; + function isValidIdleTimeMs(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value) && value >= 0; } -export async function getDesktopSystemIdleTimeMs(): Promise { +export async function readDesktopSystemIdleTimeMs( + invoke: DesktopIpcInvoker, +): Promise { try { - const idleTimeMs = await invokeDesktopCommand(DESKTOP_SYSTEM_IDLE_COMMAND); + const idleTimeMs = await invoke(DESKTOP_SYSTEM_IDLE_COMMAND); if (!isValidIdleTimeMs(idleTimeMs)) { console.warn("[DesktopIdle] Invalid system idle time", idleTimeMs); return null; diff --git a/packages/app/src/desktop/permissions/desktop-permissions.test.ts b/packages/app/src/desktop/permissions/desktop-permissions.test.ts index 08fd2de29..ebdc2985b 100644 --- a/packages/app/src/desktop/permissions/desktop-permissions.test.ts +++ b/packages/app/src/desktop/permissions/desktop-permissions.test.ts @@ -1,104 +1,65 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import type { DesktopHostBridge } from "@/desktop/host"; +import { + createDesktopPermissions, + type DesktopPermissionEnvironment, + type NavigatorLike, + type NotificationConstructorLike, +} from "./desktop-permissions"; -type MockPlatform = "web" | "ios" | "android"; - -interface GlobalSnapshot { - Notification: unknown; - navigatorDescriptor?: PropertyDescriptor; - windowDescriptor?: PropertyDescriptor; - paseoDesktop: unknown; +interface FakeEnvironmentInput { + isWeb?: boolean; + desktopHost?: DesktopHostBridge | null; + notification?: NotificationConstructorLike | null; + navigator?: NavigatorLike | null; } -const originalGlobals: GlobalSnapshot = { - Notification: (globalThis as { Notification?: unknown }).Notification, - navigatorDescriptor: Object.getOwnPropertyDescriptor(globalThis, "navigator"), - windowDescriptor: Object.getOwnPropertyDescriptor(globalThis, "window"), - paseoDesktop: - typeof globalThis.window === "undefined" - ? undefined - : (globalThis.window as { paseoDesktop?: unknown }).paseoDesktop, -}; - -function ensureWindow(): { paseoDesktop?: unknown } { - const existingWindow = (globalThis as { window?: { paseoDesktop?: unknown } }).window; - if (existingWindow) { - return existingWindow; - } - - const nextWindow: { paseoDesktop?: unknown } = {}; - Object.defineProperty(globalThis, "window", { - configurable: true, - writable: true, - value: nextWindow, - }); - return nextWindow; -} - -function setNavigator(value: unknown): void { - Object.defineProperty(globalThis, "navigator", { - configurable: true, - writable: true, - value, - }); -} - -function restoreGlobals(): void { - (globalThis as { Notification?: unknown }).Notification = originalGlobals.Notification; - - if (originalGlobals.navigatorDescriptor) { - Object.defineProperty(globalThis, "navigator", originalGlobals.navigatorDescriptor); - } else { - delete (globalThis as { navigator?: unknown }).navigator; - } - - if (originalGlobals.windowDescriptor) { - Object.defineProperty(globalThis, "window", originalGlobals.windowDescriptor); - } else { - delete (globalThis as { window?: unknown }).window; - } - - if (typeof globalThis.window !== "undefined") { - (globalThis.window as { paseoDesktop?: unknown }).paseoDesktop = originalGlobals.paseoDesktop; - } -} - -async function loadModuleForPlatform(platform: MockPlatform) { - vi.resetModules(); - vi.doMock("react-native", () => ({ Platform: { OS: platform } })); - return import("./desktop-permissions"); +function fakeEnvironment(input: FakeEnvironmentInput = {}): DesktopPermissionEnvironment { + return { + isWeb: input.isWeb ?? true, + getDesktopHost: () => input.desktopHost ?? null, + getNotification: () => input.notification ?? null, + getNavigator: () => input.navigator ?? null, + }; } describe("desktop-permissions", () => { - afterEach(() => { - vi.doUnmock("react-native"); - vi.restoreAllMocks(); - vi.resetModules(); - restoreGlobals(); - }); + it("shows section only in desktop web runtime", () => { + expect( + createDesktopPermissions( + fakeEnvironment({ isWeb: false }), + ).shouldShowDesktopPermissionSection(), + ).toBe(false); - it("shows section only in desktop web runtime", async () => { - const { shouldShowDesktopPermissionSection } = await loadModuleForPlatform("web"); + expect( + createDesktopPermissions( + fakeEnvironment({ isWeb: true, desktopHost: null }), + ).shouldShowDesktopPermissionSection(), + ).toBe(false); - expect(shouldShowDesktopPermissionSection()).toBe(false); - - ensureWindow().paseoDesktop = {}; - expect(shouldShowDesktopPermissionSection()).toBe(true); + expect( + createDesktopPermissions( + fakeEnvironment({ isWeb: true, desktopHost: {} as DesktopHostBridge }), + ).shouldShowDesktopPermissionSection(), + ).toBe(true); }); it("reads notification and microphone status", async () => { - const MockNotification = { permission: "default" }; - (globalThis as { Notification?: unknown }).Notification = MockNotification; - setNavigator({ - permissions: { - query: vi.fn(async () => ({ state: "granted" })), - }, - mediaDevices: { - getUserMedia: vi.fn(), - }, - }); + const permissions = createDesktopPermissions( + fakeEnvironment({ + notification: { permission: "default" }, + navigator: { + permissions: { + query: vi.fn(async () => ({ state: "granted" })), + }, + mediaDevices: { + getUserMedia: vi.fn(), + }, + }, + }), + ); - const { getDesktopPermissionSnapshot } = await loadModuleForPlatform("web"); - const snapshot = await getDesktopPermissionSnapshot(); + const snapshot = await permissions.getDesktopPermissionSnapshot(); expect(snapshot.notifications.state).toBe("prompt"); expect(snapshot.microphone.state).toBe("granted"); @@ -106,42 +67,44 @@ describe("desktop-permissions", () => { }); it("queries microphone permission with correct Permissions instance binding", async () => { - const permissions = { + const permissionsApi = { query(this: unknown, _descriptor: { name: string }) { - if (this !== permissions) { + if (this !== permissionsApi) { throw new TypeError("Can only call Permissions.query on instances of Permissions"); } return Promise.resolve({ state: "granted" as const }); }, }; - setNavigator({ - permissions, - mediaDevices: { - getUserMedia: vi.fn(), - }, - }); + const permissions = createDesktopPermissions( + fakeEnvironment({ + navigator: { + permissions: permissionsApi, + mediaDevices: { getUserMedia: vi.fn() }, + }, + }), + ); - const { getDesktopPermissionSnapshot } = await loadModuleForPlatform("web"); - const snapshot = await getDesktopPermissionSnapshot(); + const snapshot = await permissions.getDesktopPermissionSnapshot(); expect(snapshot.microphone.state).toBe("granted"); }); it("returns a fallback message when runtime blocks Permissions.query", async () => { - setNavigator({ - permissions: { - query: vi.fn(async () => { - throw new TypeError("Can only call Permissions.query on instances of Permissions"); - }), - }, - mediaDevices: { - getUserMedia: vi.fn(), - }, - }); + const permissions = createDesktopPermissions( + fakeEnvironment({ + navigator: { + permissions: { + query: vi.fn(async () => { + throw new TypeError("Can only call Permissions.query on instances of Permissions"); + }), + }, + mediaDevices: { getUserMedia: vi.fn() }, + }, + }), + ); - const { getDesktopPermissionSnapshot } = await loadModuleForPlatform("web"); - const snapshot = await getDesktopPermissionSnapshot(); + const snapshot = await permissions.getDesktopPermissionSnapshot(); expect(snapshot.microphone.state).toBe("unknown"); expect(snapshot.microphone.detail).toContain( @@ -150,26 +113,29 @@ describe("desktop-permissions", () => { }); it("requests notification permission via the browser Notification API", async () => { - const MockNotification = { + const fakeNotification: NotificationConstructorLike = { permission: "default", requestPermission: vi.fn(async () => "granted"), }; - (globalThis as { Notification?: unknown }).Notification = MockNotification; - const { requestDesktopPermission } = await loadModuleForPlatform("web"); - const result = await requestDesktopPermission({ kind: "notifications" }); + const permissions = createDesktopPermissions( + fakeEnvironment({ notification: fakeNotification }), + ); + const result = await permissions.requestDesktopPermission({ kind: "notifications" }); expect(result.state).toBe("granted"); - expect(MockNotification.requestPermission).toHaveBeenCalledTimes(1); + expect(fakeNotification.requestPermission).toHaveBeenCalledTimes(1); }); it("reads browser Notification permission when available", async () => { - const MockNotification = { permission: "denied" }; - (globalThis as { Notification?: unknown }).Notification = MockNotification; - setNavigator({}); + const permissions = createDesktopPermissions( + fakeEnvironment({ + notification: { permission: "denied" }, + navigator: {}, + }), + ); - const { getDesktopPermissionSnapshot } = await loadModuleForPlatform("web"); - const snapshot = await getDesktopPermissionSnapshot(); + const snapshot = await permissions.getDesktopPermissionSnapshot(); expect(snapshot.notifications.state).toBe("denied"); }); @@ -179,17 +145,18 @@ describe("desktop-permissions", () => { const getUserMedia = vi.fn(async () => ({ getTracks: () => [{ stop }], })); - setNavigator({ - permissions: { - query: vi.fn(async () => ({ state: "granted" })), - }, - mediaDevices: { - getUserMedia, - }, - }); + const permissions = createDesktopPermissions( + fakeEnvironment({ + navigator: { + permissions: { + query: vi.fn(async () => ({ state: "granted" })), + }, + mediaDevices: { getUserMedia }, + }, + }), + ); - const { requestDesktopPermission } = await loadModuleForPlatform("web"); - const result = await requestDesktopPermission({ kind: "microphone" }); + const result = await permissions.requestDesktopPermission({ kind: "microphone" }); expect(result.state).toBe("granted"); expect(getUserMedia).toHaveBeenCalledWith({ audio: true }); @@ -197,16 +164,19 @@ describe("desktop-permissions", () => { }); it("maps microphone request denial to denied status", async () => { - setNavigator({ - mediaDevices: { - getUserMedia: vi.fn(async () => { - throw { name: "NotAllowedError", message: "denied" }; - }), - }, - }); + const permissions = createDesktopPermissions( + fakeEnvironment({ + navigator: { + mediaDevices: { + getUserMedia: vi.fn(async () => { + throw { name: "NotAllowedError", message: "denied" }; + }), + }, + }, + }), + ); - const { requestDesktopPermission } = await loadModuleForPlatform("web"); - const result = await requestDesktopPermission({ kind: "microphone" }); + const result = await permissions.requestDesktopPermission({ kind: "microphone" }); expect(result.state).toBe("denied"); }); diff --git a/packages/app/src/desktop/permissions/desktop-permissions.ts b/packages/app/src/desktop/permissions/desktop-permissions.ts index 12964e1ea..6aab5494e 100644 --- a/packages/app/src/desktop/permissions/desktop-permissions.ts +++ b/packages/app/src/desktop/permissions/desktop-permissions.ts @@ -1,5 +1,5 @@ -import { getDesktopHost } from "@/desktop/host"; -import { isWeb, isNative } from "@/constants/platform"; +import { type DesktopHostBridge, getDesktopHost } from "@/desktop/host"; +import { isNative, isWeb } from "@/constants/platform"; export type DesktopPermissionKind = "notifications" | "microphone"; @@ -22,7 +22,7 @@ export interface DesktopPermissionSnapshot { microphone: DesktopPermissionStatus; } -interface NotificationConstructorLike { +export interface NotificationConstructorLike { permission?: string; requestPermission?: () => Promise; } @@ -35,7 +35,7 @@ interface MediaStreamLike { getTracks?: () => MediaStreamTrackLike[]; } -interface NavigatorLike { +export interface NavigatorLike { mediaDevices?: { getUserMedia?: (constraints: { audio: boolean }) => Promise; }; @@ -44,8 +44,19 @@ interface NavigatorLike { }; } -export function shouldShowDesktopPermissionSection(): boolean { - return isWeb && getDesktopHost() !== null; +export interface DesktopPermissionEnvironment { + isWeb: boolean; + getDesktopHost: () => DesktopHostBridge | null; + getNotification: () => NotificationConstructorLike | null; + getNavigator: () => NavigatorLike | null; +} + +export interface DesktopPermissions { + shouldShowDesktopPermissionSection: () => boolean; + getDesktopPermissionSnapshot: () => Promise; + requestDesktopPermission: (input: { + kind: DesktopPermissionKind; + }) => Promise; } function status(input: DesktopPermissionStatus): DesktopPermissionStatus { @@ -82,31 +93,6 @@ function isPermissionsQueryRuntimeUnsupported(error: unknown): boolean { return false; } -function getWebNotificationConstructor(): NotificationConstructorLike | null { - if (isNative) { - return null; - } - const NotificationConstructor = (globalThis as { Notification?: unknown }).Notification; - if ( - NotificationConstructor == null || - (typeof NotificationConstructor !== "function" && typeof NotificationConstructor !== "object") - ) { - return null; - } - return NotificationConstructor as NotificationConstructorLike; -} - -function getNavigatorLike(): NavigatorLike | null { - if (isNative) { - return null; - } - const webNavigator = (globalThis as { navigator?: unknown }).navigator; - if (!isObject(webNavigator)) { - return null; - } - return webNavigator as NavigatorLike; -} - function mapNotificationPermissionString(permission: string): DesktopPermissionStatus { if (permission === "granted") { return status({ @@ -132,201 +118,253 @@ function mapNotificationPermissionString(permission: string): DesktopPermissionS }); } -async function getNotificationPermissionStatus(): Promise { - if (isNative) { - return status({ - state: "unavailable", - detail: "Desktop notification status is only available on web runtime.", - }); +export function createDesktopPermissions(env: DesktopPermissionEnvironment): DesktopPermissions { + function shouldShowDesktopPermissionSection(): boolean { + return env.isWeb && env.getDesktopHost() !== null; } - const desktopHost = getDesktopHost(); - if (desktopHost && typeof desktopHost.notification?.isSupported === "function") { - try { - const supported = await desktopHost.notification.isSupported(); - return status({ - state: supported ? "granted" : "unavailable", - detail: supported - ? "Desktop notifications are supported." - : "Desktop notifications are not supported on this platform.", - }); - } catch { - // Fall through to web API check - } - } - - const NotificationConstructor = getWebNotificationConstructor(); - if (NotificationConstructor && typeof NotificationConstructor.permission === "string") { - return mapNotificationPermissionString(NotificationConstructor.permission); - } - - return status({ - state: "unavailable", - detail: "Web Notification API is unavailable in this environment.", - }); -} - -async function getMicrophonePermissionStatus(): Promise { - if (isNative) { - return status({ - state: "unavailable", - detail: "Desktop microphone status is only available on web runtime.", - }); - } - - const webNavigator = getNavigatorLike(); - if (!webNavigator) { - return status({ - state: "unavailable", - detail: "Navigator is unavailable in this environment.", - }); - } - - const permissionsApi = webNavigator.permissions; - if (permissionsApi && typeof permissionsApi.query === "function") { - try { - const result = await permissionsApi.query({ name: "microphone" }); - if (result?.state === "granted") { - return status({ - state: "granted", - detail: "Microphone access is granted.", - }); - } - if (result?.state === "denied") { - return status({ - state: "denied", - detail: "Microphone access is denied in system settings.", - }); - } - if (result?.state === "prompt") { - return status({ - state: "prompt", - detail: "Microphone permission has not been granted yet.", - }); - } - return status({ - state: "unknown", - detail: `Unexpected microphone permission state: ${result?.state ?? "unknown"}`, - }); - } catch (error) { - if (isPermissionsQueryRuntimeUnsupported(error)) { - return status({ - state: "unknown", - detail: - "Microphone status API is unavailable in this runtime. Use Request to check access.", - }); - } - return status({ - state: "unknown", - detail: `Failed to query microphone status: ${getErrorMessage(error)}`, - }); - } - } - - if (typeof webNavigator.mediaDevices?.getUserMedia !== "function") { - return status({ - state: "unavailable", - detail: "Microphone capture is unavailable in this environment.", - }); - } - - return status({ - state: "unknown", - detail: "Permission status API is unavailable. Use Request to check access.", - }); -} - -async function requestNotificationPermissionStatus(): Promise { - if (isNative) { - return status({ - state: "unavailable", - detail: "Desktop notification requests are only available on web runtime.", - }); - } - - const NotificationConstructor = getWebNotificationConstructor(); - if (NotificationConstructor && typeof NotificationConstructor.requestPermission === "function") { - try { - const permission = await NotificationConstructor.requestPermission(); - return mapNotificationPermissionString(permission); - } catch (error) { - return status({ - state: "unknown", - detail: `Failed to request notification permission: ${getErrorMessage(error)}`, - }); - } - } - - return status({ - state: "unavailable", - detail: "Web Notification API requestPermission() is unavailable.", - }); -} - -async function requestMicrophonePermissionStatus(): Promise { - if (isNative) { - return status({ - state: "unavailable", - detail: "Desktop microphone requests are only available on web runtime.", - }); - } - - const webNavigator = getNavigatorLike(); - if (!webNavigator || typeof webNavigator.mediaDevices?.getUserMedia !== "function") { - return status({ - state: "unavailable", - detail: "Microphone capture API is unavailable in this environment.", - }); - } - - try { - const stream = await webNavigator.mediaDevices.getUserMedia({ audio: true }); - const tracks = stream && typeof stream.getTracks === "function" ? stream.getTracks() : []; - tracks.forEach((track) => { - if (typeof track.stop === "function") { - track.stop(); - } - }); - return await getMicrophonePermissionStatus(); - } catch (error) { - const errorName = getErrorName(error); - if (errorName === "NotAllowedError" || errorName === "PermissionDeniedError") { - return status({ - state: "denied", - detail: "Microphone permission was denied by the user or system.", - }); - } - if (errorName === "NotFoundError" || errorName === "DevicesNotFoundError") { + async function getNotificationPermissionStatus(): Promise { + if (!env.isWeb) { return status({ state: "unavailable", - detail: "No microphone device was found.", + detail: "Desktop notification status is only available on web runtime.", }); } + + const desktopHost = env.getDesktopHost(); + if (desktopHost && typeof desktopHost.notification?.isSupported === "function") { + try { + const supported = await desktopHost.notification.isSupported(); + return status({ + state: supported ? "granted" : "unavailable", + detail: supported + ? "Desktop notifications are supported." + : "Desktop notifications are not supported on this platform.", + }); + } catch { + // Fall through to web API check + } + } + + const NotificationConstructor = env.getNotification(); + if (NotificationConstructor && typeof NotificationConstructor.permission === "string") { + return mapNotificationPermissionString(NotificationConstructor.permission); + } + return status({ - state: "unknown", - detail: `Failed to request microphone permission: ${getErrorMessage(error)}`, + state: "unavailable", + detail: "Web Notification API is unavailable in this environment.", }); } -} -export async function requestDesktopPermission(input: { - kind: DesktopPermissionKind; -}): Promise { - if (input.kind === "notifications") { - return await requestNotificationPermissionStatus(); + async function getMicrophonePermissionStatus(): Promise { + if (!env.isWeb) { + return status({ + state: "unavailable", + detail: "Desktop microphone status is only available on web runtime.", + }); + } + + const webNavigator = env.getNavigator(); + if (!webNavigator) { + return status({ + state: "unavailable", + detail: "Navigator is unavailable in this environment.", + }); + } + + const permissionsApi = webNavigator.permissions; + if (permissionsApi && typeof permissionsApi.query === "function") { + try { + const result = await permissionsApi.query({ name: "microphone" }); + if (result?.state === "granted") { + return status({ + state: "granted", + detail: "Microphone access is granted.", + }); + } + if (result?.state === "denied") { + return status({ + state: "denied", + detail: "Microphone access is denied in system settings.", + }); + } + if (result?.state === "prompt") { + return status({ + state: "prompt", + detail: "Microphone permission has not been granted yet.", + }); + } + return status({ + state: "unknown", + detail: `Unexpected microphone permission state: ${result?.state ?? "unknown"}`, + }); + } catch (error) { + if (isPermissionsQueryRuntimeUnsupported(error)) { + return status({ + state: "unknown", + detail: + "Microphone status API is unavailable in this runtime. Use Request to check access.", + }); + } + return status({ + state: "unknown", + detail: `Failed to query microphone status: ${getErrorMessage(error)}`, + }); + } + } + + if (typeof webNavigator.mediaDevices?.getUserMedia !== "function") { + return status({ + state: "unavailable", + detail: "Microphone capture is unavailable in this environment.", + }); + } + + return status({ + state: "unknown", + detail: "Permission status API is unavailable. Use Request to check access.", + }); } - return await requestMicrophonePermissionStatus(); -} -export async function getDesktopPermissionSnapshot(): Promise { - const [notifications, microphone] = await Promise.all([ - getNotificationPermissionStatus(), - getMicrophonePermissionStatus(), - ]); + async function requestNotificationPermissionStatus(): Promise { + if (!env.isWeb) { + return status({ + state: "unavailable", + detail: "Desktop notification requests are only available on web runtime.", + }); + } + + const NotificationConstructor = env.getNotification(); + if ( + NotificationConstructor && + typeof NotificationConstructor.requestPermission === "function" + ) { + try { + const permission = await NotificationConstructor.requestPermission(); + return mapNotificationPermissionString(permission); + } catch (error) { + return status({ + state: "unknown", + detail: `Failed to request notification permission: ${getErrorMessage(error)}`, + }); + } + } + + return status({ + state: "unavailable", + detail: "Web Notification API requestPermission() is unavailable.", + }); + } + + async function requestMicrophonePermissionStatus(): Promise { + if (!env.isWeb) { + return status({ + state: "unavailable", + detail: "Desktop microphone requests are only available on web runtime.", + }); + } + + const webNavigator = env.getNavigator(); + if (!webNavigator || typeof webNavigator.mediaDevices?.getUserMedia !== "function") { + return status({ + state: "unavailable", + detail: "Microphone capture API is unavailable in this environment.", + }); + } + + try { + const stream = await webNavigator.mediaDevices.getUserMedia({ audio: true }); + const tracks = stream && typeof stream.getTracks === "function" ? stream.getTracks() : []; + tracks.forEach((track) => { + if (typeof track.stop === "function") { + track.stop(); + } + }); + return await getMicrophonePermissionStatus(); + } catch (error) { + const errorName = getErrorName(error); + if (errorName === "NotAllowedError" || errorName === "PermissionDeniedError") { + return status({ + state: "denied", + detail: "Microphone permission was denied by the user or system.", + }); + } + if (errorName === "NotFoundError" || errorName === "DevicesNotFoundError") { + return status({ + state: "unavailable", + detail: "No microphone device was found.", + }); + } + return status({ + state: "unknown", + detail: `Failed to request microphone permission: ${getErrorMessage(error)}`, + }); + } + } + + async function requestDesktopPermission(input: { + kind: DesktopPermissionKind; + }): Promise { + if (input.kind === "notifications") { + return await requestNotificationPermissionStatus(); + } + return await requestMicrophonePermissionStatus(); + } + + async function getDesktopPermissionSnapshot(): Promise { + const [notifications, microphone] = await Promise.all([ + getNotificationPermissionStatus(), + getMicrophonePermissionStatus(), + ]); + + return { + checkedAt: Date.now(), + notifications, + microphone, + }; + } return { - checkedAt: Date.now(), - notifications, - microphone, + shouldShowDesktopPermissionSection, + getDesktopPermissionSnapshot, + requestDesktopPermission, }; } + +function getRealNotification(): NotificationConstructorLike | null { + if (isNative) { + return null; + } + const NotificationConstructor = (globalThis as { Notification?: unknown }).Notification; + if ( + NotificationConstructor == null || + (typeof NotificationConstructor !== "function" && typeof NotificationConstructor !== "object") + ) { + return null; + } + return NotificationConstructor as NotificationConstructorLike; +} + +function getRealNavigator(): NavigatorLike | null { + if (isNative) { + return null; + } + const webNavigator = (globalThis as { navigator?: unknown }).navigator; + if (!isObject(webNavigator)) { + return null; + } + return webNavigator as NavigatorLike; +} + +const realDesktopPermissions = createDesktopPermissions({ + isWeb, + getDesktopHost, + getNotification: getRealNotification, + getNavigator: getRealNavigator, +}); + +export const shouldShowDesktopPermissionSection = + realDesktopPermissions.shouldShowDesktopPermissionSection; +export const getDesktopPermissionSnapshot = realDesktopPermissions.getDesktopPermissionSnapshot; +export const requestDesktopPermission = realDesktopPermissions.requestDesktopPermission; diff --git a/packages/app/src/desktop/updates/desktop-app-updater.test.ts b/packages/app/src/desktop/updates/desktop-app-updater.test.ts new file mode 100644 index 000000000..3c1c120fd --- /dev/null +++ b/packages/app/src/desktop/updates/desktop-app-updater.test.ts @@ -0,0 +1,262 @@ +import { describe, expect, it } from "vitest"; +import { + createDesktopAppUpdater, + formatStatusText, + type DesktopAppUpdater, + type DesktopAppUpdaterErrorReport, +} from "./desktop-app-updater"; +import { + buildFakeCheckResult, + buildFakeInstallResult, + createFakeDesktopAppUpdaterPort, + type FakeDesktopAppUpdaterPort, +} from "./test-utils/fake-desktop-app-updater-port"; + +function createUpdater( + overrides: { + port?: FakeDesktopAppUpdaterPort; + now?: () => number; + reportInstallError?: (report: DesktopAppUpdaterErrorReport) => void; + } = {}, +): { + updater: DesktopAppUpdater; + port: FakeDesktopAppUpdaterPort; + reportedInstallErrors: DesktopAppUpdaterErrorReport[]; +} { + const port = overrides.port ?? createFakeDesktopAppUpdaterPort(); + const reportedInstallErrors: DesktopAppUpdaterErrorReport[] = []; + const updater = createDesktopAppUpdater({ + port, + now: overrides.now ?? (() => 1_700_000_000_000), + reportInstallError: + overrides.reportInstallError ?? + ((report) => { + reportedInstallErrors.push(report); + }), + }); + return { updater, port, reportedInstallErrors }; +} + +describe("desktop app updater — check", () => { + it("forwards the requested release channel to the port", async () => { + const { updater, port } = createUpdater(); + port.nextCheckResult(buildFakeCheckResult()); + + await updater.checkForUpdates({ releaseChannel: "beta" }); + + expect(port.recordedChecks).toEqual([{ releaseChannel: "beta" }]); + }); + + it("moves to 'checking' during a non-silent check", async () => { + const { updater, port } = createUpdater(); + const deferred = port.deferNextCheck(); + + const pending = updater.checkForUpdates({ releaseChannel: "stable" }); + expect(updater.getSnapshot().status).toBe("checking"); + expect(updater.getSnapshot().isChecking).toBe(true); + + deferred.resolve(buildFakeCheckResult()); + await pending; + }); + + it("stays on the current status during a silent check", async () => { + const { updater, port } = createUpdater(); + port.nextCheckResult(buildFakeCheckResult({ hasUpdate: true, readyToInstall: true })); + await updater.checkForUpdates({ releaseChannel: "stable" }); + expect(updater.getSnapshot().status).toBe("available"); + + const deferred = port.deferNextCheck(); + const pending = updater.checkForUpdates({ releaseChannel: "stable", silent: true }); + expect(updater.getSnapshot().status).toBe("available"); + + deferred.resolve(buildFakeCheckResult({ hasUpdate: true, readyToInstall: true })); + await pending; + }); + + it("reports 'available' when the check resolves with a downloaded update", async () => { + const { updater, port } = createUpdater({ now: () => 42 }); + port.nextCheckResult( + buildFakeCheckResult({ hasUpdate: true, readyToInstall: true, latestVersion: "1.2.3" }), + ); + + await updater.checkForUpdates({ releaseChannel: "stable" }); + + expect(updater.getSnapshot()).toMatchObject({ + status: "available", + availableUpdate: { latestVersion: "1.2.3" }, + lastCheckedAt: 42, + }); + }); + + it("reports 'pending' when the check resolves with an update that is not yet downloaded", async () => { + const { updater, port } = createUpdater(); + port.nextCheckResult(buildFakeCheckResult({ hasUpdate: true, readyToInstall: false })); + + await updater.checkForUpdates({ releaseChannel: "stable" }); + + expect(updater.getSnapshot()).toMatchObject({ + status: "pending", + availableUpdate: null, + }); + }); + + it("reports 'up-to-date' when the check resolves with no update", async () => { + const { updater, port } = createUpdater(); + port.nextCheckResult(buildFakeCheckResult({ hasUpdate: false, readyToInstall: false })); + + await updater.checkForUpdates({ releaseChannel: "stable" }); + + expect(updater.getSnapshot().status).toBe("up-to-date"); + }); + + it("reports 'error' when a non-silent check throws", async () => { + const { updater, port } = createUpdater(); + port.failNextCheck(new Error("network down")); + + await updater.checkForUpdates({ releaseChannel: "stable" }); + + expect(updater.getSnapshot()).toMatchObject({ + status: "error", + errorMessage: "network down", + }); + }); + + it("does not move to 'error' when a silent check throws", async () => { + const { updater, port } = createUpdater(); + port.nextCheckResult(buildFakeCheckResult({ hasUpdate: true, readyToInstall: true })); + await updater.checkForUpdates({ releaseChannel: "stable" }); + const statusBeforeSilent = updater.getSnapshot().status; + + port.failNextCheck(new Error("boom")); + await updater.checkForUpdates({ releaseChannel: "stable", silent: true }); + + expect(updater.getSnapshot().status).toBe(statusBeforeSilent); + }); + + it("ignores the older result when a newer check supersedes it mid-flight", async () => { + const { updater, port } = createUpdater(); + const firstDeferred = port.deferNextCheck(); + port.nextCheckResult(buildFakeCheckResult({ hasUpdate: true, readyToInstall: true })); + + const firstPending = updater.checkForUpdates({ releaseChannel: "stable" }); + const secondPending = updater.checkForUpdates({ releaseChannel: "stable" }); + await secondPending; + expect(updater.getSnapshot().status).toBe("available"); + + firstDeferred.resolve(buildFakeCheckResult({ hasUpdate: false, readyToInstall: false })); + await firstPending; + + expect(updater.getSnapshot().status).toBe("available"); + }); +}); + +describe("desktop app updater — install", () => { + it("forwards the requested release channel to the port", async () => { + const { updater, port } = createUpdater(); + port.nextInstallResult(buildFakeInstallResult({ installed: true })); + + await updater.installUpdate({ releaseChannel: "beta" }); + + expect(port.recordedInstalls).toEqual([{ releaseChannel: "beta" }]); + }); + + it("moves to 'installed' when the install reports installation succeeded", async () => { + const { updater, port } = createUpdater(); + port.nextInstallResult( + buildFakeInstallResult({ installed: true, message: "Restart to finish" }), + ); + + await updater.installUpdate({ releaseChannel: "stable" }); + + expect(updater.getSnapshot()).toMatchObject({ + status: "installed", + installMessage: "Restart to finish", + isInstalling: false, + }); + }); + + it("moves to 'up-to-date' when the install reports nothing to install", async () => { + const { updater, port } = createUpdater(); + port.nextInstallResult(buildFakeInstallResult({ installed: false })); + + await updater.installUpdate({ releaseChannel: "stable" }); + + expect(updater.getSnapshot().status).toBe("up-to-date"); + }); + + it("reports the install error and moves to 'error' when the install throws", async () => { + const { updater, port, reportedInstallErrors } = createUpdater(); + const error = new Error("install failed"); + port.failNextInstall(error); + + await updater.installUpdate({ releaseChannel: "stable" }); + + expect(updater.getSnapshot()).toMatchObject({ + status: "error", + errorMessage: "install failed", + isInstalling: false, + }); + expect(reportedInstallErrors).toEqual([ + { + error, + message: "Unable to install the desktop app update.", + logLabel: "[DesktopUpdater] Failed to install app update", + }, + ]); + }); +}); + +describe("desktop app updater — subscribe", () => { + it("notifies subscribers when the status changes", async () => { + const { updater, port } = createUpdater(); + port.nextCheckResult(buildFakeCheckResult({ hasUpdate: true, readyToInstall: true })); + + const notifications: string[] = []; + const unsubscribe = updater.subscribe(() => { + notifications.push(updater.getSnapshot().status); + }); + + await updater.checkForUpdates({ releaseChannel: "stable" }); + unsubscribe(); + + expect(notifications).toEqual(["checking", "available"]); + }); +}); + +describe("formatStatusText", () => { + const formatVersion = (version: string | null | undefined) => + version ? `v${version.replace(/^v/i, "")}` : "\u2014"; + + it("uses the latest version in the 'available' message when present", () => { + expect( + formatStatusText({ + status: "available", + availableUpdate: buildFakeCheckResult({ latestVersion: "1.2.3" }), + installMessage: null, + formatVersion, + }), + ).toBe("Update ready: v1.2.3"); + }); + + it("falls back to a generic 'available' message when no version is reported", () => { + expect( + formatStatusText({ + status: "available", + availableUpdate: null, + installMessage: null, + formatVersion, + }), + ).toBe("An app update is ready to install."); + }); + + it("uses the install message in the 'installed' state when present", () => { + expect( + formatStatusText({ + status: "installed", + availableUpdate: null, + installMessage: "Restart now", + formatVersion, + }), + ).toBe("Restart now"); + }); +}); diff --git a/packages/app/src/desktop/updates/desktop-app-updater.ts b/packages/app/src/desktop/updates/desktop-app-updater.ts new file mode 100644 index 000000000..1bcb7b5f6 --- /dev/null +++ b/packages/app/src/desktop/updates/desktop-app-updater.ts @@ -0,0 +1,275 @@ +import type { + DesktopAppUpdateCheckResult, + DesktopAppUpdateInstallResult, + DesktopReleaseChannel, +} from "@/desktop/updates/desktop-updates"; + +export type DesktopAppUpdateStatus = + | "idle" + | "checking" + | "pending" + | "up-to-date" + | "available" + | "installing" + | "installed" + | "error"; + +export const PENDING_RECHECK_MS = 10_000; + +export interface DesktopAppUpdaterSnapshot { + status: DesktopAppUpdateStatus; + availableUpdate: DesktopAppUpdateCheckResult | null; + errorMessage: string | null; + installMessage: string | null; + lastCheckedAt: number | null; + isChecking: boolean; + isInstalling: boolean; +} + +export interface DesktopAppUpdaterPort { + checkDesktopAppUpdate(input: { + releaseChannel: DesktopReleaseChannel; + }): Promise; + installDesktopAppUpdate(input: { + releaseChannel: DesktopReleaseChannel; + }): Promise; +} + +export interface DesktopAppUpdaterErrorReport { + error: unknown; + message: string; + logLabel: string; +} + +export interface DesktopAppUpdaterDeps { + port: DesktopAppUpdaterPort; + now(): number; + reportInstallError?(report: DesktopAppUpdaterErrorReport): void; +} + +export interface DesktopAppUpdater { + getSnapshot(): DesktopAppUpdaterSnapshot; + subscribe(listener: () => void): () => void; + checkForUpdates(options?: { + releaseChannel: DesktopReleaseChannel; + silent?: boolean; + }): Promise; + installUpdate(options: { + releaseChannel: DesktopReleaseChannel; + }): Promise; +} + +interface InternalState { + status: DesktopAppUpdateStatus; + availableUpdate: DesktopAppUpdateCheckResult | null; + errorMessage: string | null; + installMessage: string | null; + lastCheckedAt: number | null; + isInstalling: boolean; + requestVersion: number; +} + +const INITIAL_STATE: InternalState = { + status: "idle", + availableUpdate: null, + errorMessage: null, + installMessage: null, + lastCheckedAt: null, + isInstalling: false, + requestVersion: 0, +}; + +function buildSnapshot(state: InternalState): DesktopAppUpdaterSnapshot { + return { + status: state.status, + availableUpdate: state.availableUpdate, + errorMessage: state.errorMessage, + installMessage: state.installMessage, + lastCheckedAt: state.lastCheckedAt, + isChecking: state.status === "checking", + isInstalling: state.status === "installing" || state.isInstalling, + }; +} + +function getErrorMessage(error: unknown): string { + if (error instanceof Error && typeof error.message === "string") { + return error.message; + } + return String(error); +} + +export function formatStatusText(input: { + status: DesktopAppUpdateStatus; + availableUpdate: DesktopAppUpdateCheckResult | null; + installMessage: string | null; + formatVersion: (version: string | null | undefined) => string; +}): string { + const { status, availableUpdate, installMessage, formatVersion } = input; + + if (status === "checking") { + return "Checking for app updates..."; + } + + if (status === "installing") { + return "Installing app update..."; + } + + if (status === "up-to-date") { + return "App is up to date."; + } + + if (status === "pending") { + return "We'll let you know when the update is ready."; + } + + if (status === "available") { + if (availableUpdate?.latestVersion) { + return `Update ready: ${formatVersion(availableUpdate.latestVersion)}`; + } + return "An app update is ready to install."; + } + + if (status === "installed") { + return installMessage ?? "App update installed. Restart required."; + } + + if (status === "error") { + return "Failed to update app."; + } + + return "Update status has not been checked yet."; +} + +export function createDesktopAppUpdater(deps: DesktopAppUpdaterDeps): DesktopAppUpdater { + let state: InternalState = { ...INITIAL_STATE }; + let cachedSnapshot: DesktopAppUpdaterSnapshot = buildSnapshot(state); + const listeners = new Set<() => void>(); + + function commit(next: InternalState): void { + state = next; + cachedSnapshot = buildSnapshot(state); + for (const listener of listeners) { + listener(); + } + } + + async function checkForUpdates(options?: { + releaseChannel: DesktopReleaseChannel; + silent?: boolean; + }): Promise { + if (!options) { + return null; + } + const { releaseChannel, silent = false } = options; + const requestVersion = state.requestVersion + 1; + + commit({ + ...state, + requestVersion, + status: silent ? state.status : "checking", + errorMessage: null, + }); + + try { + const result = await deps.port.checkDesktopAppUpdate({ releaseChannel }); + if (requestVersion !== state.requestVersion) { + return result; + } + + const nextLastCheckedAt = deps.now(); + let nextStatus: DesktopAppUpdateStatus; + let nextAvailable: DesktopAppUpdateCheckResult | null; + + if (result.readyToInstall) { + nextStatus = "available"; + nextAvailable = result; + } else if (result.hasUpdate) { + nextStatus = "pending"; + nextAvailable = null; + } else { + nextStatus = "up-to-date"; + nextAvailable = null; + } + + commit({ + ...state, + status: nextStatus, + availableUpdate: nextAvailable, + installMessage: null, + lastCheckedAt: nextLastCheckedAt, + }); + + return result; + } catch (error) { + if (requestVersion !== state.requestVersion) { + return null; + } + + const message = getErrorMessage(error); + if (silent) { + console.warn("[DesktopUpdater] Silent update check failed", message); + commit({ ...state }); + } else { + commit({ + ...state, + status: "error", + errorMessage: message, + }); + } + return null; + } + } + + async function installUpdate(options: { + releaseChannel: DesktopReleaseChannel; + }): Promise { + commit({ + ...state, + status: "installing", + errorMessage: null, + isInstalling: true, + }); + + try { + const result = await deps.port.installDesktopAppUpdate({ + releaseChannel: options.releaseChannel, + }); + const nextLastCheckedAt = deps.now(); + commit({ + ...state, + status: result.installed ? "installed" : "up-to-date", + availableUpdate: null, + installMessage: result.message, + lastCheckedAt: nextLastCheckedAt, + isInstalling: false, + }); + return result; + } catch (error) { + const message = getErrorMessage(error); + deps.reportInstallError?.({ + error, + message: "Unable to install the desktop app update.", + logLabel: "[DesktopUpdater] Failed to install app update", + }); + commit({ + ...state, + status: "error", + errorMessage: message, + isInstalling: false, + }); + return null; + } + } + + return { + getSnapshot: () => cachedSnapshot, + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + checkForUpdates, + installUpdate, + }; +} diff --git a/packages/app/src/desktop/updates/resolve-update-callout.test.ts b/packages/app/src/desktop/updates/resolve-update-callout.test.ts new file mode 100644 index 000000000..4dfdc6cbb --- /dev/null +++ b/packages/app/src/desktop/updates/resolve-update-callout.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; +import { + resolveUpdateCalloutDescriptor, + type ResolveUpdateCalloutInput, +} from "./resolve-update-callout"; + +function input(overrides: Partial = {}): ResolveUpdateCalloutInput { + return { + isDesktopApp: true, + status: "available", + isInstalling: false, + availableUpdate: { latestVersion: "1.2.3" }, + errorMessage: null, + ...overrides, + }; +} + +describe("resolveUpdateCalloutDescriptor", () => { + it("returns null when not running as a desktop app", () => { + expect(resolveUpdateCalloutDescriptor(input({ isDesktopApp: false }))).toBeNull(); + }); + + it("returns null for idle / checking / up-to-date / pending statuses", () => { + for (const status of ["idle", "checking", "up-to-date", "pending"] as const) { + expect(resolveUpdateCalloutDescriptor(input({ status }))).toBeNull(); + } + }); + + it("builds an update-available descriptor with changelog + install actions", () => { + const descriptor = resolveUpdateCalloutDescriptor(input()); + + expect(descriptor).not.toBeNull(); + expect(descriptor?.id).toBe("desktop-update"); + expect(descriptor?.priority).toBe(200); + expect(descriptor?.testID).toBe("update-callout"); + expect(descriptor?.title).toBe("Update available"); + expect(descriptor?.variant).toBe("default"); + expect(descriptor?.showGiftIcon).toBe(true); + expect(descriptor?.body).toEqual({ kind: "available", versionLabel: "v1.2.3" }); + expect(descriptor?.actions).toEqual([ + { role: "changelog", label: "What's new" }, + { role: "install", label: "Install & restart", variant: "primary", disabled: false }, + ]); + expect(descriptor?.dismissalKey).toBe("desktop-update:available:1.2.3"); + }); + + it("normalizes a leading v in the latest version", () => { + const descriptor = resolveUpdateCalloutDescriptor( + input({ availableUpdate: { latestVersion: "v2.0.0" } }), + ); + expect(descriptor?.body).toEqual({ kind: "available", versionLabel: "v2.0.0" }); + }); + + it("omits the version label when no latest version is known", () => { + const descriptor = resolveUpdateCalloutDescriptor( + input({ availableUpdate: { latestVersion: null } }), + ); + expect(descriptor?.body).toEqual({ kind: "available", versionLabel: null }); + expect(descriptor?.dismissalKey).toBe("desktop-update:available:unknown"); + }); + + it("disables the install action and labels it Installing... while installing", () => { + const descriptor = resolveUpdateCalloutDescriptor( + input({ status: "installing", isInstalling: true }), + ); + + expect(descriptor?.title).toBe("Installing update"); + expect(descriptor?.body).toEqual({ kind: "installing" }); + expect(descriptor?.showGiftIcon).toBe(false); + expect(descriptor?.variant).toBe("default"); + expect(descriptor?.actions).toEqual([ + { role: "changelog", label: "What's new" }, + { role: "install", label: "Installing...", variant: "primary", disabled: true }, + ]); + expect(descriptor?.dismissalKey).toBe("desktop-update:installing:1.2.3"); + }); + + it("shows a retry action and surfaces the error message on error", () => { + const descriptor = resolveUpdateCalloutDescriptor( + input({ status: "error", errorMessage: "Download failed", availableUpdate: null }), + ); + + expect(descriptor?.title).toBe("Update failed"); + expect(descriptor?.body).toEqual({ kind: "error", message: "Download failed" }); + expect(descriptor?.variant).toBe("error"); + expect(descriptor?.showGiftIcon).toBe(false); + expect(descriptor?.actions).toEqual([ + { role: "changelog", label: "What's new" }, + { role: "retry", label: "Retry", variant: "primary" }, + ]); + expect(descriptor?.dismissalKey).toBe("desktop-update:error:unknown"); + }); + + it("falls back to a generic error message when none is provided", () => { + const descriptor = resolveUpdateCalloutDescriptor( + input({ status: "error", errorMessage: null, availableUpdate: null }), + ); + expect(descriptor?.body).toEqual({ kind: "error", message: "Something went wrong." }); + }); + + it("encodes status and version into the dismissal key", () => { + expect( + resolveUpdateCalloutDescriptor(input({ availableUpdate: { latestVersion: "1.2.4" } })) + ?.dismissalKey, + ).toBe("desktop-update:available:1.2.4"); + }); +}); diff --git a/packages/app/src/desktop/updates/resolve-update-callout.ts b/packages/app/src/desktop/updates/resolve-update-callout.ts new file mode 100644 index 000000000..b87e83efe --- /dev/null +++ b/packages/app/src/desktop/updates/resolve-update-callout.ts @@ -0,0 +1,93 @@ +import type { DesktopAppUpdateStatus } from "@/desktop/updates/use-desktop-app-updater"; + +export type UpdateCalloutBody = + | { kind: "available"; versionLabel: string | null } + | { kind: "installing" } + | { kind: "error"; message: string }; + +export type UpdateCalloutActionRole = "changelog" | "install" | "retry"; + +export interface UpdateCalloutActionDescriptor { + role: UpdateCalloutActionRole; + label: string; + variant?: "primary" | "secondary"; + disabled?: boolean; +} + +export interface UpdateCalloutDescriptor { + id: "desktop-update"; + dismissalKey: string; + priority: number; + title: string; + body: UpdateCalloutBody; + showGiftIcon: boolean; + variant: "default" | "error"; + actions: UpdateCalloutActionDescriptor[]; + testID: "update-callout"; +} + +export interface ResolveUpdateCalloutInput { + isDesktopApp: boolean; + status: DesktopAppUpdateStatus; + isInstalling: boolean; + availableUpdate: { latestVersion?: string | null } | null; + errorMessage: string | null; +} + +function formatVersionLabel(latestVersion: string | null | undefined): string | null { + if (!latestVersion) return null; + return `v${latestVersion.replace(/^v/i, "")}`; +} + +export function resolveUpdateCalloutDescriptor( + input: ResolveUpdateCalloutInput, +): UpdateCalloutDescriptor | null { + if (!input.isDesktopApp) return null; + if (input.status !== "available" && input.status !== "installing" && input.status !== "error") { + return null; + } + + const isError = input.status === "error"; + const isInstalling = input.isInstalling; + const isAvailable = !isInstalling && !isError; + + const latestVersion = input.availableUpdate?.latestVersion ?? null; + const dismissalKey = `desktop-update:${input.status}:${latestVersion ?? "unknown"}`; + + let title: string; + let body: UpdateCalloutBody; + if (isInstalling) { + title = "Installing update"; + body = { kind: "installing" }; + } else if (isError) { + title = "Update failed"; + body = { kind: "error", message: input.errorMessage ?? "Something went wrong." }; + } else { + title = "Update available"; + body = { kind: "available", versionLabel: formatVersionLabel(latestVersion) }; + } + + const actions: UpdateCalloutActionDescriptor[] = [{ role: "changelog", label: "What's new" }]; + if (isError) { + actions.push({ role: "retry", label: "Retry", variant: "primary" }); + } else { + actions.push({ + role: "install", + label: isInstalling ? "Installing..." : "Install & restart", + variant: "primary", + disabled: isInstalling, + }); + } + + return { + id: "desktop-update", + dismissalKey, + priority: 200, + title, + body, + showGiftIcon: isAvailable, + variant: isError ? "error" : "default", + actions, + testID: "update-callout", + }; +} diff --git a/packages/app/src/desktop/updates/test-utils/fake-desktop-app-updater-port.ts b/packages/app/src/desktop/updates/test-utils/fake-desktop-app-updater-port.ts new file mode 100644 index 000000000..7fa08b08b --- /dev/null +++ b/packages/app/src/desktop/updates/test-utils/fake-desktop-app-updater-port.ts @@ -0,0 +1,124 @@ +import type { + DesktopAppUpdateCheckResult, + DesktopAppUpdateInstallResult, + DesktopReleaseChannel, +} from "@/desktop/updates/desktop-updates"; +import type { DesktopAppUpdaterPort } from "@/desktop/updates/desktop-app-updater"; + +export interface FakeDesktopAppUpdaterPort extends DesktopAppUpdaterPort { + readonly recordedChecks: Array<{ releaseChannel: DesktopReleaseChannel }>; + readonly recordedInstalls: Array<{ releaseChannel: DesktopReleaseChannel }>; + nextCheckResult(result: DesktopAppUpdateCheckResult): void; + deferNextCheck(): { + resolve(result: DesktopAppUpdateCheckResult): void; + reject(error: unknown): void; + }; + failNextCheck(error: unknown): void; + nextInstallResult(result: DesktopAppUpdateInstallResult): void; + failNextInstall(error: unknown): void; +} + +type CheckOutcome = + | { kind: "result"; result: DesktopAppUpdateCheckResult } + | { kind: "error"; error: unknown } + | { kind: "deferred"; promise: Promise }; + +type InstallOutcome = + | { kind: "result"; result: DesktopAppUpdateInstallResult } + | { kind: "error"; error: unknown }; + +function buildCheckResult( + overrides: Partial = {}, +): DesktopAppUpdateCheckResult { + return { + hasUpdate: false, + readyToInstall: false, + currentVersion: null, + latestVersion: null, + body: null, + date: null, + ...overrides, + }; +} + +function buildInstallResult( + overrides: Partial = {}, +): DesktopAppUpdateInstallResult { + return { + installed: false, + version: null, + message: "Update completed.", + ...overrides, + }; +} + +export function createFakeDesktopAppUpdaterPort(): FakeDesktopAppUpdaterPort { + const recordedChecks: Array<{ releaseChannel: DesktopReleaseChannel }> = []; + const recordedInstalls: Array<{ releaseChannel: DesktopReleaseChannel }> = []; + const checkOutcomes: CheckOutcome[] = []; + const installOutcomes: InstallOutcome[] = []; + + return { + recordedChecks, + recordedInstalls, + nextCheckResult(result) { + checkOutcomes.push({ kind: "result", result }); + }, + deferNextCheck() { + let resolve!: (value: DesktopAppUpdateCheckResult) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + checkOutcomes.push({ kind: "deferred", promise }); + return { resolve, reject }; + }, + failNextCheck(error) { + checkOutcomes.push({ kind: "error", error }); + }, + nextInstallResult(result) { + installOutcomes.push({ kind: "result", result }); + }, + failNextInstall(error) { + installOutcomes.push({ kind: "error", error }); + }, + async checkDesktopAppUpdate(input) { + recordedChecks.push(input); + const outcome = checkOutcomes.shift(); + if (!outcome) { + return buildCheckResult(); + } + if (outcome.kind === "result") { + return outcome.result; + } + if (outcome.kind === "error") { + throw outcome.error; + } + return outcome.promise; + }, + async installDesktopAppUpdate(input) { + recordedInstalls.push(input); + const outcome = installOutcomes.shift(); + if (!outcome) { + return buildInstallResult(); + } + if (outcome.kind === "result") { + return outcome.result; + } + throw outcome.error; + }, + }; +} + +export function buildFakeCheckResult( + overrides: Partial = {}, +): DesktopAppUpdateCheckResult { + return buildCheckResult(overrides); +} + +export function buildFakeInstallResult( + overrides: Partial = {}, +): DesktopAppUpdateInstallResult { + return buildInstallResult(overrides); +} diff --git a/packages/app/src/desktop/updates/update-callout-source.test.tsx b/packages/app/src/desktop/updates/update-callout-source.test.tsx deleted file mode 100644 index 207b32302..000000000 --- a/packages/app/src/desktop/updates/update-callout-source.test.tsx +++ /dev/null @@ -1,259 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import React, { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { SidebarCalloutSlot } from "@/components/sidebar-callout-slot"; -import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context"; - -const { theme } = vi.hoisted(() => ({ - theme: { - spacing: { 0: 0, 1: 4, 2: 8, 3: 12, 4: 16 }, - iconSize: { sm: 14, md: 18 }, - borderWidth: { 1: 1 }, - borderRadius: { md: 6 }, - fontSize: { xs: 11, sm: 13 }, - fontWeight: { medium: "500", semibold: "600" }, - shadow: { md: {} }, - colors: { - surface0: "#000", - foreground: "#fff", - foregroundMuted: "#aaa", - border: "#555", - destructive: "#ff4444", - }, - }, -})); - -const asyncStorage = vi.hoisted(() => ({ - values: new Map(), - getItem: vi.fn(async (key: string) => asyncStorage.values.get(key) ?? null), - setItem: vi.fn(async (key: string, value: string) => { - asyncStorage.values.set(key, value); - }), -})); - -vi.mock("@react-native-async-storage/async-storage", () => ({ - default: asyncStorage, -})); - -vi.mock("react-native-unistyles", () => ({ - StyleSheet: { - create: (factory: unknown) => - typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory, - }, - useUnistyles: () => ({ theme }), -})); - -vi.mock("lucide-react-native", () => { - const X = (props: Record) => React.createElement("span", props); - const Gift = (props: Record) => - React.createElement("span", { ...props, "data-icon": "Gift" }); - return { Gift, X }; -}); - -vi.mock("@/utils/open-external-url", () => ({ - openExternalUrl: vi.fn(async () => {}), -})); - -const updaterState = vi.hoisted(() => ({ - value: { - isDesktopApp: true, - status: "available" as - | "idle" - | "checking" - | "pending" - | "up-to-date" - | "available" - | "installing" - | "installed" - | "error", - availableUpdate: { - hasUpdate: true, - readyToInstall: true, - currentVersion: "1.2.2", - latestVersion: "1.2.3", - body: null, - date: null, - } as unknown, - errorMessage: null as string | null, - lastCheckedAt: null as number | null, - isChecking: false, - isInstalling: false, - statusText: "", - checkForUpdates: vi.fn(async () => null), - installUpdate: vi.fn(async () => null), - }, -})); - -vi.mock("@/desktop/updates/use-desktop-app-updater", () => ({ - useDesktopAppUpdater: () => updaterState.value, -})); - -vi.stubGlobal("React", React); -vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - -import { UpdateCalloutSource } from "./update-callout-source"; - -function resetUpdaterState(): void { - updaterState.value = { - isDesktopApp: true, - status: "available", - availableUpdate: { - hasUpdate: true, - readyToInstall: true, - currentVersion: "1.2.2", - latestVersion: "1.2.3", - body: null, - date: null, - }, - errorMessage: null, - lastCheckedAt: null, - isChecking: false, - isInstalling: false, - statusText: "", - checkForUpdates: vi.fn(async () => null), - installUpdate: vi.fn(async () => null), - }; -} - -function UpdateCalloutHarness({ withSlot = true }: { withSlot?: boolean }) { - return ( - - - {withSlot ? : null} - - ); -} - -async function renderHarness(root: Root, withSlot = true): Promise { - await act(async () => { - root.render(); - await Promise.resolve(); - await Promise.resolve(); - }); -} - -describe("UpdateCalloutSource", () => { - let root: Root | null = null; - let container: HTMLElement | null = null; - - beforeEach(() => { - resetUpdaterState(); - asyncStorage.values.clear(); - asyncStorage.getItem.mockClear(); - asyncStorage.setItem.mockClear(); - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - }); - - afterEach(() => { - if (root) { - act(() => { - root?.unmount(); - }); - } - root = null; - container?.remove(); - container = null; - }); - - it("registers an update-available sidebar callout with changelog + install actions", async () => { - await renderHarness(root!); - - expect(container?.querySelector('[data-testid="update-callout"]')).not.toBeNull(); - expect(container?.textContent).toContain("Update available"); - expect(container?.textContent).toContain("1.2.3"); - expect(container?.textContent).toContain( - "Upgrading the app will stop running agents and close terminal sessions.", - ); - expect(container?.querySelector('[data-icon="Gift"]')).not.toBeNull(); - expect( - container?.querySelector('[data-testid="update-callout-action-0"]')?.textContent, - ).toContain("What's new"); - expect( - container?.querySelector('[data-testid="update-callout-action-1"]')?.textContent, - ).toContain("Install & restart"); - }); - - it("renders only through the sidebar slot", async () => { - await renderHarness(root!, false); - - expect(container?.querySelector('[data-testid="update-callout"]')).toBeNull(); - }); - - it("disables the install action and shows Installing... while installing", async () => { - updaterState.value = { - ...updaterState.value, - status: "installing", - isInstalling: true, - }; - await renderHarness(root!); - - expect( - container?.querySelector('[data-testid="update-callout-action-1"]')?.textContent, - ).toContain("Installing"); - }); - - it("shows a retry action on error and surfaces the error message", async () => { - updaterState.value = { - ...updaterState.value, - status: "error", - errorMessage: "Download failed", - availableUpdate: null, - }; - await renderHarness(root!); - - expect(container?.textContent).toContain("Update failed"); - expect(container?.textContent).toContain("Download failed"); - expect( - container?.querySelector('[data-testid="update-callout-action-1"]')?.textContent, - ).toContain("Retry"); - }); - - it("renders nothing when not running as a desktop app", async () => { - updaterState.value = { ...updaterState.value, isDesktopApp: false }; - await renderHarness(root!); - - expect(container?.querySelector('[data-testid="update-callout"]')).toBeNull(); - }); - - it("renders nothing when status is idle / checking / up-to-date / pending", async () => { - for (const status of ["idle", "checking", "up-to-date", "pending"] as const) { - resetUpdaterState(); - updaterState.value = { ...updaterState.value, status }; - const innerContainer = document.createElement("div"); - document.body.appendChild(innerContainer); - const innerRoot = createRoot(innerContainer); - await act(async () => { - innerRoot.render(); - await Promise.resolve(); - await Promise.resolve(); - }); - expect(innerContainer.querySelector('[data-testid="update-callout"]')).toBeNull(); - act(() => { - innerRoot.unmount(); - }); - innerContainer.remove(); - } - }); - - it("dismisses the registered update callout", async () => { - await renderHarness(root!); - - const dismiss = container?.querySelector( - '[data-testid="update-callout-dismiss"]', - ) as HTMLElement | null; - expect(dismiss).not.toBeNull(); - act(() => { - dismiss?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - }); - expect(container?.querySelector('[data-testid="update-callout"]')).toBeNull(); - expect(asyncStorage.setItem).toHaveBeenCalledWith( - "@paseo:sidebar-callout-dismissals", - JSON.stringify(["desktop-update:available:1.2.3"]), - ); - }); -}); diff --git a/packages/app/src/desktop/updates/update-callout-source.tsx b/packages/app/src/desktop/updates/update-callout-source.tsx index 1c4143ada..e3329a3dd 100644 --- a/packages/app/src/desktop/updates/update-callout-source.tsx +++ b/packages/app/src/desktop/updates/update-callout-source.tsx @@ -6,6 +6,11 @@ import { SidebarCalloutDescriptionText, } from "@/components/sidebar-callout"; import { useSidebarCallouts } from "@/contexts/sidebar-callout-context"; +import { + resolveUpdateCalloutDescriptor, + type UpdateCalloutActionDescriptor, + type UpdateCalloutBody, +} from "@/desktop/updates/resolve-update-callout"; import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater"; import { useStableEvent } from "@/hooks/use-stable-event"; import { openExternalUrl } from "@/utils/open-external-url"; @@ -13,47 +18,22 @@ import { openExternalUrl } from "@/utils/open-external-url"; const CHECK_INTERVAL_MS = 30 * 60 * 1000; const CHANGELOG_URL = "https://paseo.sh/changelog"; -function resolveUpdateCalloutTitle(args: { isInstalling: boolean; isError: boolean }): string { - if (args.isInstalling) return "Installing update"; - if (args.isError) return "Update failed"; - return "Update available"; +function renderBody(body: UpdateCalloutBody): ReactNode { + if (body.kind === "installing") return "Installing and restarting..."; + if (body.kind === "error") return body.message; + return ; } -function resolveUpdateCalloutDescription(args: { - isInstalling: boolean; - isError: boolean; - errorMessage: string | null; - latestVersion: string | undefined; -}): ReactNode { - if (args.isInstalling) return "Installing and restarting..."; - if (args.isError) return args.errorMessage ?? "Something went wrong."; - if (args.latestVersion) { - return ( - - ); - } - return ; -} - -function buildUpdateCalloutActions(args: { - isInstalling: boolean; - isError: boolean; - openChangelog: () => void; - retry: () => void; - install: () => void; -}): SidebarCalloutAction[] { - const actions: SidebarCalloutAction[] = [{ label: "What's new", onPress: args.openChangelog }]; - if (args.isError) { - actions.push({ label: "Retry", onPress: args.retry, variant: "primary" }); - } else { - actions.push({ - label: args.isInstalling ? "Installing..." : "Install & restart", - onPress: args.install, - variant: "primary", - disabled: args.isInstalling, - }); - } - return actions; +function materializeActions( + actions: readonly UpdateCalloutActionDescriptor[], + handlers: { changelog: () => void; install: () => void; retry: () => void }, +): SidebarCalloutAction[] { + return actions.map((action) => ({ + label: action.label, + onPress: handlers[action.role], + variant: action.variant, + disabled: action.disabled, + })); } export function UpdateCalloutSource() { @@ -96,46 +76,34 @@ export function UpdateCalloutSource() { }, [isDesktopApp, checkForUpdates]); useEffect(() => { - if (!isDesktopApp) { - return; - } - if (status !== "available" && status !== "installing" && status !== "error") { - return; - } - - const isError = status === "error"; - const isAvailable = !isInstalling && !isError; - - const title = resolveUpdateCalloutTitle({ isInstalling, isError }); - const description = resolveUpdateCalloutDescription({ + const descriptor = resolveUpdateCalloutDescriptor({ + isDesktopApp, + status, isInstalling, - isError, + availableUpdate, errorMessage, - latestVersion: availableUpdate?.latestVersion ?? undefined, - }); - const actions = buildUpdateCalloutActions({ - isInstalling, - isError, - openChangelog, - retry, - install, }); + if (!descriptor) return; return callouts.show({ - id: "desktop-update", - dismissalKey: `desktop-update:${status}:${availableUpdate?.latestVersion ?? "unknown"}`, - priority: 200, - title, - description, - icon: isAvailable ? ( + id: descriptor.id, + dismissalKey: descriptor.dismissalKey, + priority: descriptor.priority, + title: descriptor.title, + description: renderBody(descriptor.body), + icon: descriptor.showGiftIcon ? ( ) : undefined, - variant: isError ? "error" : "default", - actions, - testID: "update-callout", + variant: descriptor.variant, + actions: materializeActions(descriptor.actions, { + changelog: openChangelog, + install, + retry, + }), + testID: descriptor.testID, }); }, [ - availableUpdate?.latestVersion, + availableUpdate, callouts, errorMessage, install, diff --git a/packages/app/src/desktop/updates/use-desktop-app-updater.test.tsx b/packages/app/src/desktop/updates/use-desktop-app-updater.test.tsx deleted file mode 100644 index 5efbbe980..000000000 --- a/packages/app/src/desktop/updates/use-desktop-app-updater.test.tsx +++ /dev/null @@ -1,94 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import React from "react"; -import { act, renderHook, waitFor } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { useDesktopAppUpdater } from "./use-desktop-app-updater"; - -const desktopUpdatesMock = vi.hoisted(() => ({ - shouldShowDesktopUpdateSection: vi.fn(() => true), - checkDesktopAppUpdate: vi.fn(async () => ({ - hasUpdate: false, - readyToInstall: false, - latestVersion: null, - })), - installDesktopAppUpdate: vi.fn(async () => ({ - installed: false, - message: null, - })), - formatVersionWithPrefix: vi.fn((version: string | null) => version ?? "\u2014"), -})); - -const settingsState = vi.hoisted(() => ({ - releaseChannel: "stable" as "stable" | "beta", -})); - -vi.mock("@/desktop/updates/desktop-updates", () => desktopUpdatesMock); - -vi.mock("@/desktop/settings/desktop-settings", () => ({ - useDesktopSettings: () => ({ - settings: { - releaseChannel: settingsState.releaseChannel, - daemon: { manageBuiltInDaemon: true, keepRunningAfterQuit: true }, - }, - isLoading: false, - error: null, - updateSettings: vi.fn(async () => {}), - }), -})); - -vi.mock("@/contexts/toast-context", () => ({ - useToast: () => ({ error: vi.fn(), show: vi.fn(), copied: vi.fn() }), -})); - -function createQueryClient(): QueryClient { - return new QueryClient({ - defaultOptions: { - queries: { retry: false }, - mutations: { retry: false }, - }, - }); -} - -function renderUpdaterHook() { - const queryClient = createQueryClient(); - const wrapper = ({ children }: { children: React.ReactNode }) => - React.createElement(QueryClientProvider, { client: queryClient }, children); - - return renderHook(() => useDesktopAppUpdater(), { wrapper }); -} - -describe("useDesktopAppUpdater", () => { - beforeEach(() => { - settingsState.releaseChannel = "stable"; - desktopUpdatesMock.checkDesktopAppUpdate.mockClear(); - desktopUpdatesMock.installDesktopAppUpdate.mockClear(); - }); - - it("uses the effective desktop release channel when checking for updates", async () => { - settingsState.releaseChannel = "beta"; - - renderUpdaterHook(); - - await waitFor(() => { - expect(desktopUpdatesMock.checkDesktopAppUpdate).toHaveBeenCalledWith({ - releaseChannel: "beta", - }); - }); - }); - - it("uses the effective desktop release channel when installing updates", async () => { - settingsState.releaseChannel = "beta"; - const { result } = renderUpdaterHook(); - - await act(async () => { - await result.current.installUpdate(); - }); - - expect(desktopUpdatesMock.installDesktopAppUpdate).toHaveBeenCalledWith({ - releaseChannel: "beta", - }); - }); -}); diff --git a/packages/app/src/desktop/updates/use-desktop-app-updater.ts b/packages/app/src/desktop/updates/use-desktop-app-updater.ts index c67f146ee..5522213f1 100644 --- a/packages/app/src/desktop/updates/use-desktop-app-updater.ts +++ b/packages/app/src/desktop/updates/use-desktop-app-updater.ts @@ -1,5 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { useMutation } from "@tanstack/react-query"; +import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react"; import { checkDesktopAppUpdate, formatVersionWithPrefix, @@ -10,18 +9,14 @@ import { } from "@/desktop/updates/desktop-updates"; import { useDesktopSettings } from "@/desktop/settings/desktop-settings"; import { useDesktopIpcErrorReporter } from "@/desktop/hooks/desktop-ipc-error"; +import { + PENDING_RECHECK_MS, + createDesktopAppUpdater, + formatStatusText, + type DesktopAppUpdateStatus, +} from "@/desktop/updates/desktop-app-updater"; -export type DesktopAppUpdateStatus = - | "idle" - | "checking" - | "pending" - | "up-to-date" - | "available" - | "installing" - | "installed" - | "error"; - -const PENDING_RECHECK_MS = 10_000; +export type { DesktopAppUpdateStatus }; export interface UseDesktopAppUpdaterReturn { isDesktopApp: boolean; @@ -36,142 +31,57 @@ export interface UseDesktopAppUpdaterReturn { installUpdate: () => Promise; } -function getErrorMessage(error: unknown): string { - if (error instanceof Error && typeof error.message === "string") { - return error.message; - } - return String(error); -} - -function formatStatusText(input: { - status: DesktopAppUpdateStatus; - availableUpdate: DesktopAppUpdateCheckResult | null; - installMessage: string | null; -}): string { - const { status, availableUpdate, installMessage } = input; - - if (status === "checking") { - return "Checking for app updates..."; - } - - if (status === "installing") { - return "Installing app update..."; - } - - if (status === "up-to-date") { - return "App is up to date."; - } - - if (status === "pending") { - return "We'll let you know when the update is ready."; - } - - if (status === "available") { - if (availableUpdate?.latestVersion) { - return `Update ready: ${formatVersionWithPrefix(availableUpdate.latestVersion)}`; - } - return "An app update is ready to install."; - } - - if (status === "installed") { - return installMessage ?? "App update installed. Restart required."; - } - - if (status === "error") { - return "Failed to update app."; - } - - return "Update status has not been checked yet."; -} - export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn { const isDesktopApp = shouldShowDesktopUpdateSection(); const { settings: desktopSettings } = useDesktopSettings(); const releaseChannel = desktopSettings.releaseChannel; const reportError = useDesktopIpcErrorReporter(); - const requestVersionRef = useRef(0); - const [status, setStatus] = useState("idle"); - const [availableUpdate, setAvailableUpdate] = useState(null); - const [errorMessage, setErrorMessage] = useState(null); - const [installMessage, setInstallMessage] = useState(null); - const [lastCheckedAt, setLastCheckedAt] = useState(null); - const { mutateAsync: installAppUpdate, isPending: isInstallingAppUpdate } = useMutation< - DesktopAppUpdateInstallResult, - Error - >({ - mutationFn: () => installDesktopAppUpdate({ releaseChannel }), - onError: (error) => { - reportError({ - error, - message: "Unable to install the desktop app update.", - logLabel: "[DesktopUpdater] Failed to install app update", - }); - }, - }); + + const updater = useMemo( + () => + createDesktopAppUpdater({ + port: { + checkDesktopAppUpdate, + installDesktopAppUpdate, + }, + now: () => Date.now(), + reportInstallError: reportError, + }), + [reportError], + ); + + const snapshot = useSyncExternalStore( + updater.subscribe, + updater.getSnapshot, + updater.getSnapshot, + ); const checkForUpdates = useCallback( async (options: { silent?: boolean } = {}) => { if (!isDesktopApp) { return null; } - - const requestVersion = requestVersionRef.current + 1; - requestVersionRef.current = requestVersion; - - if (!options.silent) { - setStatus("checking"); - } - setErrorMessage(null); - - try { - const result = await checkDesktopAppUpdate({ releaseChannel }); - if (requestVersion !== requestVersionRef.current) { - return result; - } - - setInstallMessage(null); - setLastCheckedAt(Date.now()); - - if (result.readyToInstall) { - setAvailableUpdate(result); - setStatus("available"); - } else if (result.hasUpdate) { - setAvailableUpdate(null); - setStatus("pending"); - } else { - setAvailableUpdate(null); - setStatus("up-to-date"); - } - - return result; - } catch (error) { - if (requestVersion !== requestVersionRef.current) { - return null; - } - - const message = getErrorMessage(error); - if (options.silent) { - console.warn("[DesktopUpdater] Silent update check failed", message); - } else { - setStatus("error"); - setErrorMessage(message); - } - return null; - } + return updater.checkForUpdates({ releaseChannel, silent: options.silent }); }, - [isDesktopApp, releaseChannel], + [isDesktopApp, releaseChannel, updater], ); + const installUpdate = useCallback(async () => { + if (!isDesktopApp) { + return null; + } + return updater.installUpdate({ releaseChannel }); + }, [isDesktopApp, releaseChannel, updater]); + useEffect(() => { if (!isDesktopApp) { return; } - void checkForUpdates({ silent: true }); }, [checkForUpdates, isDesktopApp]); useEffect(() => { - if (!isDesktopApp || status !== "pending") { + if (!isDesktopApp || snapshot.status !== "pending") { return undefined; } @@ -182,52 +92,22 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn { return () => { clearInterval(intervalId); }; - }, [checkForUpdates, isDesktopApp, status]); - - const installUpdate = useCallback(async () => { - if (!isDesktopApp) { - return null; - } - - setStatus("installing"); - setErrorMessage(null); - - try { - const result = await installAppUpdate(); - setLastCheckedAt(Date.now()); - - if (result.installed) { - setAvailableUpdate(null); - setInstallMessage(result.message); - setStatus("installed"); - } else { - setAvailableUpdate(null); - setInstallMessage(result.message); - setStatus("up-to-date"); - } - - return result; - } catch (error) { - const message = getErrorMessage(error); - setStatus("error"); - setErrorMessage(message); - return null; - } - }, [installAppUpdate, isDesktopApp]); + }, [checkForUpdates, isDesktopApp, snapshot.status]); return { isDesktopApp, - status, + status: snapshot.status, statusText: formatStatusText({ - status, - availableUpdate, - installMessage, + status: snapshot.status, + availableUpdate: snapshot.availableUpdate, + installMessage: snapshot.installMessage, + formatVersion: formatVersionWithPrefix, }), - availableUpdate, - errorMessage, - lastCheckedAt, - isChecking: status === "checking", - isInstalling: status === "installing" || isInstallingAppUpdate, + availableUpdate: snapshot.availableUpdate, + errorMessage: snapshot.errorMessage, + lastCheckedAt: snapshot.lastCheckedAt, + isChecking: snapshot.isChecking, + isInstalling: snapshot.isInstalling, checkForUpdates, installUpdate, }; diff --git a/packages/app/src/git/checkout-status-cache.test.ts b/packages/app/src/git/checkout-status-cache.test.ts new file mode 100644 index 000000000..334d67a4e --- /dev/null +++ b/packages/app/src/git/checkout-status-cache.test.ts @@ -0,0 +1,124 @@ +import { QueryClient } from "@tanstack/react-query"; +import { describe, expect, it, vi } from "vitest"; +import type { CheckoutStatusUpdate } from "@getpaseo/protocol/messages"; +import { checkoutStatusQueryKey } from "@/git/query-keys"; +import { + applyCheckoutStatusUpdate, + type CheckoutStatusPayload, + peekOrFetchCheckoutStatus, +} from "./checkout-status-cache"; + +const serverId = "server-1"; +const cwd = "/repo"; + +function checkoutStatus(overrides: Partial = {}): CheckoutStatusPayload { + return { + cwd, + error: null, + requestId: "checkout-status-1", + isGit: true, + isPaseoOwnedWorktree: false, + repoRoot: cwd, + currentBranch: "main", + isDirty: false, + baseRef: "origin/main", + aheadBehind: { ahead: 0, behind: 0 }, + aheadOfOrigin: 0, + behindOfOrigin: 0, + hasRemote: true, + remoteUrl: "git@github.com:getpaseo/paseo.git", + ...overrides, + } as CheckoutStatusPayload; +} + +function checkoutStatusUpdate(payload: CheckoutStatusPayload): CheckoutStatusUpdate { + return { type: "checkout_status_update", payload }; +} + +function createQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); +} + +describe("peekOrFetchCheckoutStatus", () => { + it("fetches from the client and writes the result to the cache on a cold read", async () => { + const queryClient = createQueryClient(); + const fetched = checkoutStatus({ requestId: "cold-read" }); + const client = { getCheckoutStatus: vi.fn(async () => fetched) }; + + const result = await peekOrFetchCheckoutStatus({ queryClient, client, serverId, cwd }); + + expect(result).toEqual(fetched); + expect(client.getCheckoutStatus).toHaveBeenCalledExactlyOnceWith(cwd); + expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, cwd))).toEqual(fetched); + }); + + it("returns the cached snapshot without calling the client when the cache already has data", async () => { + const queryClient = createQueryClient(); + const cached = checkoutStatus({ requestId: "cached" }); + queryClient.setQueryData(checkoutStatusQueryKey(serverId, cwd), cached); + const client = { + getCheckoutStatus: vi.fn(async () => checkoutStatus({ requestId: "uncached" })), + }; + + const result = await peekOrFetchCheckoutStatus({ queryClient, client, serverId, cwd }); + + expect(result).toEqual(cached); + expect(client.getCheckoutStatus).not.toHaveBeenCalled(); + }); +}); + +describe("applyCheckoutStatusUpdate", () => { + it("writes the pushed payload into the cache key for the matching cwd", () => { + const queryClient = createQueryClient(); + const pushed = checkoutStatus({ + requestId: "server-push", + currentBranch: "pushed-branch", + isDirty: true, + aheadBehind: { ahead: 2, behind: 1 }, + aheadOfOrigin: 2, + behindOfOrigin: 1, + }); + + applyCheckoutStatusUpdate({ + queryClient, + serverId, + cwd, + message: checkoutStatusUpdate(pushed), + }); + + expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, cwd))).toEqual(pushed); + }); + + it("ignores updates whose payload cwd does not match the subscribed cwd", () => { + const queryClient = createQueryClient(); + const otherCwd = "/other-repo"; + const otherCached = checkoutStatus({ + cwd: otherCwd, + repoRoot: otherCwd, + requestId: "other-cached", + currentBranch: "other-main", + }); + queryClient.setQueryData(checkoutStatusQueryKey(serverId, otherCwd), otherCached); + + applyCheckoutStatusUpdate({ + queryClient, + serverId, + cwd, + message: checkoutStatusUpdate( + checkoutStatus({ + cwd: otherCwd, + repoRoot: otherCwd, + requestId: "server-push", + currentBranch: "pushed-branch", + }), + ), + }); + + expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, cwd))).toBeUndefined(); + expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, otherCwd))).toEqual( + otherCached, + ); + }); +}); diff --git a/packages/app/src/git/checkout-status-cache.ts b/packages/app/src/git/checkout-status-cache.ts new file mode 100644 index 000000000..471db1e4b --- /dev/null +++ b/packages/app/src/git/checkout-status-cache.ts @@ -0,0 +1,48 @@ +import type { QueryClient } from "@tanstack/react-query"; +import type { CheckoutStatusResponse, CheckoutStatusUpdate } from "@getpaseo/protocol/messages"; +import { checkoutStatusQueryKey } from "@/git/query-keys"; + +export type CheckoutStatusPayload = CheckoutStatusResponse["payload"]; + +export interface CheckoutStatusClient { + getCheckoutStatus: (cwd: string) => Promise; +} + +export async function peekOrFetchCheckoutStatus({ + queryClient, + client, + serverId, + cwd, +}: { + queryClient: QueryClient; + client: CheckoutStatusClient; + serverId: string; + cwd: string; +}): Promise { + const queryKey = checkoutStatusQueryKey(serverId, cwd); + const cached = queryClient.getQueryData(queryKey); + if (cached) { + return cached; + } + + const snapshot = await client.getCheckoutStatus(cwd); + queryClient.setQueryData(queryKey, snapshot); + return snapshot; +} + +export function applyCheckoutStatusUpdate({ + queryClient, + serverId, + cwd, + message, +}: { + queryClient: QueryClient; + serverId: string; + cwd: string; + message: CheckoutStatusUpdate; +}): void { + if (message.payload.cwd !== cwd) { + return; + } + queryClient.setQueryData(checkoutStatusQueryKey(serverId, cwd), message.payload); +} diff --git a/packages/app/src/git/use-status-query.test.ts b/packages/app/src/git/use-status-query.test.ts deleted file mode 100644 index a2f90fa1d..000000000 --- a/packages/app/src/git/use-status-query.test.ts +++ /dev/null @@ -1,318 +0,0 @@ -import React, { act } from "react"; -import { createRoot } from "react-dom/client"; -import { JSDOM } from "jsdom"; -import { - QueryClient, - QueryClientProvider, - focusManager, - onlineManager, -} from "@tanstack/react-query"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { CheckoutStatusResponse } from "@getpaseo/protocol/messages"; -import { checkoutStatusQueryKey } from "@/git/query-keys"; -import { useCheckoutStatusQuery } from "./use-status-query"; - -type CheckoutStatusPayload = CheckoutStatusResponse["payload"]; - -const { mockRuntime, mockClient, checkoutStatusUpdateHandlers } = vi.hoisted(() => { - const hoistedHandlers = new Set<(message: unknown) => void>(); - const hoistedClient = { - getCheckoutStatus: vi.fn(), - on: vi.fn((type: string, handler: (message: unknown) => void) => { - if (type !== "checkout_status_update") { - return () => {}; - } - hoistedHandlers.add(handler); - return () => { - hoistedHandlers.delete(handler); - }; - }), - }; - - return { - mockClient: hoistedClient, - checkoutStatusUpdateHandlers: hoistedHandlers, - mockRuntime: { - client: hoistedClient, - isConnected: true, - }, - }; -}); - -vi.mock("@/runtime/host-runtime", () => ({ - useHostRuntimeClient: () => mockRuntime.client, - useHostRuntimeIsConnected: () => mockRuntime.isConnected, -})); - -const serverId = "server-1"; -const cwd = "/repo"; - -function checkoutStatus(overrides: Partial = {}): CheckoutStatusPayload { - return { - cwd, - error: null, - requestId: "checkout-status-1", - isGit: true, - isPaseoOwnedWorktree: false, - repoRoot: cwd, - currentBranch: "main", - isDirty: false, - baseRef: "origin/main", - aheadBehind: { ahead: 0, behind: 0 }, - aheadOfOrigin: 0, - behindOfOrigin: 0, - hasRemote: true, - remoteUrl: "git@github.com:getpaseo/paseo.git", - ...overrides, - } as CheckoutStatusPayload; -} - -function createTestQueryClient(): QueryClient { - return new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, - }); -} - -function renderCheckoutStatusHook({ - queryClient = createTestQueryClient(), - hookCwd = cwd, -}: { - queryClient?: QueryClient; - hookCwd?: string; -} = {}) { - let latest: ReturnType | null = null; - - function Probe() { - latest = useCheckoutStatusQuery({ serverId, cwd: hookCwd }); - return null; - } - - const container = document.getElementById("root"); - if (!container) { - throw new Error("Missing root container"); - } - - const root = createRoot(container); - - return { - get latest() { - if (!latest) { - throw new Error("Expected hook result"); - } - return latest; - }, - async mount() { - await act(async () => { - root.render( - React.createElement( - QueryClientProvider, - { client: queryClient }, - React.createElement(Probe), - ), - ); - }); - }, - async unmount() { - await act(async () => { - root.unmount(); - }); - }, - }; -} - -async function waitForExpectation(assertion: () => void): Promise { - let lastError: unknown; - - for (let attempt = 0; attempt < 60; attempt += 1) { - try { - assertion(); - return; - } catch (error) { - lastError = error; - await act(async () => { - vi.advanceTimersByTime(10); - await Promise.resolve(); - }); - } - } - - throw lastError; -} - -async function emitCheckoutStatusUpdate(payload: CheckoutStatusPayload): Promise { - await act(async () => { - for (const handler of checkoutStatusUpdateHandlers) { - handler({ - type: "checkout_status_update", - payload, - }); - } - await Promise.resolve(); - }); -} - -describe("useCheckoutStatusQuery", () => { - beforeEach(() => { - vi.useFakeTimers(); - - const dom = new JSDOM("
", { - url: "http://localhost", - }); - - Object.defineProperty(globalThis, "document", { - value: dom.window.document, - configurable: true, - }); - Object.defineProperty(globalThis, "window", { - value: dom.window, - configurable: true, - }); - Object.defineProperty(globalThis, "navigator", { - value: dom.window.navigator, - configurable: true, - }); - Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { - value: true, - configurable: true, - }); - - mockRuntime.client = mockClient; - mockRuntime.isConnected = true; - mockClient.getCheckoutStatus.mockReset(); - mockClient.on.mockClear(); - checkoutStatusUpdateHandlers.clear(); - }); - - afterEach(() => { - focusManager.setFocused(undefined); - onlineManager.setOnline(true); - vi.useRealTimers(); - }); - - it("does not schedule polling after the cold read", async () => { - mockClient.getCheckoutStatus.mockResolvedValue(checkoutStatus()); - const hook = renderCheckoutStatusHook(); - - await hook.mount(); - await waitForExpectation(() => { - expect(hook.latest.status?.currentBranch).toBe("main"); - }); - - await act(async () => { - vi.advanceTimersByTime(60_000); - }); - - expect(mockClient.getCheckoutStatus).toHaveBeenCalledTimes(1); - }); - - it("returns the cached snapshot without calling the daemon", async () => { - const queryClient = createTestQueryClient(); - const cached = checkoutStatus({ requestId: "cached" }); - queryClient.setQueryData(checkoutStatusQueryKey(serverId, cwd), cached); - mockClient.getCheckoutStatus.mockResolvedValue(checkoutStatus({ requestId: "uncached" })); - const hook = renderCheckoutStatusHook({ queryClient }); - - await hook.mount(); - - expect(hook.latest.status).toEqual(cached); - expect(mockClient.getCheckoutStatus).not.toHaveBeenCalled(); - }); - - it("fetches once on a cold read and uses cache on remount", async () => { - const queryClient = createTestQueryClient(); - const fetched = checkoutStatus({ requestId: "cold-read" }); - mockClient.getCheckoutStatus.mockResolvedValue(fetched); - const firstHook = renderCheckoutStatusHook({ queryClient }); - - await firstHook.mount(); - await waitForExpectation(() => { - expect(firstHook.latest.status).toEqual(fetched); - }); - await firstHook.unmount(); - - const secondHook = renderCheckoutStatusHook({ queryClient }); - await secondHook.mount(); - - expect(secondHook.latest.status).toEqual(fetched); - expect(mockClient.getCheckoutStatus).toHaveBeenCalledTimes(1); - }); - - it("does not refetch checkout status on focus or reconnect", async () => { - mockClient.getCheckoutStatus.mockResolvedValue(checkoutStatus()); - const hook = renderCheckoutStatusHook(); - - await hook.mount(); - await waitForExpectation(() => { - expect(hook.latest.status?.currentBranch).toBe("main"); - }); - - await act(async () => { - focusManager.setFocused(false); - focusManager.setFocused(true); - onlineManager.setOnline(false); - onlineManager.setOnline(true); - await Promise.resolve(); - }); - - expect(mockClient.getCheckoutStatus).toHaveBeenCalledTimes(1); - }); - - it("server-pushed checkout status update writes into the React Query cache at checkoutStatusQueryKey(serverId, cwd)", async () => { - const queryClient = createTestQueryClient(); - mockClient.getCheckoutStatus.mockResolvedValue(checkoutStatus({ requestId: "initial" })); - const hook = renderCheckoutStatusHook({ queryClient }); - - await hook.mount(); - await waitForExpectation(() => { - expect(hook.latest.status?.requestId).toBe("initial"); - }); - - mockClient.getCheckoutStatus.mockClear(); - const pushed = checkoutStatus({ - requestId: "server-push", - currentBranch: "pushed-branch", - isDirty: true, - aheadBehind: { ahead: 2, behind: 1 }, - aheadOfOrigin: 2, - behindOfOrigin: 1, - }); - await emitCheckoutStatusUpdate(pushed); - - expect(mockClient.getCheckoutStatus).not.toHaveBeenCalled(); - expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, cwd))).toEqual(pushed); - }); - - it("server push only updates the matching cwd's cache key, not other workspaces", async () => { - const queryClient = createTestQueryClient(); - const otherCwd = "/other-repo"; - const otherCached = checkoutStatus({ - cwd: otherCwd, - repoRoot: otherCwd, - requestId: "other-cached", - currentBranch: "other-main", - }); - queryClient.setQueryData(checkoutStatusQueryKey(serverId, otherCwd), otherCached); - mockClient.getCheckoutStatus.mockResolvedValue(checkoutStatus({ requestId: "initial" })); - const hook = renderCheckoutStatusHook({ queryClient }); - - await hook.mount(); - await waitForExpectation(() => { - expect(hook.latest.status?.requestId).toBe("initial"); - }); - - const pushed = checkoutStatus({ - requestId: "server-push", - currentBranch: "pushed-branch", - }); - await emitCheckoutStatusUpdate(pushed); - - expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, cwd))).toEqual(pushed); - expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, otherCwd))).toEqual( - otherCached, - ); - }); -}); diff --git a/packages/app/src/git/use-status-query.ts b/packages/app/src/git/use-status-query.ts index 7f463cc07..115260a51 100644 --- a/packages/app/src/git/use-status-query.ts +++ b/packages/app/src/git/use-status-query.ts @@ -1,8 +1,15 @@ -import { type QueryClient, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect } from "react"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; -import type { CheckoutStatusResponse } from "@getpaseo/protocol/messages"; import { checkoutStatusQueryKey } from "@/git/query-keys"; +import { + applyCheckoutStatusUpdate, + type CheckoutStatusClient, + type CheckoutStatusPayload, + peekOrFetchCheckoutStatus, +} from "./checkout-status-cache"; + +export type { CheckoutStatusPayload } from "./checkout-status-cache"; export const CHECKOUT_STATUS_STALE_TIME = 15_000; @@ -11,12 +18,6 @@ interface UseCheckoutStatusQueryOptions { cwd: string; } -export type CheckoutStatusPayload = CheckoutStatusResponse["payload"]; - -interface CheckoutStatusClient { - getCheckoutStatus: (cwd: string) => Promise; -} - function fetchCheckoutStatus( client: CheckoutStatusClient, cwd: string, @@ -24,28 +25,6 @@ function fetchCheckoutStatus( return client.getCheckoutStatus(cwd); } -async function peekOrFetchSnapshot({ - queryClient, - client, - serverId, - cwd, -}: { - queryClient: QueryClient; - client: CheckoutStatusClient; - serverId: string; - cwd: string; -}): Promise { - const queryKey = checkoutStatusQueryKey(serverId, cwd); - const cached = queryClient.getQueryData(queryKey); - if (cached) { - return cached; - } - - const snapshot = await fetchCheckoutStatus(client, cwd); - queryClient.setQueryData(queryKey, snapshot); - return snapshot; -} - export function useCheckoutStatusQuery({ serverId, cwd }: UseCheckoutStatusQueryOptions) { const queryClient = useQueryClient(); const client = useHostRuntimeClient(serverId); @@ -57,10 +36,7 @@ export function useCheckoutStatusQuery({ serverId, cwd }: UseCheckoutStatusQuery } return client.on("checkout_status_update", (message) => { - if (message.payload.cwd !== cwd) { - return; - } - queryClient.setQueryData(checkoutStatusQueryKey(serverId, cwd), message.payload); + applyCheckoutStatusUpdate({ queryClient, serverId, cwd, message }); }); }, [client, isConnected, cwd, queryClient, serverId]); @@ -70,7 +46,7 @@ export function useCheckoutStatusQuery({ serverId, cwd }: UseCheckoutStatusQuery if (!client) { throw new Error("Daemon client not available"); } - return await peekOrFetchSnapshot({ queryClient, client, serverId, cwd }); + return await peekOrFetchCheckoutStatus({ queryClient, client, serverId, cwd }); }, enabled: !!client && isConnected && !!cwd, staleTime: Infinity, diff --git a/packages/app/src/hooks/client-activity-tracker.test.ts b/packages/app/src/hooks/client-activity-tracker.test.ts new file mode 100644 index 000000000..b4506f569 --- /dev/null +++ b/packages/app/src/hooks/client-activity-tracker.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, it } from "vitest"; +import { + ACTIVITY_HEARTBEAT_THROTTLE_MS, + createClientActivityTracker, + type ClientActivityTracker, + type ClientActivityTrackerInput, + type HeartbeatClient, + type HeartbeatPayload, +} from "./client-activity-tracker"; + +const START_MS = new Date("2026-04-19T10:00:00.000Z").getTime(); + +function createTestClock(initial = START_MS) { + let t = initial; + return { + now: () => t, + set(ms: number) { + t = ms; + }, + advance(ms: number) { + t += ms; + }, + }; +} + +interface FakeHeartbeatClient extends HeartbeatClient { + isConnected: boolean; + recordedHeartbeats: HeartbeatPayload[]; + latest(): HeartbeatPayload; + reset(): void; +} + +function createFakeHeartbeatClient(): FakeHeartbeatClient { + const recorded: HeartbeatPayload[] = []; + return { + isConnected: true, + recordedHeartbeats: recorded, + sendHeartbeat(payload) { + recorded.push(payload); + }, + latest() { + const last = recorded.at(-1); + if (!last) throw new Error("Expected a heartbeat"); + return last; + }, + reset() { + recorded.length = 0; + }, + }; +} + +function buildTracker( + overrides: Partial & { + client?: FakeHeartbeatClient; + clock?: ReturnType; + } = {}, +): { + tracker: ClientActivityTracker; + client: FakeHeartbeatClient; + clock: ReturnType; +} { + const client = overrides.client ?? createFakeHeartbeatClient(); + const clock = overrides.clock ?? createTestClock(); + const tracker = createClientActivityTracker({ + client, + deviceType: overrides.deviceType ?? "web", + initialFocusedAgentId: overrides.initialFocusedAgentId ?? "agent-1", + initialAppVisible: overrides.initialAppVisible ?? true, + now: clock.now, + onAppResumed: overrides.onAppResumed, + }); + return { tracker, client, clock }; +} + +describe("client activity tracker", () => { + it("includes the latest user-activity time in the next heartbeat", () => { + const { tracker, client, clock } = buildTracker(); + + clock.advance(5_250); + tracker.recordUserActivity(); + tracker.maybeSendImmediateHeartbeat(); + + expect(client.latest()).toMatchObject({ + deviceType: "web", + focusedAgentId: "agent-1", + appVisible: true, + lastActivityAt: new Date(START_MS + 5_250).toISOString(), + }); + }); + + it("throttles repeated immediate heartbeats within the throttle window", () => { + const { tracker, client, clock } = buildTracker(); + + tracker.recordUserActivity(); + tracker.maybeSendImmediateHeartbeat(); + expect(client.recordedHeartbeats).toHaveLength(1); + + clock.advance(ACTIVITY_HEARTBEAT_THROTTLE_MS - 1); + tracker.recordUserActivity(); + tracker.maybeSendImmediateHeartbeat(); + expect(client.recordedHeartbeats).toHaveLength(1); + + clock.advance(1); + tracker.recordUserActivity(); + tracker.maybeSendImmediateHeartbeat(); + expect(client.recordedHeartbeats).toHaveLength(2); + }); + + it("sends one immediate heartbeat when the focused agent changes", () => { + const { tracker, client, clock } = buildTracker(); + tracker.sendHeartbeat(); // simulate the on-connect heartbeat + expect(client.recordedHeartbeats).toHaveLength(1); + client.reset(); + + clock.advance(5_000); + tracker.setFocusedAgentId("agent-2"); + + expect(client.recordedHeartbeats).toHaveLength(1); + expect(client.latest()).toMatchObject({ + focusedAgentId: "agent-2", + lastActivityAt: new Date(START_MS + 5_000).toISOString(), + }); + }); + + it("ignores focused-agent updates that do not change the value", () => { + const { tracker, client } = buildTracker({ initialFocusedAgentId: "agent-1" }); + + tracker.setFocusedAgentId("agent-1"); + + expect(client.recordedHeartbeats).toHaveLength(0); + }); + + it("drives lastActivityAt forward from system idle polling", () => { + const { tracker, client, clock } = buildTracker(); + + clock.advance(5_000); + tracker.notifySystemIdleMs(0); + clock.advance(5_000); + tracker.notifySystemIdleMs(0); + clock.advance(5_000); + tracker.notifySystemIdleMs(0); + tracker.sendHeartbeat(); + + expect(client.latest().lastActivityAt).toBe(new Date(START_MS + 15_000).toISOString()); + }); + + it("sets lastActivityAt to now minus the system idle time", () => { + const { tracker, client, clock } = buildTracker(); + + clock.advance(15_000); + tracker.notifySystemIdleMs(2_000); + tracker.sendHeartbeat(); + + expect(client.latest().lastActivityAt).toBe(new Date(START_MS + 13_000).toISOString()); + }); + + it("ignores failed system idle polls", () => { + const { tracker, client, clock } = buildTracker(); + tracker.sendHeartbeat(); + const before = client.latest().lastActivityAt; + + clock.advance(15_000); + tracker.notifySystemIdleMs(null); + tracker.sendHeartbeat(); + + expect(client.latest().lastActivityAt).toBe(before); + }); + + it("never moves lastActivityAt backward from a system idle poll", () => { + const { tracker, client, clock } = buildTracker(); + + clock.advance(5_000); + tracker.recordUserActivity(); + const userActivityAt = new Date(clock.now()).toISOString(); + + clock.advance(10_000); + tracker.notifySystemIdleMs(20_000); // would imply activity 5_000 ms before start + tracker.sendHeartbeat(); + + expect(client.latest().lastActivityAt).toBe(userActivityAt); + }); + + it("emits an appVisibilityChangedAt and runs onAppResumed when becoming visible after backgrounding", () => { + let resumed: number | null = null; + const { tracker, client, clock } = buildTracker({ + initialAppVisible: true, + onAppResumed: (awayMs) => { + resumed = awayMs; + }, + }); + + clock.advance(2_000); + const hideTransition = tracker.notifyAppVisibility(false); + expect(hideTransition.changed).toBe(true); + + clock.advance(8_000); + const showTransition = tracker.notifyAppVisibility(true); + expect(showTransition.changed).toBe(true); + expect(resumed).toBe(8_000); + + tracker.sendHeartbeat(); + expect(client.latest()).toMatchObject({ + appVisible: true, + appVisibilityChangedAt: new Date(START_MS + 10_000).toISOString(), + lastActivityAt: new Date(START_MS + 10_000).toISOString(), + }); + }); + + it("treats no-op visibility transitions as no change", () => { + const { tracker } = buildTracker({ initialAppVisible: true }); + const result = tracker.notifyAppVisibility(true); + expect(result.changed).toBe(false); + }); + + it("does not call onAppResumed when transitioning visible→visible or from initial-visible to hidden", () => { + let resumed: number | null = null; + const { tracker } = buildTracker({ + initialAppVisible: true, + onAppResumed: (awayMs) => { + resumed = awayMs; + }, + }); + + tracker.notifyAppVisibility(false); + expect(resumed).toBeNull(); + }); + + it("skips heartbeats while the client is disconnected", () => { + const client = createFakeHeartbeatClient(); + client.isConnected = false; + const { tracker, clock } = buildTracker({ client }); + + tracker.sendHeartbeat(); + tracker.recordUserActivity(); + tracker.maybeSendImmediateHeartbeat(); + clock.advance(10_000); + tracker.setFocusedAgentId("agent-2"); + + expect(client.recordedHeartbeats).toHaveLength(0); + }); +}); diff --git a/packages/app/src/hooks/client-activity-tracker.ts b/packages/app/src/hooks/client-activity-tracker.ts new file mode 100644 index 000000000..594a75486 --- /dev/null +++ b/packages/app/src/hooks/client-activity-tracker.ts @@ -0,0 +1,104 @@ +export const HEARTBEAT_INTERVAL_MS = 15_000; +export const ACTIVITY_HEARTBEAT_THROTTLE_MS = 5_000; +export const DESKTOP_IDLE_POLL_INTERVAL_MS = 5_000; + +export interface HeartbeatPayload { + deviceType: "web" | "mobile"; + focusedAgentId: string | null; + lastActivityAt: string; + appVisible: boolean; + appVisibilityChangedAt?: string; +} + +export interface HeartbeatClient { + readonly isConnected: boolean; + sendHeartbeat(payload: HeartbeatPayload): void; +} + +export interface ClientActivityTrackerInput { + client: HeartbeatClient; + deviceType: "web" | "mobile"; + initialFocusedAgentId: string | null; + initialAppVisible: boolean; + now: () => number; + onAppResumed?: (awayMs: number) => void; +} + +export interface ClientActivityTracker { + recordUserActivity(): void; + maybeSendImmediateHeartbeat(): void; + setFocusedAgentId(id: string | null): void; + notifyAppVisibility(visible: boolean): { changed: boolean }; + notifySystemIdleMs(idleMs: number | null): void; + sendHeartbeat(): void; +} + +export function createClientActivityTracker( + input: ClientActivityTrackerInput, +): ClientActivityTracker { + const { client, deviceType, now, onAppResumed } = input; + let lastActivityAtMs = now(); + let appVisible = input.initialAppVisible; + let appVisibilityChangedAtMs = now(); + let backgroundedAtMs: number | null = appVisible ? null : now(); + let focusedAgentId = input.initialFocusedAgentId; + let lastImmediateHeartbeatAtMs = 0; + + function sendHeartbeat(): void { + if (!client.isConnected) return; + client.sendHeartbeat({ + deviceType, + focusedAgentId, + lastActivityAt: new Date(lastActivityAtMs).toISOString(), + appVisible, + appVisibilityChangedAt: new Date(appVisibilityChangedAtMs).toISOString(), + }); + } + + function recordUserActivity(): void { + lastActivityAtMs = now(); + } + + function maybeSendImmediateHeartbeat(): void { + if (!client.isConnected) return; + const t = now(); + if (t - lastImmediateHeartbeatAtMs < ACTIVITY_HEARTBEAT_THROTTLE_MS) return; + lastImmediateHeartbeatAtMs = t; + sendHeartbeat(); + } + + return { + recordUserActivity, + maybeSendImmediateHeartbeat, + setFocusedAgentId(id) { + if (focusedAgentId === id) return; + focusedAgentId = id; + recordUserActivity(); + sendHeartbeat(); + }, + notifyAppVisibility(nextVisible) { + if (appVisible === nextVisible) return { changed: false }; + appVisible = nextVisible; + appVisibilityChangedAtMs = now(); + if (!nextVisible) { + backgroundedAtMs = now(); + return { changed: true }; + } + const at = backgroundedAtMs; + backgroundedAtMs = null; + if (at !== null) { + onAppResumed?.(Math.max(0, now() - at)); + } + recordUserActivity(); + return { changed: true }; + }, + notifySystemIdleMs(idleMs) { + if (idleMs === null) return; + const systemLastActivityAtMs = now() - idleMs; + if (systemLastActivityAtMs > lastActivityAtMs) { + lastActivityAtMs = systemLastActivityAtMs; + } + }, + sendHeartbeat, + }; +} diff --git a/packages/app/src/hooks/hardware-keyboard-submit-controller.test.ts b/packages/app/src/hooks/hardware-keyboard-submit-controller.test.ts new file mode 100644 index 000000000..a7acd8ccc --- /dev/null +++ b/packages/app/src/hooks/hardware-keyboard-submit-controller.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from "vitest"; +import { + createHardwareKeyboardSubmitController, + type HardwareKeyboardSubmitListenerPort, +} from "./hardware-keyboard-submit-controller"; + +interface FakeKeyboard extends HardwareKeyboardSubmitListenerPort { + emit(): void; + readonly isEnabled: boolean; + readonly listenerCount: number; +} + +function createFakeKeyboard(): FakeKeyboard { + const handlers = new Set<() => void>(); + let enabled = false; + return { + addListener(handler) { + handlers.add(handler); + return { remove: () => handlers.delete(handler) }; + }, + setEnabled(value) { + enabled = value; + }, + emit() { + handlers.forEach((handler) => handler()); + }, + get isEnabled() { + return enabled; + }, + get listenerCount() { + return handlers.size; + }, + }; +} + +describe("hardware-keyboard-submit-controller", () => { + it("dispatches to onSubmit when the keyboard emits while enabled", () => { + const keyboard = createFakeKeyboard(); + const controller = createHardwareKeyboardSubmitController(keyboard); + let calls = 0; + controller.setOnSubmit(() => { + calls += 1; + }); + + controller.enable(); + keyboard.emit(); + + expect(calls).toBe(1); + expect(keyboard.isEnabled).toBe(true); + }); + + it("does not subscribe or enable when never enabled", () => { + const keyboard = createFakeKeyboard(); + const controller = createHardwareKeyboardSubmitController(keyboard); + let calls = 0; + controller.setOnSubmit(() => { + calls += 1; + }); + + keyboard.emit(); + + expect(calls).toBe(0); + expect(keyboard.listenerCount).toBe(0); + expect(keyboard.isEnabled).toBe(false); + }); + + it("disables native hardware submit and unsubscribes on disable", () => { + const keyboard = createFakeKeyboard(); + const controller = createHardwareKeyboardSubmitController(keyboard); + controller.setOnSubmit(() => {}); + + controller.enable(); + expect(keyboard.isEnabled).toBe(true); + expect(keyboard.listenerCount).toBe(1); + + controller.disable(); + expect(keyboard.isEnabled).toBe(false); + expect(keyboard.listenerCount).toBe(0); + }); + + it("dispatches the latest onSubmit handler", () => { + const keyboard = createFakeKeyboard(); + const controller = createHardwareKeyboardSubmitController(keyboard); + const received: string[] = []; + controller.setOnSubmit(() => received.push("first")); + + controller.enable(); + controller.setOnSubmit(() => received.push("second")); + keyboard.emit(); + + expect(received).toEqual(["second"]); + }); + + it("does not dispatch after disable", () => { + const keyboard = createFakeKeyboard(); + const controller = createHardwareKeyboardSubmitController(keyboard); + let calls = 0; + controller.setOnSubmit(() => { + calls += 1; + }); + + controller.enable(); + controller.disable(); + keyboard.emit(); + + expect(calls).toBe(0); + }); + + it("ignores repeated enable calls", () => { + const keyboard = createFakeKeyboard(); + const controller = createHardwareKeyboardSubmitController(keyboard); + let calls = 0; + controller.setOnSubmit(() => { + calls += 1; + }); + + controller.enable(); + controller.enable(); + keyboard.emit(); + + expect(calls).toBe(1); + expect(keyboard.listenerCount).toBe(1); + }); + + it("ignores disable without a prior enable", () => { + const keyboard = createFakeKeyboard(); + const controller = createHardwareKeyboardSubmitController(keyboard); + + controller.disable(); + + expect(keyboard.isEnabled).toBe(false); + }); +}); diff --git a/packages/app/src/hooks/hardware-keyboard-submit-controller.ts b/packages/app/src/hooks/hardware-keyboard-submit-controller.ts new file mode 100644 index 000000000..0854f858b --- /dev/null +++ b/packages/app/src/hooks/hardware-keyboard-submit-controller.ts @@ -0,0 +1,34 @@ +export interface HardwareKeyboardSubmitListenerPort { + addListener(handler: () => void): { remove: () => void }; + setEnabled(enabled: boolean): void; +} + +export interface HardwareKeyboardSubmitController { + setOnSubmit(handler: () => void): void; + enable(): void; + disable(): void; +} + +export function createHardwareKeyboardSubmitController( + port: HardwareKeyboardSubmitListenerPort, +): HardwareKeyboardSubmitController { + let subscription: { remove: () => void } | null = null; + let onSubmit: () => void = () => {}; + + return { + setOnSubmit(handler) { + onSubmit = handler; + }, + enable() { + if (subscription) return; + subscription = port.addListener(() => onSubmit()); + port.setEnabled(true); + }, + disable() { + if (!subscription) return; + port.setEnabled(false); + subscription.remove(); + subscription = null; + }, + }; +} diff --git a/packages/app/src/hooks/hover-safe-zone-tracker.test.ts b/packages/app/src/hooks/hover-safe-zone-tracker.test.ts new file mode 100644 index 000000000..b4fdc3df0 --- /dev/null +++ b/packages/app/src/hooks/hover-safe-zone-tracker.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; +import { createHoverSafeZoneTracker, type RectLike } from "@/hooks/hover-safe-zone-tracker"; + +const TRIGGER: RectLike = { left: 0, right: 100, top: 20, bottom: 60 }; +const CONTENT: RectLike = { left: 120, right: 240, top: 20, bottom: 120 }; + +interface TrackerHandle { + pointerMoved(x: number, y: number): void; + pointerLeftWindow(): void; + windowBlurred(): void; + readonly enters: number; + readonly leaves: number; +} + +function createHandle( + rects: { trigger: RectLike | null; content: RectLike | null } = { + trigger: TRIGGER, + content: CONTENT, + }, +): TrackerHandle { + let enters = 0; + let leaves = 0; + const tracker = createHoverSafeZoneTracker({ + getTriggerRect: () => rects.trigger, + getContentRect: () => rects.content, + onEnterSafeZone: () => { + enters += 1; + }, + onLeaveSafeZone: () => { + leaves += 1; + }, + }); + return { + pointerMoved: tracker.pointerMoved, + pointerLeftWindow: tracker.pointerLeftWindow, + windowBlurred: tracker.windowBlurred, + get enters() { + return enters; + }, + get leaves() { + return leaves; + }, + }; +} + +describe("hover safe-zone tracker", () => { + it("tracks transitions across trigger, bridge, content, and outside space", () => { + const handle = createHandle(); + + // Bridge between trigger and content. + handle.pointerMoved(110, 40); + expect(handle.enters).toBe(1); + expect(handle.leaves).toBe(0); + + // Outside everything — fires leave once. + handle.pointerMoved(300, 40); + expect(handle.leaves).toBe(1); + + // Back into the bridge — fires enter again. + handle.pointerMoved(130, 40); + expect(handle.enters).toBe(2); + }); + + it("refreshes the safe-zone enter callback while moving inside", () => { + const handle = createHandle(); + + handle.pointerMoved(110, 40); + handle.pointerMoved(130, 40); + + expect(handle.enters).toBe(2); + expect(handle.leaves).toBe(0); + }); + + it("treats leaving the browser window as leaving the safe zone", () => { + const handle = createHandle(); + + handle.pointerLeftWindow(); + expect(handle.leaves).toBe(1); + + // Already outside — blur does not fire a second leave. + handle.windowBlurred(); + expect(handle.leaves).toBe(1); + }); + + it("falls back to trigger-or-content membership when a rect is missing", () => { + const handle = createHandle({ trigger: TRIGGER, content: null }); + + // Inside the trigger. + handle.pointerMoved(50, 40); + expect(handle.enters).toBe(1); + + // Inside the (now-missing) bridge — counts as outside. + handle.pointerMoved(110, 40); + expect(handle.leaves).toBe(1); + }); + + it("treats overlapping trigger and content as having no bridge", () => { + const handle = createHandle({ + trigger: { left: 0, right: 200, top: 0, bottom: 50 }, + content: { left: 100, right: 300, top: 60, bottom: 100 }, + }); + + // Outside both rects, in what would be a bridge — should be outside. + handle.pointerMoved(150, 55); + expect(handle.enters).toBe(0); + expect(handle.leaves).toBe(1); + }); +}); diff --git a/packages/app/src/hooks/hover-safe-zone-tracker.ts b/packages/app/src/hooks/hover-safe-zone-tracker.ts new file mode 100644 index 000000000..d097413c5 --- /dev/null +++ b/packages/app/src/hooks/hover-safe-zone-tracker.ts @@ -0,0 +1,74 @@ +export interface RectLike { + left: number; + right: number; + top: number; + bottom: number; +} + +export interface HoverSafeZoneTrackerInput { + getTriggerRect: () => RectLike | null; + getContentRect: () => RectLike | null; + onEnterSafeZone: () => void; + onLeaveSafeZone: () => void; +} + +export interface HoverSafeZoneTracker { + pointerMoved(x: number, y: number): void; + pointerLeftWindow(): void; + windowBlurred(): void; +} + +// Tracks the pointer's position relative to a hover card's "safe zone": the +// trigger, the content, and the rectangular bridge between them. The bridge +// lets the pointer cross the visual gap without dropping the hover. Fires +// `onEnterSafeZone` on every move that lands inside (so consumers can refresh +// timers) and `onLeaveSafeZone` once per inside→outside transition. +export function createHoverSafeZoneTracker(input: HoverSafeZoneTrackerInput): HoverSafeZoneTracker { + const { getTriggerRect, getContentRect, onEnterSafeZone, onLeaveSafeZone } = input; + // The pointer opened the card, so we start inside. + let wasInside = true; + + function leave(): void { + if (!wasInside) return; + wasInside = false; + onLeaveSafeZone(); + } + + return { + pointerMoved(x, y) { + if (isInsideSafeZone(getTriggerRect(), getContentRect(), x, y)) { + wasInside = true; + onEnterSafeZone(); + return; + } + leave(); + }, + pointerLeftWindow: leave, + windowBlurred: leave, + }; +} + +function isInsideRect(rect: RectLike | null, x: number, y: number): boolean { + if (!rect) return false; + return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; +} + +function isInsideSafeZone( + trigger: RectLike | null, + content: RectLike | null, + x: number, + y: number, +): boolean { + if (isInsideRect(trigger, x, y)) return true; + if (isInsideRect(content, x, y)) return true; + if (!trigger || !content) return false; + + // Bridge: the horizontal strip connecting trigger and content, stretched + // vertically to span both. If they overlap horizontally there's no bridge. + const bridgeLeft = Math.min(trigger.right, content.right); + const bridgeRight = Math.max(trigger.left, content.left); + if (bridgeLeft >= bridgeRight) return false; + const bridgeTop = Math.min(trigger.top, content.top); + const bridgeBottom = Math.max(trigger.bottom, content.bottom); + return x >= bridgeLeft && x <= bridgeRight && y >= bridgeTop && y <= bridgeBottom; +} diff --git a/packages/app/src/hooks/image-attachment-picker.native.test.ts b/packages/app/src/hooks/image-attachment-picker.native.test.ts deleted file mode 100644 index fd1fa52a1..000000000 --- a/packages/app/src/hooks/image-attachment-picker.native.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -vi.mock("expo-image-manipulator", () => ({ - SaveFormat: { PNG: "png" }, - ImageManipulator: { - manipulate: (_source: string) => ({ - async renderAsync() { - return { - release() {}, - async saveAsync(options: { format?: string }) { - return { - uri: - options.format === "png" - ? "file:///cache/ImageManipulator/safe-picked.png" - : "file:///cache/ImageManipulator/unsafe-picked.jpg", - width: 100, - height: 100, - }; - }, - }; - }, - release() {}, - }), - }, -})); - -import { normalizePickedImageAssets } from "./image-attachment-picker.native"; - -describe("native image attachment picker", () => { - it("preserves native picked JPEG and PNG attachment inputs", async () => { - const result = await normalizePickedImageAssets([ - { - uri: "file:///photos/IMG_0001.JPG", - mimeType: "image/jpeg", - fileName: "picked.jpeg", - }, - { - uri: "file:///photos/screenshot.png", - mimeType: "image/png", - fileName: "screenshot.png", - }, - ]); - - expect(result).toEqual([ - { - source: { kind: "file_uri", uri: "file:///photos/IMG_0001.JPG" }, - mimeType: "image/jpeg", - fileName: "picked.jpg", - }, - { - source: { kind: "file_uri", uri: "file:///photos/screenshot.png" }, - mimeType: "image/png", - fileName: "screenshot.png", - }, - ]); - }); - - it("turns a native picked HEIC-like asset into a PNG attachment input", async () => { - const result = await normalizePickedImageAssets([ - { - uri: "file:///photos/IMG_0001.HEIC", - mimeType: "image/png", - fileName: "picked.png", - }, - ]); - - expect(result).toEqual([ - { - source: { kind: "file_uri", uri: "file:///cache/ImageManipulator/safe-picked.png" }, - mimeType: "image/png", - fileName: "picked.png", - }, - ]); - }); -}); diff --git a/packages/app/src/hooks/image-attachment-picker.native.ts b/packages/app/src/hooks/image-attachment-picker.native.ts index 23d9bb351..dfb36c575 100644 --- a/packages/app/src/hooks/image-attachment-picker.native.ts +++ b/packages/app/src/hooks/image-attachment-picker.native.ts @@ -1,87 +1,16 @@ import { ImageManipulator, SaveFormat } from "expo-image-manipulator"; +import { + normalizePickedImageAssetsWith, + type ExpoImagePickerAssetLike, + type PickedImageAttachmentInput, +} from "./picked-image-normalizer"; -export type PickedImageSource = { kind: "file_uri"; uri: string } | { kind: "blob"; blob: Blob }; - -export interface PickedImageAttachmentInput { - source: PickedImageSource; - mimeType?: string | null; - fileName?: string | null; -} - -export interface ExpoImagePickerAssetLike { - uri: string; - mimeType?: string | null; - fileName?: string | null; - file?: File | null; -} - -interface SupportedPickedImageFormat { - mimeType: "image/jpeg" | "image/png"; - extension: "jpg" | "png"; -} - -const JPEG_FORMAT: SupportedPickedImageFormat = { - mimeType: "image/jpeg", - extension: "jpg", -}; - -const PNG_FORMAT: SupportedPickedImageFormat = { - mimeType: "image/png", - extension: "png", -}; - -function extensionFromPath(path: string | null | undefined): string | null { - const match = path?.match(/\.([a-z0-9]+)(?:[?#].*)?$/i); - return match?.[1]?.toLowerCase() ?? null; -} - -function supportedFormatForExtension(extension: string | null): SupportedPickedImageFormat | null { - if (extension === "jpg" || extension === "jpeg") { - return JPEG_FORMAT; - } - if (extension === "png") { - return PNG_FORMAT; - } - return null; -} - -function supportedFormatForMimeType( - mimeType: string | null | undefined, -): SupportedPickedImageFormat | null { - const normalizedMimeType = mimeType?.toLowerCase(); - if (normalizedMimeType === "image/jpeg" || normalizedMimeType === "image/jpg") { - return JPEG_FORMAT; - } - if (normalizedMimeType === "image/png") { - return PNG_FORMAT; - } - return null; -} - -function pickedAssetSupportedFormat( - asset: ExpoImagePickerAssetLike, -): SupportedPickedImageFormat | null { - const uriExtension = extensionFromPath(asset.uri); - if (uriExtension) { - return supportedFormatForExtension(uriExtension); - } - - return ( - supportedFormatForExtension(extensionFromPath(asset.fileName)) ?? - supportedFormatForMimeType(asset.mimeType) - ); -} - -function replaceFileExtension( - fileName: string | null | undefined, - extension: SupportedPickedImageFormat["extension"], -): string | null { - if (!fileName) { - return null; - } - - return fileName.replace(/\.[^./\\]+$/, "") + `.${extension}`; -} +export type { + ExportPickedImageAsPng, + ExpoImagePickerAssetLike, + PickedImageAttachmentInput, + PickedImageSource, +} from "./picked-image-normalizer"; async function exportPickedImageAsPng(uri: string): Promise { const context = ImageManipulator.manipulate(uri); @@ -102,28 +31,9 @@ async function exportPickedImageAsPng(uri: string): Promise { export async function normalizePickedImageAssets( assets: readonly ExpoImagePickerAssetLike[], ): Promise { - return await Promise.all( - assets.map(async (asset) => { - const supportedFormat = pickedAssetSupportedFormat(asset); - if (supportedFormat) { - return { - source: { kind: "file_uri", uri: asset.uri }, - mimeType: supportedFormat.mimeType, - fileName: replaceFileExtension(asset.fileName, supportedFormat.extension), - }; - } - - const convertedUri = await exportPickedImageAsPng(asset.uri); - - return { - source: { kind: "file_uri", uri: convertedUri }, - mimeType: PNG_FORMAT.mimeType, - fileName: replaceFileExtension(asset.fileName, PNG_FORMAT.extension), - }; - }), - ); + return normalizePickedImageAssetsWith(assets, exportPickedImageAsPng); } -export async function openImagePathsWithDesktopDialog(): Promise { +export async function openImagePathsWithDesktopDialog(_dialog?: unknown): Promise { throw new Error("Desktop dialog API is not available on native."); } diff --git a/packages/app/src/hooks/image-attachment-picker.test.ts b/packages/app/src/hooks/image-attachment-picker.test.ts index 3ae194dc0..821312db2 100644 --- a/packages/app/src/hooks/image-attachment-picker.test.ts +++ b/packages/app/src/hooks/image-attachment-picker.test.ts @@ -1,23 +1,29 @@ -import { describe, expect, it, vi, beforeEach } from "vitest"; - -const desktopHostState = vi.hoisted(() => ({ - api: null as unknown, -})); - -vi.mock("@/desktop/host", () => ({ - getDesktopHost: () => desktopHostState.api, -})); - +import { describe, expect, it } from "vitest"; +import type { DesktopDialogBridge, DesktopDialogOpenOptions } from "@/desktop/host"; import { normalizePickedImageAssets, openImagePathsWithDesktopDialog, } from "./image-attachment-picker"; -describe("image-attachment-picker", () => { - beforeEach(() => { - desktopHostState.api = null; - }); +function fakeDialogReturning(selection: string | string[] | null): { + dialog: DesktopDialogBridge; + recordedOptions: DesktopDialogOpenOptions[]; +} { + const recordedOptions: DesktopDialogOpenOptions[] = []; + return { + dialog: { + open: async (options?: DesktopDialogOpenOptions) => { + if (options) { + recordedOptions.push(options); + } + return selection; + }, + }, + recordedOptions, + }; +} +describe("image-attachment-picker", () => { it("normalizes a picked File into a blob source", async () => { const file = new File(["hello"], "picked.png", { type: "image/png" }); @@ -70,27 +76,24 @@ describe("image-attachment-picker", () => { }); it("uses the desktop dialog api when available", async () => { - const open = vi.fn().mockResolvedValue(["/tmp/one.png", "/tmp/two.jpg"]); - desktopHostState.api = { - dialog: { open }, - }; + const { dialog, recordedOptions } = fakeDialogReturning(["/tmp/one.png", "/tmp/two.jpg"]); - const result = await openImagePathsWithDesktopDialog(); + const result = await openImagePathsWithDesktopDialog(dialog); - expect(open).toHaveBeenCalledWith( - expect.objectContaining({ - multiple: true, - directory: false, - title: "Attach images", - }), - ); + expect(recordedOptions).toHaveLength(1); + expect(recordedOptions[0]).toMatchObject({ + multiple: true, + directory: false, + title: "Attach images", + }); expect(result).toEqual(["/tmp/one.png", "/tmp/two.jpg"]); }); it("throws when desktop dialog API is not available", async () => { - desktopHostState.api = {}; - - await expect(openImagePathsWithDesktopDialog()).rejects.toThrow( + await expect(openImagePathsWithDesktopDialog(null)).rejects.toThrow( + "Desktop dialog API is not available.", + ); + await expect(openImagePathsWithDesktopDialog({})).rejects.toThrow( "Desktop dialog API is not available.", ); }); diff --git a/packages/app/src/hooks/image-attachment-picker.ts b/packages/app/src/hooks/image-attachment-picker.ts index e11d3111c..b3478ae54 100644 --- a/packages/app/src/hooks/image-attachment-picker.ts +++ b/packages/app/src/hooks/image-attachment-picker.ts @@ -1,4 +1,4 @@ -import { getDesktopHost } from "@/desktop/host"; +import type { DesktopDialogBridge } from "@/desktop/host"; import { isAbsolutePath } from "@/utils/path"; export type PickedImageSource = { kind: "file_uri"; uri: string } | { kind: "blob"; blob: Blob }; @@ -79,8 +79,9 @@ function normalizeDesktopDialogSelection(selection: string | string[] | null): s return Array.isArray(selection) ? selection : [selection]; } -export async function openImagePathsWithDesktopDialog(): Promise { - const desktop = getDesktopHost(); +export async function openImagePathsWithDesktopDialog( + dialog: DesktopDialogBridge | null | undefined, +): Promise { const options = { directory: false, multiple: true, @@ -88,7 +89,7 @@ export async function openImagePathsWithDesktopDialog(): Promise { title: "Attach images", }; - const dialogOpen = desktop?.dialog?.open; + const dialogOpen = dialog?.open; if (typeof dialogOpen !== "function") { throw new Error("Desktop dialog API is not available."); } diff --git a/packages/app/src/hooks/open-project.ts b/packages/app/src/hooks/open-project.ts new file mode 100644 index 000000000..bc0dbd585 --- /dev/null +++ b/packages/app/src/hooks/open-project.ts @@ -0,0 +1,43 @@ +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { normalizeWorkspaceDescriptor, type WorkspaceDescriptor } from "@/stores/session-store"; +import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store"; + +export interface OpenProjectDirectlyInput { + serverId: string; + projectPath: string; + isConnected: boolean; + client: Pick | null; + mergeWorkspaces: (serverId: string, workspaces: Iterable) => void; + setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void; + openDraftTab: (workspaceKey: string) => string | null; + navigateToWorkspace: (serverId: string, workspaceId: string) => void; +} + +export async function openProjectDirectly(input: OpenProjectDirectlyInput): Promise { + const normalizedServerId = input.serverId.trim(); + const trimmedPath = input.projectPath.trim(); + if (!normalizedServerId || !trimmedPath || !input.client || !input.isConnected) { + return false; + } + + const payload = await input.client.openProject(trimmedPath); + if (payload.error || !payload.workspace) { + return false; + } + + const workspace = normalizeWorkspaceDescriptor(payload.workspace); + input.mergeWorkspaces(normalizedServerId, [workspace]); + input.setHasHydratedWorkspaces(normalizedServerId, true); + + const workspaceKey = buildWorkspaceTabPersistenceKey({ + serverId: normalizedServerId, + workspaceId: workspace.id, + }); + if (!workspaceKey) { + return false; + } + + input.openDraftTab(workspaceKey); + input.navigateToWorkspace(normalizedServerId, workspace.id); + return true; +} diff --git a/packages/app/src/hooks/picked-image-normalizer.test.ts b/packages/app/src/hooks/picked-image-normalizer.test.ts new file mode 100644 index 000000000..a00144af2 --- /dev/null +++ b/packages/app/src/hooks/picked-image-normalizer.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { + normalizePickedImageAssetsWith, + type ExportPickedImageAsPng, +} from "./picked-image-normalizer"; + +function fakeExportAsPng(): { + exportAsPng: ExportPickedImageAsPng; + recordedUris: string[]; +} { + const recordedUris: string[] = []; + return { + exportAsPng: async (uri: string) => { + recordedUris.push(uri); + return "file:///cache/ImageManipulator/safe-picked.png"; + }, + recordedUris, + }; +} + +describe("native image attachment picker", () => { + it("preserves native picked JPEG and PNG attachment inputs", async () => { + const { exportAsPng, recordedUris } = fakeExportAsPng(); + + const result = await normalizePickedImageAssetsWith( + [ + { + uri: "file:///photos/IMG_0001.JPG", + mimeType: "image/jpeg", + fileName: "picked.jpeg", + }, + { + uri: "file:///photos/screenshot.png", + mimeType: "image/png", + fileName: "screenshot.png", + }, + ], + exportAsPng, + ); + + expect(result).toEqual([ + { + source: { kind: "file_uri", uri: "file:///photos/IMG_0001.JPG" }, + mimeType: "image/jpeg", + fileName: "picked.jpg", + }, + { + source: { kind: "file_uri", uri: "file:///photos/screenshot.png" }, + mimeType: "image/png", + fileName: "screenshot.png", + }, + ]); + expect(recordedUris).toEqual([]); + }); + + it("turns a native picked HEIC-like asset into a PNG attachment input", async () => { + const { exportAsPng, recordedUris } = fakeExportAsPng(); + + const result = await normalizePickedImageAssetsWith( + [ + { + uri: "file:///photos/IMG_0001.HEIC", + mimeType: "image/png", + fileName: "picked.png", + }, + ], + exportAsPng, + ); + + expect(result).toEqual([ + { + source: { kind: "file_uri", uri: "file:///cache/ImageManipulator/safe-picked.png" }, + mimeType: "image/png", + fileName: "picked.png", + }, + ]); + expect(recordedUris).toEqual(["file:///photos/IMG_0001.HEIC"]); + }); +}); diff --git a/packages/app/src/hooks/picked-image-normalizer.ts b/packages/app/src/hooks/picked-image-normalizer.ts new file mode 100644 index 000000000..a662d10ff --- /dev/null +++ b/packages/app/src/hooks/picked-image-normalizer.ts @@ -0,0 +1,110 @@ +export type PickedImageSource = { kind: "file_uri"; uri: string } | { kind: "blob"; blob: Blob }; + +export interface PickedImageAttachmentInput { + source: PickedImageSource; + mimeType?: string | null; + fileName?: string | null; +} + +export interface ExpoImagePickerAssetLike { + uri: string; + mimeType?: string | null; + fileName?: string | null; + file?: File | null; +} + +export type ExportPickedImageAsPng = (uri: string) => Promise; + +interface SupportedPickedImageFormat { + mimeType: "image/jpeg" | "image/png"; + extension: "jpg" | "png"; +} + +const JPEG_FORMAT: SupportedPickedImageFormat = { + mimeType: "image/jpeg", + extension: "jpg", +}; + +const PNG_FORMAT: SupportedPickedImageFormat = { + mimeType: "image/png", + extension: "png", +}; + +function extensionFromPath(path: string | null | undefined): string | null { + const match = path?.match(/\.([a-z0-9]+)(?:[?#].*)?$/i); + return match?.[1]?.toLowerCase() ?? null; +} + +function supportedFormatForExtension(extension: string | null): SupportedPickedImageFormat | null { + if (extension === "jpg" || extension === "jpeg") { + return JPEG_FORMAT; + } + if (extension === "png") { + return PNG_FORMAT; + } + return null; +} + +function supportedFormatForMimeType( + mimeType: string | null | undefined, +): SupportedPickedImageFormat | null { + const normalizedMimeType = mimeType?.toLowerCase(); + if (normalizedMimeType === "image/jpeg" || normalizedMimeType === "image/jpg") { + return JPEG_FORMAT; + } + if (normalizedMimeType === "image/png") { + return PNG_FORMAT; + } + return null; +} + +function pickedAssetSupportedFormat( + asset: ExpoImagePickerAssetLike, +): SupportedPickedImageFormat | null { + const uriExtension = extensionFromPath(asset.uri); + if (uriExtension) { + return supportedFormatForExtension(uriExtension); + } + + return ( + supportedFormatForExtension(extensionFromPath(asset.fileName)) ?? + supportedFormatForMimeType(asset.mimeType) + ); +} + +function replaceFileExtension( + fileName: string | null | undefined, + extension: SupportedPickedImageFormat["extension"], +): string | null { + if (!fileName) { + return null; + } + + return fileName.replace(/\.[^./\\]+$/, "") + `.${extension}`; +} + +export async function normalizePickedImageAssetsWith( + assets: readonly ExpoImagePickerAssetLike[], + exportAsPng: ExportPickedImageAsPng, +): Promise { + return await Promise.all( + assets.map(async (asset) => { + const supportedFormat = pickedAssetSupportedFormat(asset); + if (supportedFormat) { + return { + source: { kind: "file_uri", uri: asset.uri }, + mimeType: supportedFormat.mimeType, + fileName: replaceFileExtension(asset.fileName, supportedFormat.extension), + }; + } + + const convertedUri = await exportAsPng(asset.uri); + + return { + source: { kind: "file_uri", uri: convertedUri }, + mimeType: PNG_FORMAT.mimeType, + fileName: replaceFileExtension(asset.fileName, PNG_FORMAT.extension), + }; + }), + ); +} diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts new file mode 100644 index 000000000..1963e0dd4 --- /dev/null +++ b/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts @@ -0,0 +1,248 @@ +import { describe, expect, it } from "vitest"; +import type { WorkspaceStructureProject } from "@/stores/session-store-hooks"; +import { + appendMissingOrderKeys, + applyStoredOrdering, + buildSidebarProjectsFromStructure, + computeSidebarOrderUpdates, + deriveSidebarLoadingState, + type SidebarProjectEntry, +} from "./sidebar-workspaces-view-model"; + +interface OrderedItem { + key: string; +} + +function item(key: string): OrderedItem { + return { key }; +} + +function project(input: { + projectKey: string; + projectName?: string; + projectKind?: WorkspaceStructureProject["projectKind"]; + iconWorkingDir?: string; + workspaceKeys: string[]; +}): WorkspaceStructureProject { + return { + projectKey: input.projectKey, + projectName: input.projectName ?? input.projectKey, + projectKind: input.projectKind ?? "git", + iconWorkingDir: input.iconWorkingDir ?? input.projectKey, + workspaceKeys: input.workspaceKeys, + }; +} + +function sidebarProject(input: { + projectKey: string; + workspaceKeys: string[]; + serverId?: string; +}): SidebarProjectEntry { + const projects = buildSidebarProjectsFromStructure({ + serverId: input.serverId ?? "srv", + projects: [project({ projectKey: input.projectKey, workspaceKeys: input.workspaceKeys })], + }); + const result = projects[0]; + if (!result) { + throw new Error("expected a project entry"); + } + return result; +} + +describe("applyStoredOrdering", () => { + it("keeps unknown items on the baseline while applying stored order", () => { + const result = applyStoredOrdering({ + items: [item("new"), item("a"), item("b")], + storedOrder: ["b", "a"], + getKey: (entry) => entry.key, + }); + + expect(result.map((entry) => entry.key)).toEqual(["new", "b", "a"]); + }); + + it("ignores stale and duplicate stored keys", () => { + const result = applyStoredOrdering({ + items: [item("x"), item("y")], + storedOrder: ["missing", "y", "y", "x"], + getKey: (entry) => entry.key, + }); + + expect(result.map((entry) => entry.key)).toEqual(["y", "x"]); + }); + + it("returns baseline when there is no persisted order", () => { + const baseline = [item("first"), item("second")]; + const result = applyStoredOrdering({ + items: baseline, + storedOrder: [], + getKey: (entry) => entry.key, + }); + + expect(result).toBe(baseline); + }); +}); + +describe("appendMissingOrderKeys", () => { + it("appends unseen keys while preserving existing order", () => { + const result = appendMissingOrderKeys({ + currentOrder: ["project-b", "project-a"], + visibleKeys: ["project-a", "project-b", "project-c"], + }); + + expect(result).toEqual(["project-b", "project-a", "project-c"]); + }); + + it("returns the same array when there are no unseen keys", () => { + const currentOrder = ["project-a", "project-b"]; + + const result = appendMissingOrderKeys({ + currentOrder, + visibleKeys: ["project-b", "project-a"], + }); + + expect(result).toBe(currentOrder); + }); +}); + +describe("buildSidebarProjectsFromStructure", () => { + it("creates structural workspace rows from ordered workspace keys", () => { + const projects = buildSidebarProjectsFromStructure({ + serverId: "srv", + projects: [ + project({ + projectKey: "project-1", + projectName: "Project 1", + iconWorkingDir: "/repo/main", + workspaceKeys: ["ws-main"], + }), + ], + }); + + expect(projects).toHaveLength(1); + expect(projects[0]?.projectName).toBe("Project 1"); + expect(projects[0]?.workspaces[0]).toMatchObject({ + workspaceKey: "srv:ws-main", + serverId: "srv", + workspaceId: "ws-main", + projectRootPath: "/repo/main", + projectKind: "git", + }); + }); + + it("preserves the structure hook project order", () => { + const projects = buildSidebarProjectsFromStructure({ + serverId: "srv", + projects: [ + project({ projectKey: "project-b", workspaceKeys: ["ws-b"] }), + project({ projectKey: "project-a", workspaceKeys: ["ws-a"] }), + ], + }); + + expect(projects.map((entry) => entry.projectKey)).toEqual(["project-b", "project-a"]); + }); + + it("preserves the structure hook workspace order", () => { + const projects = buildSidebarProjectsFromStructure({ + serverId: "srv", + projects: [project({ projectKey: "project-1", workspaceKeys: ["feature", "main"] })], + }); + + expect(projects[0]?.workspaces.map((workspace) => workspace.workspaceId)).toEqual([ + "feature", + "main", + ]); + }); +}); + +describe("computeSidebarOrderUpdates", () => { + it("returns no updates when there are no visible projects", () => { + const updates = computeSidebarOrderUpdates({ + projects: [], + persistedProjectOrder: ["stale-project"], + getWorkspaceOrder: () => [], + }); + + expect(updates).toEqual({ projectOrder: null, workspaceOrders: [] }); + }); + + it("appends unseen projects and workspaces to the persisted orders", () => { + const projects = [ + sidebarProject({ projectKey: "project-a", workspaceKeys: ["ws-1", "ws-2"] }), + sidebarProject({ projectKey: "project-b", workspaceKeys: ["ws-3"] }), + ]; + + const updates = computeSidebarOrderUpdates({ + projects, + persistedProjectOrder: ["project-a"], + getWorkspaceOrder: (projectKey) => (projectKey === "project-a" ? ["srv:ws-1"] : []), + }); + + expect(updates.projectOrder).toEqual(["project-a", "project-b"]); + expect(updates.workspaceOrders).toEqual([ + { projectKey: "project-a", order: ["srv:ws-1", "srv:ws-2"] }, + { projectKey: "project-b", order: ["srv:ws-3"] }, + ]); + }); + + it("returns no project-order update when persisted order already covers visible keys", () => { + const projects = [ + sidebarProject({ projectKey: "project-a", workspaceKeys: ["ws-1"] }), + sidebarProject({ projectKey: "project-b", workspaceKeys: ["ws-2"] }), + ]; + + const updates = computeSidebarOrderUpdates({ + projects, + persistedProjectOrder: ["project-b", "project-a"], + getWorkspaceOrder: (projectKey) => (projectKey === "project-a" ? ["srv:ws-1"] : ["srv:ws-2"]), + }); + + expect(updates.projectOrder).toBeNull(); + expect(updates.workspaceOrders).toEqual([]); + }); +}); + +describe("deriveSidebarLoadingState", () => { + it("reports initial-load while active and unhydrated with no projects", () => { + expect( + deriveSidebarLoadingState({ + isActive: true, + serverId: "srv", + hasHydratedWorkspaces: false, + hasProjects: false, + }), + ).toEqual({ isLoading: true, isInitialLoad: true, isRevalidating: false }); + }); + + it("stays loading but not initial once projects are visible", () => { + expect( + deriveSidebarLoadingState({ + isActive: true, + serverId: "srv", + hasHydratedWorkspaces: false, + hasProjects: true, + }), + ).toEqual({ isLoading: true, isInitialLoad: false, isRevalidating: false }); + }); + + it("clears loading once workspaces have hydrated", () => { + expect( + deriveSidebarLoadingState({ + isActive: true, + serverId: "srv", + hasHydratedWorkspaces: true, + hasProjects: true, + }), + ).toEqual({ isLoading: false, isInitialLoad: false, isRevalidating: false }); + }); + + it("short-circuits to idle when inactive", () => { + expect( + deriveSidebarLoadingState({ + isActive: false, + serverId: "srv", + hasHydratedWorkspaces: false, + hasProjects: false, + }), + ).toEqual({ isLoading: false, isInitialLoad: false, isRevalidating: false }); + }); +}); diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.ts new file mode 100644 index 000000000..62f547958 --- /dev/null +++ b/packages/app/src/hooks/sidebar-workspaces-view-model.ts @@ -0,0 +1,200 @@ +import type { PrHint } from "@/git/use-pr-status-query"; +import type { WorkspaceDescriptor } from "@/stores/session-store"; +import type { WorkspaceStructureProject } from "@/stores/session-store-hooks"; + +const EMPTY_PROJECTS: SidebarProjectEntry[] = []; + +export type SidebarStateBucket = WorkspaceDescriptor["status"]; + +export interface SidebarWorkspaceEntry { + workspaceKey: string; + serverId: string; + workspaceId: string; + projectKey: string; + projectRootPath?: string; + workspaceDirectory?: string; + projectKind: WorkspaceDescriptor["projectKind"]; + workspaceKind: WorkspaceDescriptor["workspaceKind"]; + name: string; + statusBucket: SidebarStateBucket; + archivingAt: string | null; + diffStat: { additions: number; deletions: number } | null; + prHint: PrHint | null; + archiveHasUncommittedChanges: boolean | null; + archiveUnpushedCommitCount: number | null; + scripts: WorkspaceDescriptor["scripts"]; + hasRunningScripts: boolean; +} + +export interface SidebarProjectEntry { + projectKey: string; + projectName: string; + projectKind: WorkspaceDescriptor["projectKind"]; + iconWorkingDir: string; + workspaces: SidebarWorkspaceEntry[]; +} + +function createStructuralWorkspaceEntry(input: { + serverId: string; + project: WorkspaceStructureProject; + workspaceId: string; +}): SidebarWorkspaceEntry { + return { + workspaceKey: `${input.serverId}:${input.workspaceId}`, + serverId: input.serverId, + workspaceId: input.workspaceId, + projectKey: input.project.projectKey, + projectRootPath: input.project.iconWorkingDir, + workspaceDirectory: undefined, + projectKind: input.project.projectKind, + workspaceKind: "checkout", + name: input.workspaceId, + statusBucket: "done", + archivingAt: null, + diffStat: null, + prHint: null, + archiveHasUncommittedChanges: null, + archiveUnpushedCommitCount: null, + scripts: [], + hasRunningScripts: false, + }; +} + +export function buildSidebarProjectsFromStructure(input: { + serverId: string; + projects: WorkspaceStructureProject[]; +}): SidebarProjectEntry[] { + if (input.projects.length === 0) { + return EMPTY_PROJECTS; + } + + return input.projects.map((project) => ({ + projectKey: project.projectKey, + projectName: project.projectName, + projectKind: project.projectKind, + iconWorkingDir: project.iconWorkingDir, + workspaces: project.workspaceKeys.map((workspaceId) => + createStructuralWorkspaceEntry({ + serverId: input.serverId, + project, + workspaceId, + }), + ), + })); +} + +export function applyStoredOrdering(input: { + items: T[]; + storedOrder: string[]; + getKey: (item: T) => string; +}): T[] { + if (input.items.length <= 1 || input.storedOrder.length === 0) { + return input.items; + } + + const itemByKey = new Map(); + for (const item of input.items) { + itemByKey.set(input.getKey(item), item); + } + + const prunedOrder: string[] = []; + const seen = new Set(); + for (const key of input.storedOrder) { + if (!itemByKey.has(key) || seen.has(key)) { + continue; + } + seen.add(key); + prunedOrder.push(key); + } + + if (prunedOrder.length === 0) { + return input.items; + } + + const orderedSet = new Set(prunedOrder); + const ordered: T[] = []; + let orderedIndex = 0; + + for (const item of input.items) { + const key = input.getKey(item); + if (!orderedSet.has(key)) { + ordered.push(item); + continue; + } + + const targetKey = prunedOrder[orderedIndex] ?? key; + orderedIndex += 1; + ordered.push(itemByKey.get(targetKey) ?? item); + } + + return ordered; +} + +export function appendMissingOrderKeys(input: { + currentOrder: string[]; + visibleKeys: string[]; +}): string[] { + if (input.visibleKeys.length === 0) { + return input.currentOrder; + } + + const existingKeys = new Set(input.currentOrder); + const missingKeys = input.visibleKeys.filter((key) => !existingKeys.has(key)); + if (missingKeys.length === 0) { + return input.currentOrder; + } + + return [...input.currentOrder, ...missingKeys]; +} + +export interface SidebarOrderUpdates { + projectOrder: string[] | null; + workspaceOrders: Array<{ projectKey: string; order: string[] }>; +} + +export function computeSidebarOrderUpdates(input: { + projects: SidebarProjectEntry[]; + persistedProjectOrder: string[]; + getWorkspaceOrder: (projectKey: string) => string[]; +}): SidebarOrderUpdates { + if (input.projects.length === 0) { + return { projectOrder: null, workspaceOrders: [] }; + } + + const nextProjectOrder = appendMissingOrderKeys({ + currentOrder: input.persistedProjectOrder, + visibleKeys: input.projects.map((project) => project.projectKey), + }); + const projectOrder = nextProjectOrder === input.persistedProjectOrder ? null : nextProjectOrder; + + const workspaceOrders: Array<{ projectKey: string; order: string[] }> = []; + for (const project of input.projects) { + const persistedWorkspaceOrder = input.getWorkspaceOrder(project.projectKey); + const nextWorkspaceOrder = appendMissingOrderKeys({ + currentOrder: persistedWorkspaceOrder, + visibleKeys: project.workspaces.map((workspace) => workspace.workspaceKey), + }); + if (nextWorkspaceOrder !== persistedWorkspaceOrder) { + workspaceOrders.push({ projectKey: project.projectKey, order: nextWorkspaceOrder }); + } + } + + return { projectOrder, workspaceOrders }; +} + +export interface SidebarLoadingState { + isLoading: boolean; + isInitialLoad: boolean; + isRevalidating: boolean; +} + +export function deriveSidebarLoadingState(input: { + isActive: boolean; + serverId: string | null; + hasHydratedWorkspaces: boolean; + hasProjects: boolean; +}): SidebarLoadingState { + const isLoading = input.isActive && Boolean(input.serverId) && !input.hasHydratedWorkspaces; + const isInitialLoad = isLoading && !input.hasProjects; + return { isLoading, isInitialLoad, isRevalidating: false }; +} diff --git a/packages/app/src/hooks/use-agent-commands-query.test.ts b/packages/app/src/hooks/use-agent-commands-query.test.ts index 4bcd288cc..dfff07095 100644 --- a/packages/app/src/hooks/use-agent-commands-query.test.ts +++ b/packages/app/src/hooks/use-agent-commands-query.test.ts @@ -1,79 +1,75 @@ -/** - * @vitest-environment jsdom - */ -import React from "react"; -import { renderHook, waitFor } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { + type AgentCommandsClient, + type DraftCommandConfig, + fetchAgentCommands, +} from "./use-agent-commands-query"; -import { useAgentCommandsQuery } from "./use-agent-commands-query"; +type ListCommands = DaemonClient["listCommands"]; +type ListCommandsResult = Awaited>; -const { mockClient, mockRuntime } = vi.hoisted(() => { - const hoistedClient = { - listCommands: vi.fn(), - }; +interface ListCommandsCall { + agentId: string; + draftConfig: DraftCommandConfig | undefined; +} + +interface FakeAgentCommandsClient extends AgentCommandsClient { + calls: ListCommandsCall[]; +} + +function createClient(response: ListCommandsResult): FakeAgentCommandsClient { + const calls: ListCommandsCall[] = []; return { - mockClient: hoistedClient, - mockRuntime: { - client: hoistedClient, - isConnected: true, - }, + calls, + listCommands: (async ( + agentId: string, + requestIdOrOptions?: string | { draftConfig?: DraftCommandConfig }, + ) => { + const options = + typeof requestIdOrOptions === "object" && requestIdOrOptions !== null + ? requestIdOrOptions + : undefined; + calls.push({ agentId, draftConfig: options?.draftConfig }); + return response; + }) as ListCommands, }; -}); - -vi.mock("@/runtime/host-runtime", () => ({ - useHostRuntimeClient: () => mockRuntime.client, - useHostRuntimeIsConnected: () => mockRuntime.isConnected, -})); - -function createQueryClient(): QueryClient { - return new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, - }); } -function renderCommandsHook(input: Parameters[0]) { - const queryClient = createQueryClient(); - const wrapper = ({ children }: { children: React.ReactNode }) => - React.createElement(QueryClientProvider, { client: queryClient }, children); - - return renderHook(() => useAgentCommandsQuery(input), { wrapper }); +function commandsPayload(commands: ListCommandsResult["commands"]): ListCommandsResult { + return { + requestId: "req_commands", + agentId: "", + error: null, + commands, + }; } -describe("useAgentCommandsQuery", () => { - afterEach(() => { - vi.clearAllMocks(); - mockRuntime.client = mockClient; - mockRuntime.isConnected = true; - }); - +describe("fetchAgentCommands", () => { it("loads commands for a draft composer without an agent id", async () => { - mockClient.listCommands.mockResolvedValue({ - commands: [{ name: "compact", description: "Compact context", argumentHint: "" }], - }); + const client = createClient( + commandsPayload([{ name: "compact", description: "Compact context", argumentHint: "" }]), + ); - const draftConfig = { - provider: "opencode" as const, + const draftConfig: DraftCommandConfig = { + provider: "opencode", cwd: "/repo", modeId: "build", }; - const { result } = renderCommandsHook({ - serverId: "server-1", - agentId: "", - draftConfig, - }); + const commands = await fetchAgentCommands({ client, agentId: "", draftConfig }); - await waitFor(() => { - expect(result.current.commands).toEqual([ - { name: "compact", description: "Compact context", argumentHint: "" }, - ]); - }); + expect(commands).toEqual([ + { name: "compact", description: "Compact context", argumentHint: "" }, + ]); + expect(client.calls).toEqual([{ agentId: "", draftConfig }]); + }); - expect(mockClient.listCommands).toHaveBeenCalledWith("", { draftConfig }); + it("passes the agent id when fetching commands for a running agent", async () => { + const client = createClient(commandsPayload([])); + + await fetchAgentCommands({ client, agentId: "agent-1" }); + + expect(client.calls).toEqual([{ agentId: "agent-1", draftConfig: undefined }]); }); }); diff --git a/packages/app/src/hooks/use-agent-commands-query.ts b/packages/app/src/hooks/use-agent-commands-query.ts index c7b511707..05071d903 100644 --- a/packages/app/src/hooks/use-agent-commands-query.ts +++ b/packages/app/src/hooks/use-agent-commands-query.ts @@ -1,4 +1,5 @@ import { useQuery } from "@tanstack/react-query"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import type { AgentProvider } from "@getpaseo/protocol/agent-types"; @@ -19,7 +20,24 @@ export interface DraftCommandConfig { featureValues?: Record; } -function commandsQueryKey(serverId: string, agentId: string, draftConfig?: DraftCommandConfig) { +export type AgentCommandsClient = Pick; + +export async function fetchAgentCommands(input: { + client: AgentCommandsClient; + agentId: string; + draftConfig?: DraftCommandConfig; +}): Promise { + const response = await input.client.listCommands(input.agentId, { + draftConfig: input.draftConfig, + }); + return response.commands as AgentSlashCommand[]; +} + +function agentCommandsQueryKey( + serverId: string, + agentId: string, + draftConfig?: DraftCommandConfig, +) { return [ "agentCommands", serverId, @@ -50,13 +68,12 @@ export function useAgentCommandsQuery({ const isConnected = useHostRuntimeIsConnected(serverId); const query = useQuery({ - queryKey: commandsQueryKey(serverId, agentId, draftConfig), + queryKey: agentCommandsQueryKey(serverId, agentId, draftConfig), queryFn: async () => { if (!client) { throw new Error("Daemon client not available"); } - const response = await client.listCommands(agentId, { draftConfig }); - return response.commands as AgentSlashCommand[]; + return fetchAgentCommands({ client, agentId, draftConfig }); }, enabled: enabled && !!client && isConnected && (!!agentId || !!draftConfig), staleTime: COMMANDS_STALE_TIME, diff --git a/packages/app/src/hooks/use-agent-history.test.ts b/packages/app/src/hooks/use-agent-history.test.ts new file mode 100644 index 000000000..0cdb392c2 --- /dev/null +++ b/packages/app/src/hooks/use-agent-history.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, it } from "vitest"; +import type { + DaemonClient, + FetchAgentHistoryEntry, + FetchAgentHistoryOptions, +} from "@getpaseo/client/internal/daemon-client"; +import { type AgentHistoryClient, fetchAgentHistoryPage } from "./use-agent-history"; + +type FetchAgentHistory = DaemonClient["fetchAgentHistory"]; +type FetchAgentHistoryResult = Awaited>; + +interface FakeAgentHistoryClient extends AgentHistoryClient { + calls: FetchAgentHistoryOptions[]; +} + +function createClient(pages: FetchAgentHistoryResult[]): FakeAgentHistoryClient { + const calls: FetchAgentHistoryOptions[] = []; + let index = 0; + return { + calls, + fetchAgentHistory: async (options) => { + calls.push(options ?? {}); + const page = pages[index] ?? pages[pages.length - 1]; + index += 1; + if (!page) { + throw new Error("No more history pages configured"); + } + return page; + }, + }; +} + +function historyPayload(input: { + entries: FetchAgentHistoryEntry[]; + hasMore?: boolean; + nextCursor?: string | null; +}): FetchAgentHistoryResult { + return { + requestId: "req_history", + entries: input.entries, + pageInfo: { + nextCursor: input.nextCursor ?? null, + prevCursor: null, + hasMore: input.hasMore ?? false, + }, + }; +} + +function historyEntry(input: { + id: string; + cwd: string; + updatedAt: string; + title?: string | null; + archivedAt?: string | null; +}): FetchAgentHistoryEntry { + return { + agent: { + id: input.id, + provider: "codex", + status: "closed", + createdAt: input.updatedAt, + updatedAt: input.updatedAt, + lastUserMessageAt: null, + lastError: undefined, + runtimeInfo: { + provider: "codex", + sessionId: null, + }, + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + title: input.title ?? null, + cwd: input.cwd, + model: null, + thinkingOptionId: null, + requiresAttention: false, + attentionReason: null, + attentionTimestamp: null, + archivedAt: input.archivedAt ?? null, + labels: {}, + }, + project: { + projectKey: input.cwd, + projectName: "workspace", + checkout: { + cwd: input.cwd, + isGit: false, + currentBranch: null, + remoteUrl: null, + worktreeRoot: null, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }, + }, + }; +} + +describe("fetchAgentHistoryPage", () => { + it("requests the first page with the default limit and updated_at descending sort", async () => { + const client = createClient([ + historyPayload({ + entries: [ + historyEntry({ + id: "history-1", + cwd: "/repo", + updatedAt: "2026-04-02T10:00:00.000Z", + title: "History one", + }), + ], + hasMore: true, + nextCursor: "cursor-2", + }), + ]); + + const page = await fetchAgentHistoryPage({ client, serverId: "server-1", cursor: null }); + + expect(client.calls).toEqual([ + { + sort: [{ key: "updated_at", direction: "desc" }], + page: { limit: 200 }, + } satisfies FetchAgentHistoryOptions, + ]); + expect(page.agents.map((agent) => agent.id)).toEqual(["history-1"]); + expect(page.pageInfo).toEqual({ + nextCursor: "cursor-2", + prevCursor: null, + hasMore: true, + }); + }); + + it("passes the cursor when fetching subsequent pages", async () => { + const client = createClient([ + historyPayload({ + entries: [ + historyEntry({ + id: "history-2", + cwd: "/repo", + updatedAt: "2026-04-01T10:00:00.000Z", + title: "History two", + }), + ], + }), + ]); + + await fetchAgentHistoryPage({ client, serverId: "server-1", cursor: "cursor-2" }); + + expect(client.calls.at(-1)).toEqual({ + sort: [{ key: "updated_at", direction: "desc" }], + page: { limit: 200, cursor: "cursor-2" }, + } satisfies FetchAgentHistoryOptions); + }); + + it("maps daemon history entries into aggregated agents tagged with the requested server", async () => { + const client = createClient([ + historyPayload({ + entries: [ + historyEntry({ + id: "history-1", + cwd: "/repo", + updatedAt: "2026-04-02T10:00:00.000Z", + title: "History one", + }), + ], + }), + ]); + + const page = await fetchAgentHistoryPage({ client, serverId: "server-1", cursor: null }); + + expect(page.agents).toEqual([ + expect.objectContaining({ + id: "history-1", + serverId: "server-1", + serverLabel: "server-1", + title: "History one", + cwd: "/repo", + provider: "codex", + archivedAt: null, + }), + ]); + }); + + it("carries archived entries through with their archivedAt timestamp", async () => { + const client = createClient([ + historyPayload({ + entries: [ + historyEntry({ + id: "history-archived", + cwd: "/repo", + updatedAt: "2026-04-01T10:00:00.000Z", + archivedAt: "2026-04-01T10:05:00.000Z", + }), + ], + }), + ]); + + const page = await fetchAgentHistoryPage({ client, serverId: "server-1", cursor: null }); + + expect(page.agents[0]?.archivedAt).toEqual(new Date("2026-04-01T10:05:00.000Z")); + }); +}); diff --git a/packages/app/src/hooks/use-agent-history.test.tsx b/packages/app/src/hooks/use-agent-history.test.tsx deleted file mode 100644 index d43082216..000000000 --- a/packages/app/src/hooks/use-agent-history.test.tsx +++ /dev/null @@ -1,267 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import React from "react"; -import { act, renderHook, waitFor } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import type { - DaemonClient, - FetchAgentHistoryEntry, - FetchAgentHistoryOptions, -} from "@getpaseo/client/internal/daemon-client"; -import { useSessionStore, type Agent } from "@/stores/session-store"; -import { useAgentHistory } from "./use-agent-history"; - -const { mockClient, mockRuntimeStore } = vi.hoisted(() => { - const hoistedClient = { - fetchAgentHistory: vi.fn(), - }; - const hoistedRuntimeStore = { - refreshAgentDirectory: vi.fn(), - }; - return { mockClient: hoistedClient, mockRuntimeStore: hoistedRuntimeStore }; -}); - -vi.mock("@/runtime/host-runtime", () => ({ - getHostRuntimeStore: () => mockRuntimeStore, - useHostRuntimeClient: () => mockClient, - useHostRuntimeIsConnected: () => true, - useHosts: () => [{ serverId: "server-1", label: "Local" }], -})); - -function createQueryClient(): QueryClient { - return new QueryClient({ - defaultOptions: { - queries: { retry: false }, - }, - }); -} - -function renderAgentHistoryHook(options?: { enabled?: boolean }) { - const queryClient = createQueryClient(); - const wrapper = ({ children }: { children: React.ReactNode }) => - React.createElement(QueryClientProvider, { client: queryClient }, children); - - return renderHook(() => useAgentHistory({ serverId: "server-1", enabled: options?.enabled }), { - wrapper, - }); -} - -function makeHistoryPayload(input: { - entries: FetchAgentHistoryEntry[]; - hasMore?: boolean; - nextCursor?: string | null; -}): Awaited> { - return { - requestId: "req_history", - entries: input.entries, - pageInfo: { - nextCursor: input.nextCursor ?? null, - prevCursor: null, - hasMore: input.hasMore ?? false, - }, - }; -} - -function makeHistoryEntry(input: { - id: string; - cwd: string; - updatedAt: string; - title?: string | null; - archivedAt?: string | null; -}): FetchAgentHistoryEntry { - return { - agent: { - id: input.id, - provider: "codex", - status: "closed", - createdAt: input.updatedAt, - updatedAt: input.updatedAt, - lastUserMessageAt: null, - lastError: undefined, - runtimeInfo: { - provider: "codex", - sessionId: null, - }, - capabilities: { - supportsStreaming: true, - supportsSessionPersistence: true, - supportsDynamicModes: true, - supportsMcpServers: true, - supportsReasoningStream: true, - supportsToolInvocations: true, - }, - currentModeId: null, - availableModes: [], - pendingPermissions: [], - persistence: null, - title: input.title ?? null, - cwd: input.cwd, - model: null, - thinkingOptionId: null, - requiresAttention: false, - attentionReason: null, - attentionTimestamp: null, - archivedAt: input.archivedAt ?? null, - labels: {}, - }, - project: { - projectKey: input.cwd, - projectName: "workspace", - checkout: { - cwd: input.cwd, - isGit: false, - currentBranch: null, - remoteUrl: null, - worktreeRoot: null, - isPaseoOwnedWorktree: false, - mainRepoRoot: null, - }, - }, - }; -} - -function makeActiveAgent(): Agent { - const timestamp = new Date("2026-04-01T10:00:00.000Z"); - return { - serverId: "server-1", - id: "active-1", - provider: "codex", - status: "idle", - createdAt: timestamp, - updatedAt: timestamp, - lastUserMessageAt: null, - lastActivityAt: timestamp, - capabilities: { - supportsStreaming: true, - supportsSessionPersistence: true, - supportsDynamicModes: true, - supportsMcpServers: true, - supportsReasoningStream: true, - supportsToolInvocations: true, - }, - currentModeId: null, - availableModes: [], - pendingPermissions: [], - persistence: null, - title: "Active", - cwd: "/repo", - model: null, - labels: {}, - archivedAt: null, - parentAgentId: null, - }; -} - -afterEach(() => { - mockClient.fetchAgentHistory.mockReset(); - mockRuntimeStore.refreshAgentDirectory.mockReset(); - useSessionStore.setState({ sessions: {}, agentLastActivity: new Map() }); -}); - -function agentIds(agents: ReadonlyArray<{ id: string }>): string[] { - return agents.map((agent) => agent.id); -} - -describe("useAgentHistory", () => { - it("loads history one page at a time without refreshing active agents", async () => { - mockClient.fetchAgentHistory - .mockResolvedValueOnce( - makeHistoryPayload({ - entries: [ - makeHistoryEntry({ - id: "history-1", - cwd: "/repo", - updatedAt: "2026-04-02T10:00:00.000Z", - title: "History one", - }), - ], - hasMore: true, - nextCursor: "cursor-2", - }), - ) - .mockResolvedValueOnce( - makeHistoryPayload({ - entries: [ - makeHistoryEntry({ - id: "history-2", - cwd: "/repo", - updatedAt: "2026-04-01T10:00:00.000Z", - title: "History two", - archivedAt: "2026-04-01T10:05:00.000Z", - }), - ], - }), - ); - - act(() => { - useSessionStore - .getState() - .initializeSession("server-1", mockClient as unknown as DaemonClient); - useSessionStore.getState().setAgents("server-1", new Map([["active-1", makeActiveAgent()]])); - }); - - const { result } = renderAgentHistoryHook(); - - await waitFor(() => { - expect(mockClient.fetchAgentHistory).toHaveBeenCalledTimes(1); - }); - - expect(mockClient.fetchAgentHistory.mock.calls.map(([options]) => options)).toEqual([ - { - sort: [{ key: "updated_at", direction: "desc" }], - page: { limit: 200 }, - }, - ] satisfies FetchAgentHistoryOptions[]); - await waitFor(() => { - expect(agentIds(result.current.agents)).toEqual(["history-1"]); - }); - expect(result.current.hasMore).toBe(true); - - await act(async () => { - result.current.loadMore(); - }); - - await waitFor(() => { - expect(mockClient.fetchAgentHistory).toHaveBeenCalledTimes(2); - }); - - expect(mockClient.fetchAgentHistory.mock.calls.at(-1)?.[0]).toEqual({ - sort: [{ key: "updated_at", direction: "desc" }], - page: { limit: 200, cursor: "cursor-2" }, - } satisfies FetchAgentHistoryOptions); - await waitFor(() => { - expect(agentIds(result.current.agents)).toEqual(["history-1", "history-2"]); - }); - expect(result.current.hasMore).toBe(false); - expect( - Array.from(useSessionStore.getState().sessions["server-1"]?.agents.keys() ?? []), - ).toEqual(["active-1"]); - expect(mockRuntimeStore.refreshAgentDirectory).not.toHaveBeenCalled(); - }); - - it("waits until history is enabled before calling the history RPC", async () => { - mockClient.fetchAgentHistory.mockResolvedValue(makeHistoryPayload({ entries: [] })); - - const queryClient = createQueryClient(); - const wrapper = ({ children }: { children: React.ReactNode }) => - React.createElement(QueryClientProvider, { client: queryClient }, children); - let enabled = false; - const { rerender } = renderHook(() => useAgentHistory({ serverId: "server-1", enabled }), { - wrapper, - }); - await act(async () => { - await Promise.resolve(); - }); - - expect(mockClient.fetchAgentHistory).not.toHaveBeenCalled(); - - enabled = true; - rerender(); - - await waitFor(() => { - expect(mockClient.fetchAgentHistory).toHaveBeenCalledTimes(1); - }); - }); -}); diff --git a/packages/app/src/hooks/use-agent-history.ts b/packages/app/src/hooks/use-agent-history.ts index 0ddf54709..ff9fa0101 100644 --- a/packages/app/src/hooks/use-agent-history.ts +++ b/packages/app/src/hooks/use-agent-history.ts @@ -26,13 +26,15 @@ export interface AgentHistoryResult { loadMore: () => void; } -interface AgentHistoryPage { +export interface AgentHistoryPage { agents: AggregatedAgent[]; pageInfo: FetchAgentHistoryPageInfo; } -async function fetchAgentHistoryPage(input: { - client: DaemonClient; +export type AgentHistoryClient = Pick; + +export async function fetchAgentHistoryPage(input: { + client: AgentHistoryClient; serverId: string; cursor: string | null; }): Promise { @@ -147,7 +149,3 @@ export function useAgentHistory(options: { loadMore, }; } - -export const __private__ = { - fetchAgentHistoryPage, -}; diff --git a/packages/app/src/hooks/use-agent-initialization.test.ts b/packages/app/src/hooks/use-agent-initialization.test.ts index cdb9496bf..2c7b9f38b 100644 --- a/packages/app/src/hooks/use-agent-initialization.test.ts +++ b/packages/app/src/hooks/use-agent-initialization.test.ts @@ -1,29 +1,39 @@ -// @vitest-environment jsdom - -import { act, renderHook } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { useSessionStore } from "@/stores/session-store"; import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy"; import { getInitDeferred, getInitKey, resolveInitDeferred } from "@/utils/agent-initialization"; -import { useAgentInitialization } from "./use-agent-initialization"; +import { + createSetAgentInitializing, + ensureAgentIsInitialized, + refreshAgent, +} from "./use-agent-initialization"; const serverId = "server-1"; const agentId = "agent-1"; -function makeClient() { +interface FakeDaemonClient { + fetchAgentTimeline: ReturnType; + refreshAgent: ReturnType; +} + +function makeClient(): FakeDaemonClient { return { fetchAgentTimeline: vi.fn().mockResolvedValue(undefined), refreshAgent: vi.fn().mockResolvedValue(undefined), }; } +function bindSetAgentInitializing() { + return createSetAgentInitializing(serverId, useSessionStore.getState().setInitializingAgents); +} + afterEach(() => { resolveInitDeferred(getInitKey(serverId, agentId)); useSessionStore.setState({ sessions: {}, agentLastActivity: new Map() }); vi.restoreAllMocks(); }); -describe("useAgentInitialization", () => { +describe("ensureAgentIsInitialized", () => { it("requests bounded canonical catch-up after the current cursor when authoritative history is loaded", () => { const client = makeClient(); useSessionStore.getState().initializeSession(serverId, client as never); @@ -35,12 +45,11 @@ describe("useAgentInitialization", () => { ); useSessionStore.getState().setAgentAuthoritativeHistoryApplied(serverId, agentId, true); - const { result } = renderHook(() => - useAgentInitialization({ serverId, client: client as never }), - ); - - act(() => { - void result.current.ensureAgentIsInitialized(agentId); + void ensureAgentIsInitialized({ + serverId, + agentId, + client: client as never, + setAgentInitializing: bindSetAgentInitializing(), }); expect(client.fetchAgentTimeline).toHaveBeenCalledWith(agentId, { @@ -56,12 +65,11 @@ describe("useAgentInitialization", () => { const client = makeClient(); useSessionStore.getState().initializeSession(serverId, client as never); - const { result } = renderHook(() => - useAgentInitialization({ serverId, client: client as never }), - ); - - act(() => { - void result.current.ensureAgentIsInitialized(agentId); + void ensureAgentIsInitialized({ + serverId, + agentId, + client: client as never, + setAgentInitializing: bindSetAgentInitializing(), }); expect(client.fetchAgentTimeline).toHaveBeenCalledWith(agentId, { @@ -77,20 +85,17 @@ describe("useAgentInitialization", () => { const client = makeClient(); useSessionStore.getState().initializeSession(serverId, client as never); - const { result } = renderHook(() => - useAgentInitialization({ serverId, client: client as never }), - ); - - const promise = result.current.ensureAgentIsInitialized(agentId); - - act(() => { - vi.advanceTimersByTime(29_999); + const promise = ensureAgentIsInitialized({ + serverId, + agentId, + client: client as never, + setAgentInitializing: bindSetAgentInitializing(), }); + + vi.advanceTimersByTime(29_999); expect(getInitDeferred(getInitKey(serverId, agentId))).toBeDefined(); - act(() => { - vi.advanceTimersByTime(1); - }); + vi.advanceTimersByTime(1); await expect(promise).rejects.toThrow("History sync timed out after 30s"); expect(getInitDeferred(getInitKey(serverId, agentId))).toBeUndefined(); @@ -99,15 +104,17 @@ describe("useAgentInitialization", () => { ); vi.useRealTimers(); }); +}); - it("refresh fetches a bounded canonical tail after refreshing the agent", async () => { +describe("refreshAgent", () => { + it("fetches a bounded canonical tail after refreshing the agent", async () => { const client = makeClient(); - const { result } = renderHook(() => - useAgentInitialization({ serverId, client: client as never }), - ); + useSessionStore.getState().initializeSession(serverId, client as never); - await act(async () => { - await result.current.refreshAgent(agentId); + await refreshAgent({ + agentId, + client: client as never, + setAgentInitializing: bindSetAgentInitializing(), }); expect(client.refreshAgent).toHaveBeenCalledWith(agentId); diff --git a/packages/app/src/hooks/use-agent-initialization.ts b/packages/app/src/hooks/use-agent-initialization.ts index 6d89a074c..8e1e72deb 100644 --- a/packages/app/src/hooks/use-agent-initialization.ts +++ b/packages/app/src/hooks/use-agent-initialization.ts @@ -1,6 +1,6 @@ -import { useCallback } from "react"; -import { useSessionStore } from "@/stores/session-store"; +import { useCallback, useMemo } from "react"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { useSessionStore } from "@/stores/session-store"; import { attachInitTimeout, createInitDeferred, @@ -10,7 +10,109 @@ import { } from "@/utils/agent-initialization"; import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy"; -const INIT_TIMEOUT_MS = 30_000; +export const INIT_TIMEOUT_MS = 30_000; + +export type SetAgentInitializing = (agentId: string, initializing: boolean) => void; + +export interface EnsureAgentIsInitializedInput { + serverId: string; + agentId: string; + client: Pick | null; + setAgentInitializing: SetAgentInitializing; +} + +export function ensureAgentIsInitialized(input: EnsureAgentIsInitializedInput): Promise { + const { serverId, agentId, client, setAgentInitializing } = input; + const key = getInitKey(serverId, agentId); + const existing = getInitDeferred(key); + if (existing) { + return existing.promise; + } + + const session = useSessionStore.getState().sessions[serverId]; + const cursor = session?.agentTimelineCursor.get(agentId); + const hasAuthoritativeHistory = session?.agentAuthoritativeHistoryApplied.get(agentId) === true; + const timelineRequest = + hasAuthoritativeHistory && cursor + ? { + direction: "after" as const, + cursor: { epoch: cursor.epoch, seq: cursor.endSeq }, + limit: TIMELINE_FETCH_PAGE_SIZE, + projection: "canonical" as const, + } + : { + direction: "tail" as const, + limit: TIMELINE_FETCH_PAGE_SIZE, + projection: "canonical" as const, + }; + + const deferred = createInitDeferred(key, timelineRequest.direction); + const timeoutId = setTimeout(() => { + setAgentInitializing(agentId, false); + rejectInitDeferred( + key, + new Error(`History sync timed out after ${Math.round(INIT_TIMEOUT_MS / 1000)}s`), + ); + }, INIT_TIMEOUT_MS); + attachInitTimeout(key, timeoutId); + + setAgentInitializing(agentId, true); + + if (!client) { + setAgentInitializing(agentId, false); + rejectInitDeferred(key, new Error("Host is not connected")); + return deferred.promise; + } + + client.fetchAgentTimeline(agentId, timelineRequest).catch((error) => { + setAgentInitializing(agentId, false); + rejectInitDeferred(key, error instanceof Error ? error : new Error(String(error))); + }); + + return deferred.promise; +} + +export interface RefreshAgentInput { + agentId: string; + client: Pick | null; + setAgentInitializing: SetAgentInitializing; +} + +export async function refreshAgent(input: RefreshAgentInput): Promise { + const { agentId, client, setAgentInitializing } = input; + if (!client) { + throw new Error("Host is not connected"); + } + setAgentInitializing(agentId, true); + + try { + await client.refreshAgent(agentId); + await client.fetchAgentTimeline(agentId, { + direction: "tail", + limit: TIMELINE_FETCH_PAGE_SIZE, + projection: "canonical", + }); + } catch (error) { + setAgentInitializing(agentId, false); + throw error; + } +} + +export function createSetAgentInitializing( + serverId: string, + setInitializingAgents: ReturnType["setInitializingAgents"], +): SetAgentInitializing { + return (agentId, initializing) => { + setInitializingAgents(serverId, (prev) => { + if (prev.get(agentId) === initializing) { + return prev; + } + const next = new Map(prev); + next.set(agentId, initializing); + return next; + }); + }; +} export function useAgentInitialization({ serverId, @@ -20,95 +122,24 @@ export function useAgentInitialization({ client: DaemonClient | null; }) { const setInitializingAgents = useSessionStore((state) => state.setInitializingAgents); - const setAgentInitializing = useCallback( - (agentId: string, initializing: boolean) => { - setInitializingAgents(serverId, (prev) => { - if (prev.get(agentId) === initializing) { - return prev; - } - const next = new Map(prev); - next.set(agentId, initializing); - return next; - }); - }, + const setAgentInitializing = useMemo( + () => createSetAgentInitializing(serverId, setInitializingAgents), [serverId, setInitializingAgents], ); - const ensureAgentIsInitialized = useCallback( - (agentId: string): Promise => { - const key = getInitKey(serverId, agentId); - const existing = getInitDeferred(key); - if (existing) { - return existing.promise; - } - - const session = useSessionStore.getState().sessions[serverId]; - const cursor = session?.agentTimelineCursor.get(agentId); - const hasAuthoritativeHistory = - session?.agentAuthoritativeHistoryApplied.get(agentId) === true; - const timelineRequest = - hasAuthoritativeHistory && cursor - ? { - direction: "after" as const, - cursor: { epoch: cursor.epoch, seq: cursor.endSeq }, - limit: TIMELINE_FETCH_PAGE_SIZE, - projection: "canonical" as const, - } - : { - direction: "tail" as const, - limit: TIMELINE_FETCH_PAGE_SIZE, - projection: "canonical" as const, - }; - - const deferred = createInitDeferred(key, timelineRequest.direction); - const timeoutId = setTimeout(() => { - setAgentInitializing(agentId, false); - rejectInitDeferred( - key, - new Error(`History sync timed out after ${Math.round(INIT_TIMEOUT_MS / 1000)}s`), - ); - }, INIT_TIMEOUT_MS); - attachInitTimeout(key, timeoutId); - - setAgentInitializing(agentId, true); - - if (!client) { - setAgentInitializing(agentId, false); - rejectInitDeferred(key, new Error("Host is not connected")); - return deferred.promise; - } - - client.fetchAgentTimeline(agentId, timelineRequest).catch((error) => { - setAgentInitializing(agentId, false); - rejectInitDeferred(key, error instanceof Error ? error : new Error(String(error))); - }); - - return deferred.promise; - }, + const ensureAgentIsInitializedCallback = useCallback( + (agentId: string): Promise => + ensureAgentIsInitialized({ serverId, agentId, client, setAgentInitializing }), [client, serverId, setAgentInitializing], ); - const refreshAgent = useCallback( - async (agentId: string) => { - if (!client) { - throw new Error("Host is not connected"); - } - setAgentInitializing(agentId, true); - - try { - await client.refreshAgent(agentId); - await client.fetchAgentTimeline(agentId, { - direction: "tail", - limit: TIMELINE_FETCH_PAGE_SIZE, - projection: "canonical", - }); - } catch (error) { - setAgentInitializing(agentId, false); - throw error; - } - }, + const refreshAgentCallback = useCallback( + (agentId: string): Promise => refreshAgent({ agentId, client, setAgentInitializing }), [client, setAgentInitializing], ); - return { ensureAgentIsInitialized, refreshAgent }; + return { + ensureAgentIsInitialized: ensureAgentIsInitializedCallback, + refreshAgent: refreshAgentCallback, + }; } diff --git a/packages/app/src/hooks/use-archive-agent.test.ts b/packages/app/src/hooks/use-archive-agent.test.ts index bd05954f0..429fdc602 100644 --- a/packages/app/src/hooks/use-archive-agent.test.ts +++ b/packages/app/src/hooks/use-archive-agent.test.ts @@ -1,18 +1,15 @@ -/** - * @vitest-environment jsdom - */ -import React from "react"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { act, renderHook, waitFor } from "@testing-library/react"; +import { QueryClient } from "@tanstack/react-query"; import { beforeEach, describe, expect, it } from "vitest"; import type { Agent } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store"; import { agentHistoryQueryKey } from "./agent-history-query-key"; import { - __private__, applyArchivedAgentCloseResults, - usePendingArchiveAgentIds, + isAgentArchiving, + removeAgentFromListPayload, + selectPendingArchiveAgentIds, + setAgentArchiving, } from "./use-archive-agent"; function makeAgent(overrides: Partial = {}): Agent { @@ -56,14 +53,14 @@ describe("useArchiveAgent", () => { const queryClient = new QueryClient(); expect( - __private__.isAgentArchiving({ + isAgentArchiving({ queryClient, serverId: "server-a", agentId: "agent-1", }), ).toBe(false); - __private__.setAgentArchiving({ + setAgentArchiving({ queryClient, serverId: "server-a", agentId: "agent-1", @@ -71,21 +68,21 @@ describe("useArchiveAgent", () => { }); expect( - __private__.isAgentArchiving({ + isAgentArchiving({ queryClient, serverId: "server-a", agentId: "agent-1", }), ).toBe(true); expect( - __private__.isAgentArchiving({ + isAgentArchiving({ queryClient, serverId: "server-a", agentId: "agent-2", }), ).toBe(false); - __private__.setAgentArchiving({ + setAgentArchiving({ queryClient, serverId: "server-a", agentId: "agent-1", @@ -93,7 +90,7 @@ describe("useArchiveAgent", () => { }); expect( - __private__.isAgentArchiving({ + isAgentArchiving({ queryClient, serverId: "server-a", agentId: "agent-1", @@ -102,7 +99,7 @@ describe("useArchiveAgent", () => { }); it("selects pending archive ids for a single server", () => { - const pendingIds = __private__.selectPendingArchiveAgentIds( + const pendingIds = selectPendingArchiveAgentIds( { "server-a:agent-1": true, "server-a:agent-2": true, @@ -114,42 +111,13 @@ describe("useArchiveAgent", () => { expect(Array.from(pendingIds)).toEqual(["agent-1", "agent-2"]); }); - it("subscribes to pending archive ids through react-query", async () => { - const queryClient = new QueryClient(); - const wrapper = ({ children }: { children: React.ReactNode }) => - React.createElement(QueryClientProvider, { client: queryClient }, children); - - const { result } = renderHook(() => usePendingArchiveAgentIds("server-a"), { wrapper }); - - expect(Array.from(result.current)).toEqual([]); - - act(() => { - __private__.setAgentArchiving({ - queryClient, - serverId: "server-a", - agentId: "agent-1", - isArchiving: true, - }); - __private__.setAgentArchiving({ - queryClient, - serverId: "server-b", - agentId: "agent-2", - isArchiving: true, - }); - }); - - await waitFor(() => { - expect(Array.from(result.current)).toEqual(["agent-1"]); - }); - }); - it("removes an archived agent from cached list payloads", () => { const payload = { entries: [{ agent: { id: "agent-1" } }, { agent: { id: "agent-2" } }], pageInfo: { hasMore: false }, }; - const next = __private__.removeAgentFromListPayload(payload, "agent-1"); + const next = removeAgentFromListPayload(payload, "agent-1"); expect(next.entries).toEqual([{ agent: { id: "agent-2" } }]); expect(next.pageInfo).toEqual({ hasMore: false }); diff --git a/packages/app/src/hooks/use-archive-agent.ts b/packages/app/src/hooks/use-archive-agent.ts index 6122893ae..333e0b71c 100644 --- a/packages/app/src/hooks/use-archive-agent.ts +++ b/packages/app/src/hooks/use-archive-agent.ts @@ -11,12 +11,7 @@ export interface ArchiveAgentInput { agentId: string; } -export interface ArchivedAgentCloseResult { - agentId: string; - archivedAt: string; -} - -type ArchiveAgentPendingState = Record; +export type ArchiveAgentPendingState = Record; interface SetAgentArchivingInput extends ArchiveAgentInput { queryClient: QueryClient; @@ -27,35 +22,24 @@ interface IsAgentArchivingInput extends ArchiveAgentInput { queryClient: QueryClient; } -interface AgentsListQueryData { +export interface AgentsListQueryData { entries?: Array<{ agent?: { id?: string | null } | null } | null>; } -interface ArchivedAgentListCacheSnapshot { - sidebarAgentsList: AgentsListQueryData | undefined; - allAgents: AgentsListQueryData | undefined; - agentHistory: AgentHistoryQueryData | undefined; -} - -interface ArchiveAgentMutationContext { - agent: ReturnType; - lists: ArchivedAgentListCacheSnapshot; -} - -interface AgentHistoryQueryAgent { +export interface AgentHistoryQueryAgent { id?: string | null; archivedAt?: Date | null; } -interface AgentHistoryQueryPage { +export interface AgentHistoryQueryPage { agents?: AgentHistoryQueryAgent[]; } -interface AgentHistoryQueryData { +export interface AgentHistoryQueryData { pages?: AgentHistoryQueryPage[]; } -function toArchiveKey(input: ArchiveAgentInput): string { +export function toArchiveKey(input: ArchiveAgentInput): string { const serverId = input.serverId.trim(); const agentId = input.agentId.trim(); if (!serverId || !agentId) { @@ -64,11 +48,11 @@ function toArchiveKey(input: ArchiveAgentInput): string { return `${serverId}:${agentId}`; } -function readPendingState(queryClient: QueryClient): ArchiveAgentPendingState { +export function readPendingState(queryClient: QueryClient): ArchiveAgentPendingState { return queryClient.getQueryData(ARCHIVE_AGENT_PENDING_QUERY_KEY) ?? {}; } -function selectPendingArchiveAgentIds( +export function selectPendingArchiveAgentIds( pendingState: ArchiveAgentPendingState, serverId: string, ): ReadonlySet { @@ -97,7 +81,7 @@ function selectPendingArchiveAgentIds( return new Set(agentIds); } -function setAgentArchiving(input: SetAgentArchivingInput): void { +export function setAgentArchiving(input: SetAgentArchivingInput): void { const key = toArchiveKey(input); if (!key) { return; @@ -125,7 +109,7 @@ function setAgentArchiving(input: SetAgentArchivingInput): void { ); } -function isAgentArchiving(input: IsAgentArchivingInput): boolean { +export function isAgentArchiving(input: IsAgentArchivingInput): boolean { const key = toArchiveKey(input); if (!key) { return false; @@ -133,7 +117,7 @@ function isAgentArchiving(input: IsAgentArchivingInput): boolean { return readPendingState(input.queryClient)[key] ?? false; } -function removeAgentFromListPayload( +export function removeAgentFromListPayload( payload: T, agentId: string, ): T { @@ -150,7 +134,10 @@ function removeAgentFromListPayload( } as T; } -function removeAgentFromCachedLists(queryClient: QueryClient, input: ArchiveAgentInput): void { +export function removeAgentFromCachedLists( + queryClient: QueryClient, + input: ArchiveAgentInput, +): void { const agentId = input.agentId.trim(); if (!agentId) { return; @@ -166,7 +153,7 @@ function removeAgentFromCachedLists(queryClient: QueryClient, input: ArchiveAgen ); } -function markAgentArchivedInHistoryPayload( +export function markAgentArchivedInHistoryPayload( payload: T, input: ArchiveAgentInput & { archivedAt: string }, ): T { @@ -204,7 +191,7 @@ function markAgentArchivedInHistoryPayload; + lists: ArchivedAgentListCacheSnapshot; +} + function getStoredAgentSnapshot(input: ArchiveAgentInput) { return useSessionStore.getState().sessions[input.serverId]?.agents.get(input.agentId); } @@ -352,13 +362,6 @@ export function applyArchivedAgentCloseResults(input: ApplyArchivedAgentCloseRes } } -export function clearArchiveAgentPending(input: IsAgentArchivingInput): void { - setAgentArchiving({ - ...input, - isArchiving: false, - }); -} - function useArchiveAgentPendingQuery() { return useQuery({ queryKey: ARCHIVE_AGENT_PENDING_QUERY_KEY, @@ -472,13 +475,3 @@ export function useArchiveAgent() { isArchivingAgent, }; } - -export const __private__ = { - toArchiveKey, - readPendingState, - selectPendingArchiveAgentIds, - setAgentArchiving, - isAgentArchiving, - removeAgentFromListPayload, - markAgentArchivedInHistoryPayload, -}; diff --git a/packages/app/src/hooks/use-changes-preferences.test.ts b/packages/app/src/hooks/use-changes-preferences.test.ts deleted file mode 100644 index 04451a73d..000000000 --- a/packages/app/src/hooks/use-changes-preferences.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const asyncStorageMock = vi.hoisted(() => ({ - getItem: vi.fn<(_: string) => Promise>(), - setItem: vi.fn<(_: string, __: string) => Promise>(), -})); - -vi.mock("@react-native-async-storage/async-storage", () => ({ - default: asyncStorageMock, -})); - -describe("use-changes-preferences", () => { - beforeEach(() => { - vi.resetModules(); - asyncStorageMock.getItem.mockReset(); - asyncStorageMock.setItem.mockReset(); - }); - - it("defaults to unified layout with visible whitespace", async () => { - asyncStorageMock.getItem.mockResolvedValue(null); - asyncStorageMock.setItem.mockResolvedValue(); - - const mod = await import("./use-changes-preferences"); - const result = await mod.loadChangesPreferencesFromStorage(); - - expect(result).toEqual(mod.DEFAULT_CHANGES_PREFERENCES); - expect(asyncStorageMock.setItem).toHaveBeenCalledWith( - "@paseo:changes-preferences", - JSON.stringify(mod.DEFAULT_CHANGES_PREFERENCES), - ); - }); - - it("migrates the legacy wrap-lines toggle into the new preferences object", async () => { - asyncStorageMock.getItem.mockImplementation(async (key: string) => { - if (key === "diff-wrap-lines") { - return "true"; - } - return null; - }); - asyncStorageMock.setItem.mockResolvedValue(); - - const mod = await import("./use-changes-preferences"); - const result = await mod.loadChangesPreferencesFromStorage(); - - expect(result).toEqual({ - layout: "unified", - wrapLines: true, - hideWhitespace: false, - }); - }); - - it("loads persisted layout and whitespace preferences", async () => { - asyncStorageMock.getItem.mockImplementation(async (key: string) => { - if (key === "@paseo:changes-preferences") { - return JSON.stringify({ - layout: "split", - hideWhitespace: true, - wrapLines: false, - }); - } - return null; - }); - - const mod = await import("./use-changes-preferences"); - const result = await mod.loadChangesPreferencesFromStorage(); - - expect(result).toEqual({ - layout: "split", - hideWhitespace: true, - wrapLines: false, - }); - expect(asyncStorageMock.setItem).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/app/src/hooks/use-changes-preferences.ts b/packages/app/src/hooks/use-changes-preferences.ts deleted file mode 100644 index cfc6ee57b..000000000 --- a/packages/app/src/hooks/use-changes-preferences.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { useCallback } from "react"; -import AsyncStorage from "@react-native-async-storage/async-storage"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { z } from "zod"; - -const CHANGES_PREFERENCES_STORAGE_KEY = "@paseo:changes-preferences"; -const LEGACY_WRAP_LINES_STORAGE_KEY = "diff-wrap-lines"; -const CHANGES_PREFERENCES_QUERY_KEY = ["changes-preferences"]; - -const changesPreferencesSchema = z.object({ - layout: z.enum(["unified", "split"]).optional(), - wrapLines: z.boolean().optional(), - hideWhitespace: z.boolean().optional(), -}); - -export interface ChangesPreferences { - layout: "unified" | "split"; - wrapLines: boolean; - hideWhitespace: boolean; -} - -export const DEFAULT_CHANGES_PREFERENCES: ChangesPreferences = { - layout: "unified", - wrapLines: false, - hideWhitespace: false, -}; - -async function loadLegacyWrapLinesPreference(): Promise { - const legacyValue = await AsyncStorage.getItem(LEGACY_WRAP_LINES_STORAGE_KEY); - if (legacyValue === "true") { - return true; - } - if (legacyValue === "false") { - return false; - } - return null; -} - -export async function loadChangesPreferencesFromStorage(): Promise { - const stored = await AsyncStorage.getItem(CHANGES_PREFERENCES_STORAGE_KEY); - if (stored) { - const parsed = changesPreferencesSchema.safeParse(JSON.parse(stored)); - if (parsed.success) { - return { ...DEFAULT_CHANGES_PREFERENCES, ...parsed.data }; - } - } - - const legacyWrapLines = await loadLegacyWrapLinesPreference(); - const next = { - ...DEFAULT_CHANGES_PREFERENCES, - ...(legacyWrapLines !== null ? { wrapLines: legacyWrapLines } : {}), - } satisfies ChangesPreferences; - await AsyncStorage.setItem(CHANGES_PREFERENCES_STORAGE_KEY, JSON.stringify(next)); - return next; -} - -export interface UseChangesPreferencesReturn { - preferences: ChangesPreferences; - isLoading: boolean; - updatePreferences: (updates: Partial) => Promise; -} - -export function useChangesPreferences(): UseChangesPreferencesReturn { - const queryClient = useQueryClient(); - const { data, isPending } = useQuery({ - queryKey: CHANGES_PREFERENCES_QUERY_KEY, - queryFn: loadChangesPreferencesFromStorage, - staleTime: Infinity, - gcTime: Infinity, - }); - - const updatePreferences = useCallback( - async (updates: Partial) => { - const prev = - queryClient.getQueryData(CHANGES_PREFERENCES_QUERY_KEY) ?? - DEFAULT_CHANGES_PREFERENCES; - const next = { ...prev, ...updates }; - queryClient.setQueryData(CHANGES_PREFERENCES_QUERY_KEY, next); - await AsyncStorage.setItem(CHANGES_PREFERENCES_STORAGE_KEY, JSON.stringify(next)); - }, - [queryClient], - ); - - return { - preferences: data ?? DEFAULT_CHANGES_PREFERENCES, - isLoading: isPending, - updatePreferences, - }; -} diff --git a/packages/app/src/hooks/use-changes-preferences/fakes.ts b/packages/app/src/hooks/use-changes-preferences/fakes.ts new file mode 100644 index 000000000..96e7a8490 --- /dev/null +++ b/packages/app/src/hooks/use-changes-preferences/fakes.ts @@ -0,0 +1,20 @@ +import type { KeyValueStorage } from "./storage"; + +export interface InMemoryKeyValueStorage extends KeyValueStorage { + readonly entries: Map; +} + +export function createInMemoryKeyValueStorage( + initial: Record = {}, +): InMemoryKeyValueStorage { + const entries = new Map(Object.entries(initial)); + return { + entries, + async getItem(key) { + return entries.get(key) ?? null; + }, + async setItem(key, value) { + entries.set(key, value); + }, + }; +} diff --git a/packages/app/src/hooks/use-changes-preferences/index.ts b/packages/app/src/hooks/use-changes-preferences/index.ts new file mode 100644 index 000000000..72570f6d2 --- /dev/null +++ b/packages/app/src/hooks/use-changes-preferences/index.ts @@ -0,0 +1,52 @@ +import { useCallback } from "react"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { + CHANGES_PREFERENCES_QUERY_KEY, + DEFAULT_CHANGES_PREFERENCES, + loadChangesPreferencesFromStorage as loadChangesPreferencesFromStoragePure, + saveChangesPreferences as saveChangesPreferencesPure, + type ChangesPreferences, + type KeyValueStorage, +} from "./storage"; + +export { DEFAULT_CHANGES_PREFERENCES, type ChangesPreferences, type KeyValueStorage }; + +const productionStorage: KeyValueStorage = AsyncStorage; + +export function loadChangesPreferencesFromStorage(): Promise { + return loadChangesPreferencesFromStoragePure(productionStorage); +} + +export interface UseChangesPreferencesReturn { + preferences: ChangesPreferences; + isLoading: boolean; + updatePreferences: (updates: Partial) => Promise; +} + +export function useChangesPreferences(): UseChangesPreferencesReturn { + const queryClient = useQueryClient(); + const { data, isPending } = useQuery({ + queryKey: CHANGES_PREFERENCES_QUERY_KEY, + queryFn: loadChangesPreferencesFromStorage, + staleTime: Infinity, + gcTime: Infinity, + }); + + const updatePreferences = useCallback( + async (updates: Partial) => { + await saveChangesPreferencesPure({ + queryClient, + updates, + storage: productionStorage, + }); + }, + [queryClient], + ); + + return { + preferences: data ?? DEFAULT_CHANGES_PREFERENCES, + isLoading: isPending, + updatePreferences, + }; +} diff --git a/packages/app/src/hooks/use-changes-preferences/storage.test.ts b/packages/app/src/hooks/use-changes-preferences/storage.test.ts new file mode 100644 index 000000000..044cec1a9 --- /dev/null +++ b/packages/app/src/hooks/use-changes-preferences/storage.test.ts @@ -0,0 +1,89 @@ +import { QueryClient } from "@tanstack/react-query"; +import { describe, expect, it } from "vitest"; +import { createInMemoryKeyValueStorage } from "./fakes"; +import { + CHANGES_PREFERENCES_QUERY_KEY, + CHANGES_PREFERENCES_STORAGE_KEY, + DEFAULT_CHANGES_PREFERENCES, + loadChangesPreferencesFromStorage, + saveChangesPreferences, +} from "./storage"; + +describe("loadChangesPreferencesFromStorage", () => { + it("defaults to unified layout with visible whitespace and writes the defaults back", async () => { + const storage = createInMemoryKeyValueStorage(); + + const result = await loadChangesPreferencesFromStorage(storage); + + expect(result).toEqual(DEFAULT_CHANGES_PREFERENCES); + expect(storage.entries.get(CHANGES_PREFERENCES_STORAGE_KEY)).toBe( + JSON.stringify(DEFAULT_CHANGES_PREFERENCES), + ); + }); + + it("migrates the legacy wrap-lines toggle into the new preferences object", async () => { + const storage = createInMemoryKeyValueStorage({ "diff-wrap-lines": "true" }); + + const result = await loadChangesPreferencesFromStorage(storage); + + expect(result).toEqual({ + layout: "unified", + wrapLines: true, + hideWhitespace: false, + }); + expect(storage.entries.get(CHANGES_PREFERENCES_STORAGE_KEY)).toBe(JSON.stringify(result)); + }); + + it("loads persisted layout and whitespace preferences without rewriting storage", async () => { + const persisted = JSON.stringify({ + layout: "split", + hideWhitespace: true, + wrapLines: false, + }); + const storage = createInMemoryKeyValueStorage({ + [CHANGES_PREFERENCES_STORAGE_KEY]: persisted, + }); + + const result = await loadChangesPreferencesFromStorage(storage); + + expect(result).toEqual({ + layout: "split", + hideWhitespace: true, + wrapLines: false, + }); + expect(storage.entries.get(CHANGES_PREFERENCES_STORAGE_KEY)).toBe(persisted); + expect(storage.entries.size).toBe(1); + }); +}); + +describe("saveChangesPreferences", () => { + it("merges updates onto cached preferences and persists the result", async () => { + const storage = createInMemoryKeyValueStorage(); + const queryClient = new QueryClient(); + queryClient.setQueryData(CHANGES_PREFERENCES_QUERY_KEY, DEFAULT_CHANGES_PREFERENCES); + + await saveChangesPreferences({ + queryClient, + updates: { layout: "split", hideWhitespace: true }, + storage, + }); + + const expected = { ...DEFAULT_CHANGES_PREFERENCES, layout: "split", hideWhitespace: true }; + expect(queryClient.getQueryData(CHANGES_PREFERENCES_QUERY_KEY)).toEqual(expected); + expect(storage.entries.get(CHANGES_PREFERENCES_STORAGE_KEY)).toBe(JSON.stringify(expected)); + }); + + it("falls back to defaults when the query cache has no prior preferences", async () => { + const storage = createInMemoryKeyValueStorage(); + const queryClient = new QueryClient(); + + await saveChangesPreferences({ + queryClient, + updates: { wrapLines: true }, + storage, + }); + + const expected = { ...DEFAULT_CHANGES_PREFERENCES, wrapLines: true }; + expect(storage.entries.get(CHANGES_PREFERENCES_STORAGE_KEY)).toBe(JSON.stringify(expected)); + }); +}); diff --git a/packages/app/src/hooks/use-changes-preferences/storage.ts b/packages/app/src/hooks/use-changes-preferences/storage.ts new file mode 100644 index 000000000..0c6f8ecf4 --- /dev/null +++ b/packages/app/src/hooks/use-changes-preferences/storage.ts @@ -0,0 +1,73 @@ +import type { QueryClient } from "@tanstack/react-query"; +import { z } from "zod"; + +export const CHANGES_PREFERENCES_STORAGE_KEY = "@paseo:changes-preferences"; +export const LEGACY_WRAP_LINES_STORAGE_KEY = "diff-wrap-lines"; +export const CHANGES_PREFERENCES_QUERY_KEY = ["changes-preferences"]; + +const changesPreferencesSchema = z.object({ + layout: z.enum(["unified", "split"]).optional(), + wrapLines: z.boolean().optional(), + hideWhitespace: z.boolean().optional(), +}); + +export interface ChangesPreferences { + layout: "unified" | "split"; + wrapLines: boolean; + hideWhitespace: boolean; +} + +export const DEFAULT_CHANGES_PREFERENCES: ChangesPreferences = { + layout: "unified", + wrapLines: false, + hideWhitespace: false, +}; + +export interface KeyValueStorage { + getItem(key: string): Promise; + setItem(key: string, value: string): Promise; +} + +async function loadLegacyWrapLinesPreference(storage: KeyValueStorage): Promise { + const legacyValue = await storage.getItem(LEGACY_WRAP_LINES_STORAGE_KEY); + if (legacyValue === "true") { + return true; + } + if (legacyValue === "false") { + return false; + } + return null; +} + +export async function loadChangesPreferencesFromStorage( + storage: KeyValueStorage, +): Promise { + const stored = await storage.getItem(CHANGES_PREFERENCES_STORAGE_KEY); + if (stored) { + const parsed = changesPreferencesSchema.safeParse(JSON.parse(stored)); + if (parsed.success) { + return { ...DEFAULT_CHANGES_PREFERENCES, ...parsed.data }; + } + } + + const legacyWrapLines = await loadLegacyWrapLinesPreference(storage); + const next = { + ...DEFAULT_CHANGES_PREFERENCES, + ...(legacyWrapLines !== null ? { wrapLines: legacyWrapLines } : {}), + } satisfies ChangesPreferences; + await storage.setItem(CHANGES_PREFERENCES_STORAGE_KEY, JSON.stringify(next)); + return next; +} + +export async function saveChangesPreferences(input: { + queryClient: QueryClient; + updates: Partial; + storage: KeyValueStorage; +}): Promise { + const prev = + input.queryClient.getQueryData(CHANGES_PREFERENCES_QUERY_KEY) ?? + DEFAULT_CHANGES_PREFERENCES; + const next = { ...prev, ...input.updates }; + input.queryClient.setQueryData(CHANGES_PREFERENCES_QUERY_KEY, next); + await input.storage.setItem(CHANGES_PREFERENCES_STORAGE_KEY, JSON.stringify(next)); +} diff --git a/packages/app/src/hooks/use-client-activity.test.tsx b/packages/app/src/hooks/use-client-activity.test.tsx deleted file mode 100644 index 98d17400a..000000000 --- a/packages/app/src/hooks/use-client-activity.test.tsx +++ /dev/null @@ -1,244 +0,0 @@ -import React, { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { JSDOM } from "jsdom"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { useClientActivity } from "./use-client-activity"; - -interface HeartbeatPayload { - deviceType: "web" | "mobile"; - focusedAgentId: string | null; - lastActivityAt: string; - appVisible: boolean; - appVisibilityChangedAt?: string; -} - -const { platformState, getDesktopSystemIdleTimeMs } = vi.hoisted(() => ({ - platformState: { - isWeb: true, - isNative: false, - isElectron: false, - }, - getDesktopSystemIdleTimeMs: vi.fn<() => Promise>(), -})); - -vi.mock("@/constants/platform", () => ({ - get isWeb() { - return platformState.isWeb; - }, - get isNative() { - return platformState.isNative; - }, - getIsElectron: () => platformState.isElectron, -})); - -vi.mock("@/desktop/electron/idle", () => ({ - getDesktopSystemIdleTimeMs, -})); - -vi.mock("react-native", () => ({ - AppState: { - currentState: "active", - addEventListener: vi.fn(() => ({ - remove: vi.fn(), - })), - }, -})); - -vi.mock("@getpaseo/client/internal/daemon-client", () => ({})); - -function createTestClient() { - return { - isConnected: true, - subscribeConnectionStatus: vi.fn((listener: (state: { status: "connected" }) => void) => { - listener({ status: "connected" }); - return vi.fn(); - }), - sendHeartbeat: vi.fn<(payload: HeartbeatPayload) => void>(), - }; -} - -function latestHeartbeat(client: ReturnType): HeartbeatPayload { - const call = client.sendHeartbeat.mock.calls.at(-1); - if (!call) { - throw new Error("Expected a heartbeat"); - } - return call[0]; -} - -function heartbeatTimeMs(client: ReturnType): number { - return new Date(latestHeartbeat(client).lastActivityAt).getTime(); -} - -async function renderActivityHook({ - client = createTestClient(), - focusedAgentId = "agent-1", -}: { - client?: ReturnType; - focusedAgentId?: string | null; -} = {}) { - function Probe({ focusedAgentId: probeFocusedAgentId }: { focusedAgentId: string | null }) { - useClientActivity({ - client: client as unknown as Parameters[0]["client"], - focusedAgentId: probeFocusedAgentId, - }); - return null; - } - - const container = document.createElement("div"); - document.body.appendChild(container); - const root = createRoot(container); - - await act(async () => { - root.render(); - }); - - const rerender = async (nextFocusedAgentId: string | null) => { - await act(async () => { - root.render(); - }); - }; - - return { client, rerender, root }; -} - -async function advance(ms: number) { - await act(async () => { - await vi.advanceTimersByTimeAsync(ms); - }); -} - -describe("useClientActivity", () => { - let root: Root | null = null; - - beforeEach(() => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-04-19T10:00:00.000Z")); - - platformState.isWeb = true; - platformState.isNative = false; - platformState.isElectron = false; - getDesktopSystemIdleTimeMs.mockReset(); - - const dom = new JSDOM("", { - url: "http://localhost", - }); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - vi.stubGlobal("window", dom.window); - vi.stubGlobal("document", dom.window.document); - }); - - afterEach(async () => { - if (root) { - await act(async () => { - root?.unmount(); - }); - root = null; - } - vi.unstubAllGlobals(); - vi.useRealTimers(); - }); - - it("updates lastActivityAt from web pointer activity", async () => { - const rendered = await renderActivityHook(); - root = rendered.root; - - vi.setSystemTime(new Date("2026-04-19T10:00:05.250Z")); - window.dispatchEvent(new window.Event("pointerdown")); - - expect(heartbeatTimeMs(rendered.client)).toBe(Date.now()); - expect(getDesktopSystemIdleTimeMs).not.toHaveBeenCalled(); - }); - - it("sends one immediate heartbeat when the focused agent changes", async () => { - const rendered = await renderActivityHook(); - root = rendered.root; - - expect(rendered.client.sendHeartbeat).toHaveBeenCalledTimes(1); - rendered.client.sendHeartbeat.mockClear(); - - vi.setSystemTime(new Date("2026-04-19T10:00:05.000Z")); - await rendered.rerender("agent-2"); - - expect(rendered.client.subscribeConnectionStatus).toHaveBeenCalledTimes(1); - expect(rendered.client.sendHeartbeat).toHaveBeenCalledTimes(1); - expect(latestHeartbeat(rendered.client)).toMatchObject({ - focusedAgentId: "agent-2", - lastActivityAt: "2026-04-19T10:00:05.000Z", - }); - }); - - it("drives lastActivityAt forward from Electron idle polling", async () => { - platformState.isElectron = true; - getDesktopSystemIdleTimeMs.mockResolvedValue(0); - const rendered = await renderActivityHook(); - root = rendered.root; - - await advance(5_000); - expect(getDesktopSystemIdleTimeMs).toHaveBeenCalledTimes(1); - - await advance(10_000); - - expect(heartbeatTimeMs(rendered.client)).toBe(Date.now()); - }); - - it("sets lastActivityAt to Date.now() minus the Electron idle time", async () => { - platformState.isElectron = true; - getDesktopSystemIdleTimeMs.mockResolvedValue(2_000); - const rendered = await renderActivityHook(); - root = rendered.root; - - await advance(5_000); - await advance(10_000); - - expect(heartbeatTimeMs(rendered.client)).toBe(Date.now() - 2_000); - }); - - it("skips failed Electron idle polls", async () => { - platformState.isElectron = true; - getDesktopSystemIdleTimeMs.mockResolvedValue(null); - const rendered = await renderActivityHook(); - root = rendered.root; - const previousLastActivityAt = heartbeatTimeMs(rendered.client); - - await advance(5_000); - await advance(10_000); - - expect(heartbeatTimeMs(rendered.client)).toBe(previousLastActivityAt); - }); - - it("never moves lastActivityAt backward from an idle poll", async () => { - platformState.isElectron = true; - getDesktopSystemIdleTimeMs.mockResolvedValue(20_000); - const rendered = await renderActivityHook(); - root = rendered.root; - - vi.setSystemTime(new Date("2026-04-19T10:00:05.000Z")); - window.dispatchEvent(new window.Event("pointerdown")); - const pointerActivityAt = latestHeartbeat(rendered.client).lastActivityAt; - - await advance(5_000); - expect(getDesktopSystemIdleTimeMs).toHaveBeenCalled(); - - await advance(10_000); - - expect(latestHeartbeat(rendered.client).lastActivityAt).toBe(pointerActivityAt); - }); - - it("keeps an Electron pointerdown newer than a stale idle poll", async () => { - platformState.isElectron = true; - getDesktopSystemIdleTimeMs.mockResolvedValue(20_000); - const rendered = await renderActivityHook(); - root = rendered.root; - - await advance(4_500); - window.dispatchEvent(new window.Event("pointerdown")); - const pointerActivityAt = latestHeartbeat(rendered.client).lastActivityAt; - - await advance(500); - expect(getDesktopSystemIdleTimeMs).toHaveBeenCalled(); - - await advance(10_000); - - expect(latestHeartbeat(rendered.client).lastActivityAt).toBe(pointerActivityAt); - }); -}); diff --git a/packages/app/src/hooks/use-client-activity.ts b/packages/app/src/hooks/use-client-activity.ts index 257b59d31..3dfcb6c0c 100644 --- a/packages/app/src/hooks/use-client-activity.ts +++ b/packages/app/src/hooks/use-client-activity.ts @@ -1,13 +1,15 @@ -import { useEffect, useRef, useCallback } from "react"; +import { useEffect, useRef } from "react"; import { AppState } from "react-native"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { getIsElectron, isWeb, isNative } from "@/constants/platform"; -import { getDesktopSystemIdleTimeMs } from "@/desktop/electron/idle"; -import { useStableEvent } from "@/hooks/use-stable-event"; - -const HEARTBEAT_INTERVAL_MS = 15_000; -const ACTIVITY_HEARTBEAT_THROTTLE_MS = 5_000; -const DESKTOP_IDLE_POLL_INTERVAL_MS = 5_000; +import { readDesktopSystemIdleTimeMs } from "@/desktop/electron/idle"; +import { invokeDesktopCommand } from "@/desktop/electron/invoke"; +import { + type ClientActivityTracker, + createClientActivityTracker, + DESKTOP_IDLE_POLL_INTERVAL_MS, + HEARTBEAT_INTERVAL_MS, +} from "./client-activity-tracker"; interface ClientActivityOptions { client: DaemonClient; @@ -26,93 +28,46 @@ export function useClientActivity({ focusedAgentId, onAppResumed, }: ClientActivityOptions): void { - const lastActivityAtRef = useRef(new Date()); - const appVisibleRef = useRef(AppState.currentState === "active"); - const appVisibilityChangedAtRef = useRef(new Date()); - const backgroundedAtMsRef = useRef( - AppState.currentState === "active" ? null : Date.now(), - ); - const heartbeatIntervalRef = useRef | null>(null); - const prevFocusedAgentIdRef = useRef(focusedAgentId); - const lastImmediateHeartbeatAtRef = useRef(0); + const onAppResumedRef = useRef(onAppResumed); + onAppResumedRef.current = onAppResumed; - const deviceType = isWeb ? "web" : "mobile"; - - const recordUserActivity = useCallback(() => { - lastActivityAtRef.current = new Date(); - }, []); - - const sendHeartbeat = useStableEvent(() => { - if (!client.isConnected) return; - client.sendHeartbeat({ - deviceType, - focusedAgentId, - lastActivityAt: lastActivityAtRef.current.toISOString(), - appVisible: appVisibleRef.current, - appVisibilityChangedAt: appVisibilityChangedAtRef.current.toISOString(), + const trackerRef = useRef(null); + if (!trackerRef.current) { + trackerRef.current = createClientActivityTracker({ + client, + deviceType: isWeb ? "web" : "mobile", + initialFocusedAgentId: focusedAgentId, + initialAppVisible: AppState.currentState === "active", + now: () => Date.now(), + onAppResumed: (awayMs) => onAppResumedRef.current?.(awayMs), }); - }); + } + const tracker = trackerRef.current; - const setAppVisible = useCallback( - (nextVisible: boolean) => { - const previousVisible = appVisibleRef.current; - if (previousVisible === nextVisible) { - return; - } - appVisibleRef.current = nextVisible; - appVisibilityChangedAtRef.current = new Date(); - - if (!nextVisible) { - backgroundedAtMsRef.current = Date.now(); - return; - } - - const backgroundedAt = backgroundedAtMsRef.current; - backgroundedAtMsRef.current = null; - if (backgroundedAt !== null) { - onAppResumed?.(Math.max(0, Date.now() - backgroundedAt)); - } - recordUserActivity(); - }, - [onAppResumed, recordUserActivity], - ); - - const maybeSendImmediateHeartbeat = useCallback(() => { - if (!client.isConnected) return; - const now = Date.now(); - if (now - lastImmediateHeartbeatAtRef.current < ACTIVITY_HEARTBEAT_THROTTLE_MS) { - return; - } - lastImmediateHeartbeatAtRef.current = now; - sendHeartbeat(); - }, [client, sendHeartbeat]); - - // Track app visibility + // Track app visibility via AppState (native). useEffect(() => { const subscription = AppState.addEventListener("change", (nextState) => { - setAppVisible(nextState === "active"); - // Send immediately on visibility changes so the server can adapt streaming behavior. - sendHeartbeat(); + tracker.notifyAppVisibility(nextState === "active"); + tracker.sendHeartbeat(); }); - return () => subscription.remove(); - }, [sendHeartbeat, setAppVisible]); + }, [tracker]); - // Track user activity on web for accurate staleness. + // Track user activity and visibility on web. useEffect(() => { if (isNative) return; if (typeof document === "undefined") return; const handleUserActivity = () => { - recordUserActivity(); - maybeSendImmediateHeartbeat(); + tracker.recordUserActivity(); + tracker.maybeSendImmediateHeartbeat(); }; const handleVisibilityChange = () => { const visible = document.visibilityState === "visible"; - setAppVisible(visible); - if (visible) { - maybeSendImmediateHeartbeat(); + const { changed } = tracker.notifyAppVisibility(visible); + if (changed && visible) { + tracker.maybeSendImmediateHeartbeat(); } }; @@ -131,7 +86,7 @@ export function useClientActivity({ window.removeEventListener("wheel", handleUserActivity); window.removeEventListener("touchstart", handleUserActivity); }; - }, [maybeSendImmediateHeartbeat, recordUserActivity, setAppVisible]); + }, [tracker]); // Track OS-wide activity in Electron so backgrounded desktop windows still report presence. useEffect(() => { @@ -139,13 +94,9 @@ export function useClientActivity({ let disposed = false; const pollSystemIdleTime = async () => { - const systemIdleMs = await getDesktopSystemIdleTimeMs(); - if (disposed || systemIdleMs === null) return; - - const systemLastActivityAtMs = Date.now() - systemIdleMs; - if (systemLastActivityAtMs > lastActivityAtRef.current.getTime()) { - lastActivityAtRef.current = new Date(systemLastActivityAtMs); - } + const systemIdleMs = await readDesktopSystemIdleTimeMs(invokeDesktopCommand); + if (disposed) return; + tracker.notifySystemIdleMs(systemIdleMs); }; const interval = setInterval(() => { @@ -156,45 +107,41 @@ export function useClientActivity({ disposed = true; clearInterval(interval); }; - }, [client]); + }, [tracker]); - // Send heartbeat on focused agent change + // Send heartbeat on focused agent change. useEffect(() => { - if (prevFocusedAgentIdRef.current !== focusedAgentId) { - prevFocusedAgentIdRef.current = focusedAgentId; - recordUserActivity(); - sendHeartbeat(); - } - }, [focusedAgentId, recordUserActivity, sendHeartbeat]); + tracker.setFocusedAgentId(focusedAgentId); + }, [focusedAgentId, tracker]); - // Periodic heartbeat + // Periodic heartbeat gated by connection status. useEffect(() => { - const startHeartbeat = () => { - if (heartbeatIntervalRef.current) { - clearInterval(heartbeatIntervalRef.current); - } - sendHeartbeat(); - heartbeatIntervalRef.current = setInterval(sendHeartbeat, HEARTBEAT_INTERVAL_MS); + let intervalId: ReturnType | null = null; + + const start = () => { + if (intervalId) clearInterval(intervalId); + tracker.sendHeartbeat(); + intervalId = setInterval(() => tracker.sendHeartbeat(), HEARTBEAT_INTERVAL_MS); }; - const stopHeartbeat = () => { - if (heartbeatIntervalRef.current) { - clearInterval(heartbeatIntervalRef.current); - heartbeatIntervalRef.current = null; + const stop = () => { + if (intervalId) { + clearInterval(intervalId); + intervalId = null; } }; const unsubscribe = client.subscribeConnectionStatus((state) => { if (state.status === "connected") { - startHeartbeat(); + start(); } else { - stopHeartbeat(); + stop(); } }); return () => { unsubscribe(); - stopHeartbeat(); + stop(); }; - }, [client, sendHeartbeat]); + }, [client, tracker]); } diff --git a/packages/app/src/hooks/use-hover-safe-zone.test.tsx b/packages/app/src/hooks/use-hover-safe-zone.test.tsx deleted file mode 100644 index 3c4d31c9d..000000000 --- a/packages/app/src/hooks/use-hover-safe-zone.test.tsx +++ /dev/null @@ -1,157 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import React, { useCallback, useRef, type RefObject } from "react"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import type { View } from "react-native"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { useHoverSafeZone } from "./use-hover-safe-zone"; - -vi.mock("@/constants/platform", () => ({ - isWeb: true, -})); - -interface RectInput { - left: number; - right: number; - top: number; - bottom: number; -} - -let root: Root | null = null; -let container: HTMLElement | null = null; - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); -}); - -afterEach(() => { - if (root) { - act(() => { - root?.unmount(); - }); - } - root = null; - container?.remove(); - container = null; - vi.unstubAllGlobals(); - vi.restoreAllMocks(); -}); - -function installRect(node: HTMLDivElement | null, rect: RectInput): void { - if (!node) return; - node.getBoundingClientRect = () => - ({ - ...rect, - x: rect.left, - y: rect.top, - width: rect.right - rect.left, - height: rect.bottom - rect.top, - toJSON: () => rect, - }) as DOMRect; -} - -function Harness({ - onEnterSafeZone, - onLeaveSafeZone, -}: { - onEnterSafeZone: () => void; - onLeaveSafeZone: () => void; -}) { - const triggerRef = useRef(null); - const contentRef = useRef(null); - - useHoverSafeZone({ - enabled: true, - triggerRef: triggerRef as unknown as RefObject, - contentRef: contentRef as unknown as RefObject, - onEnterSafeZone, - onLeaveSafeZone, - }); - - const handleTriggerRef = useCallback((node: HTMLDivElement | null) => { - triggerRef.current = node; - installRect(node, { left: 0, right: 100, top: 20, bottom: 60 }); - }, []); - - const handleContentRef = useCallback((node: HTMLDivElement | null) => { - contentRef.current = node; - installRect(node, { left: 120, right: 240, top: 20, bottom: 120 }); - }, []); - - return ( - <> -
-
- - ); -} - -function pointerMove(x: number, y: number): void { - document.dispatchEvent( - new window.MouseEvent("pointermove", { - bubbles: true, - clientX: x, - clientY: y, - }), - ); -} - -describe("useHoverSafeZone", () => { - it("tracks transitions across trigger, bridge, content, and outside space", () => { - const onEnterSafeZone = vi.fn(); - const onLeaveSafeZone = vi.fn(); - - act(() => { - root?.render(); - }); - - act(() => pointerMove(110, 40)); - expect(onEnterSafeZone).toHaveBeenCalledTimes(1); - expect(onLeaveSafeZone).not.toHaveBeenCalled(); - - act(() => pointerMove(300, 40)); - expect(onLeaveSafeZone).toHaveBeenCalledTimes(1); - - act(() => pointerMove(130, 40)); - expect(onEnterSafeZone).toHaveBeenCalledTimes(2); - }); - - it("refreshes the safe-zone enter callback while moving inside", () => { - const onEnterSafeZone = vi.fn(); - const onLeaveSafeZone = vi.fn(); - - act(() => { - root?.render(); - }); - - act(() => pointerMove(110, 40)); - act(() => pointerMove(130, 40)); - - expect(onEnterSafeZone).toHaveBeenCalledTimes(2); - expect(onLeaveSafeZone).not.toHaveBeenCalled(); - }); - - it("treats leaving the browser window as leaving the safe zone", () => { - const onEnterSafeZone = vi.fn(); - const onLeaveSafeZone = vi.fn(); - - act(() => { - root?.render(); - }); - - act(() => { - window.dispatchEvent(new window.MouseEvent("pointerout", { bubbles: true })); - }); - expect(onLeaveSafeZone).toHaveBeenCalledTimes(1); - - act(() => { - window.dispatchEvent(new window.FocusEvent("blur")); - }); - expect(onLeaveSafeZone).toHaveBeenCalledTimes(1); - }); -}); diff --git a/packages/app/src/hooks/use-hover-safe-zone.ts b/packages/app/src/hooks/use-hover-safe-zone.ts index 4f4d1cb6f..487052251 100644 --- a/packages/app/src/hooks/use-hover-safe-zone.ts +++ b/packages/app/src/hooks/use-hover-safe-zone.ts @@ -1,6 +1,7 @@ import { useEffect, type RefObject } from "react"; import type { View } from "react-native"; import { isWeb } from "@/constants/platform"; +import { createHoverSafeZoneTracker, type RectLike } from "@/hooks/hover-safe-zone-tracker"; interface UseHoverSafeZoneParams { enabled: boolean; @@ -10,18 +11,11 @@ interface UseHoverSafeZoneParams { onLeaveSafeZone: () => void; } -interface RectLike { - left: number; - right: number; - top: number; - bottom: number; +function readRect(ref: RefObject): RectLike | null { + const node = ref.current as unknown as Element | null; + return node ? node.getBoundingClientRect() : null; } -// Tracks the pointer's position relative to a hover card's "safe zone": the -// trigger, the content, and the rectangular bridge between them. The bridge -// lets the pointer cross the visual gap without dropping the hover. Fires -// `onEnterSafeZone` / `onLeaveSafeZone` on transitions only. Web-only; no-op -// on native. export function useHoverSafeZone({ enabled, triggerRef, @@ -32,77 +26,34 @@ export function useHoverSafeZone({ useEffect(() => { if (!isWeb || !enabled) return; - // The pointer opened the card, so we start inside. - let wasInside = true; - - function enterSafeZone() { - if (wasInside) return; - wasInside = true; - onEnterSafeZone(); - } - - function leaveSafeZone() { - if (!wasInside) return; - wasInside = false; - onLeaveSafeZone(); - } + const tracker = createHoverSafeZoneTracker({ + getTriggerRect: () => readRect(triggerRef), + getContentRect: () => readRect(contentRef), + onEnterSafeZone, + onLeaveSafeZone, + }); function handlePointerMove(event: PointerEvent) { - const triggerNode = triggerRef.current as unknown as Element | null; - const contentNode = contentRef.current as unknown as Element | null; - const triggerRect = triggerNode ? triggerNode.getBoundingClientRect() : null; - const contentRect = contentNode ? contentNode.getBoundingClientRect() : null; - - const inside = isInsideSafeZone(triggerRect, contentRect, event.clientX, event.clientY); - if (inside) { - if (wasInside) { - onEnterSafeZone(); - return; - } - enterSafeZone(); - return; - } - leaveSafeZone(); + tracker.pointerMoved(event.clientX, event.clientY); } function handlePointerOut(event: PointerEvent) { if (event.relatedTarget === null) { - leaveSafeZone(); + tracker.pointerLeftWindow(); } } + function handleBlur() { + tracker.windowBlurred(); + } + document.addEventListener("pointermove", handlePointerMove); window.addEventListener("pointerout", handlePointerOut); - window.addEventListener("blur", leaveSafeZone); + window.addEventListener("blur", handleBlur); return () => { document.removeEventListener("pointermove", handlePointerMove); window.removeEventListener("pointerout", handlePointerOut); - window.removeEventListener("blur", leaveSafeZone); + window.removeEventListener("blur", handleBlur); }; }, [enabled, triggerRef, contentRef, onEnterSafeZone, onLeaveSafeZone]); } - -function isInsideRect(rect: RectLike | null, x: number, y: number): boolean { - if (!rect) return false; - return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; -} - -function isInsideSafeZone( - trigger: RectLike | null, - content: RectLike | null, - x: number, - y: number, -): boolean { - if (isInsideRect(trigger, x, y)) return true; - if (isInsideRect(content, x, y)) return true; - if (!trigger || !content) return false; - - // Bridge: the horizontal strip connecting trigger and content, stretched - // vertically to span both. If they overlap horizontally there's no bridge. - const bridgeLeft = Math.min(trigger.right, content.right); - const bridgeRight = Math.max(trigger.left, content.left); - if (bridgeLeft >= bridgeRight) return false; - const bridgeTop = Math.min(trigger.top, content.top); - const bridgeBottom = Math.max(trigger.bottom, content.bottom); - return x >= bridgeLeft && x <= bridgeRight && y >= bridgeTop && y <= bridgeBottom; -} diff --git a/packages/app/src/hooks/use-image-attachment-picker.ts b/packages/app/src/hooks/use-image-attachment-picker.ts index a8b428c1e..0475f3ef8 100644 --- a/packages/app/src/hooks/use-image-attachment-picker.ts +++ b/packages/app/src/hooks/use-image-attachment-picker.ts @@ -1,7 +1,7 @@ import { useCallback, useRef } from "react"; import { Alert } from "react-native"; import * as ImagePicker from "expo-image-picker"; -import { isElectronRuntime } from "@/desktop/host"; +import { getDesktopHost, isElectronRuntime } from "@/desktop/host"; import { normalizePickedImageAssets, openImagePathsWithDesktopDialog, @@ -49,7 +49,7 @@ export function useImageAttachmentPicker(): UseImageAttachmentPickerResult { try { if (isWeb && isElectronRuntime()) { - const selectedPaths = await openImagePathsWithDesktopDialog(); + const selectedPaths = await openImagePathsWithDesktopDialog(getDesktopHost()?.dialog); if (selectedPaths.length === 0) { return null; } diff --git a/packages/app/src/hooks/use-ios-hardware-keyboard-submit.test.tsx b/packages/app/src/hooks/use-ios-hardware-keyboard-submit.test.tsx deleted file mode 100644 index 86c7c64c5..000000000 --- a/packages/app/src/hooks/use-ios-hardware-keyboard-submit.test.tsx +++ /dev/null @@ -1,119 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import { act, renderHook } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { useIosHardwareKeyboardSubmit } from "./use-ios-hardware-keyboard-submit"; -import { - emitHardwareKeyboardSubmitForTest, - resetHardwareKeyboardSubmitForTest, - getHardwareKeyboardSubmitEnabledForTest, - setHardwareKeyboardSubmitEnabled, -} from "@/native/ios-hardware-keyboard-submit"; - -describe("useIosHardwareKeyboardSubmit", () => { - beforeEach(() => { - resetHardwareKeyboardSubmitForTest(); - }); - - it("submits when the focused composer receives a hardware keyboard submit event", () => { - const onSubmit = vi.fn(); - - renderHook(() => - useIosHardwareKeyboardSubmit({ - isEnabled: true, - onSubmit, - }), - ); - - act(() => { - emitHardwareKeyboardSubmitForTest(); - }); - - expect(onSubmit).toHaveBeenCalledTimes(1); - }); - - it("does not submit when the composer is blurred", () => { - const onSubmit = vi.fn(); - - renderHook(() => - useIosHardwareKeyboardSubmit({ - isEnabled: false, - onSubmit, - }), - ); - - act(() => { - emitHardwareKeyboardSubmitForTest(); - }); - - expect(onSubmit).not.toHaveBeenCalled(); - expect(getHardwareKeyboardSubmitEnabledForTest()).toBe(false); - }); - - it("does not submit while the default send action is disabled", () => { - const onSubmit = vi.fn(); - - renderHook(() => - useIosHardwareKeyboardSubmit({ - isEnabled: false, - onSubmit, - }), - ); - - act(() => { - emitHardwareKeyboardSubmitForTest(); - }); - - expect(onSubmit).not.toHaveBeenCalled(); - }); - - it("does not disable native hardware submit when mounted disabled", () => { - setHardwareKeyboardSubmitEnabled(true); - - renderHook(() => - useIosHardwareKeyboardSubmit({ - isEnabled: false, - onSubmit: vi.fn(), - }), - ); - - expect(getHardwareKeyboardSubmitEnabledForTest()).toBe(true); - }); - - it("disables native hardware submit when focus moves away", () => { - const onSubmit = vi.fn(); - const { rerender } = renderHook( - ({ isEnabled }) => - useIosHardwareKeyboardSubmit({ - isEnabled, - onSubmit, - }), - { initialProps: { isEnabled: true } }, - ); - - expect(getHardwareKeyboardSubmitEnabledForTest()).toBe(true); - - rerender({ isEnabled: false }); - - expect(getHardwareKeyboardSubmitEnabledForTest()).toBe(false); - }); - - it("unsubscribes on unmount", () => { - const onSubmit = vi.fn(); - const { unmount } = renderHook(() => - useIosHardwareKeyboardSubmit({ - isEnabled: true, - onSubmit, - }), - ); - - unmount(); - - act(() => { - emitHardwareKeyboardSubmitForTest(); - }); - - expect(onSubmit).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/app/src/hooks/use-ios-hardware-keyboard-submit.ts b/packages/app/src/hooks/use-ios-hardware-keyboard-submit.ts index b11626315..75517aebe 100644 --- a/packages/app/src/hooks/use-ios-hardware-keyboard-submit.ts +++ b/packages/app/src/hooks/use-ios-hardware-keyboard-submit.ts @@ -3,6 +3,10 @@ import { addHardwareKeyboardSubmitListener, setHardwareKeyboardSubmitEnabled, } from "@/native/ios-hardware-keyboard-submit"; +import { + createHardwareKeyboardSubmitController, + type HardwareKeyboardSubmitController, +} from "./hardware-keyboard-submit-controller"; interface UseIosHardwareKeyboardSubmitInput { isEnabled: boolean; @@ -10,25 +14,22 @@ interface UseIosHardwareKeyboardSubmitInput { } export function useIosHardwareKeyboardSubmit(input: UseIosHardwareKeyboardSubmitInput) { - const onSubmitRef = useRef(input.onSubmit); + const controllerRef = useRef(null); + if (!controllerRef.current) { + controllerRef.current = createHardwareKeyboardSubmitController({ + addListener: addHardwareKeyboardSubmitListener, + setEnabled: setHardwareKeyboardSubmitEnabled, + }); + } + const controller = controllerRef.current; - useEffect(() => { - onSubmitRef.current = input.onSubmit; - }, [input.onSubmit]); + controller.setOnSubmit(input.onSubmit); useEffect(() => { if (!input.isEnabled) { return; } - - const subscription = addHardwareKeyboardSubmitListener(() => { - onSubmitRef.current(); - }); - setHardwareKeyboardSubmitEnabled(true); - - return () => { - setHardwareKeyboardSubmitEnabled(false); - subscription.remove(); - }; - }, [input.isEnabled]); + controller.enable(); + return () => controller.disable(); + }, [controller, input.isEnabled]); } diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.ts b/packages/app/src/hooks/use-keyboard-shortcuts.ts index ee697206e..f2bdc894b 100644 --- a/packages/app/src/hooks/use-keyboard-shortcuts.ts +++ b/packages/app/src/hooks/use-keyboard-shortcuts.ts @@ -3,7 +3,7 @@ import { usePathname, useRouter } from "expo-router"; import { getIsElectronRuntime } from "@/constants/layout"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore"; -import { navigateToWorkspace } from "@/hooks/use-workspace-navigation"; +import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher"; import { type ChordState, diff --git a/packages/app/src/hooks/use-load-older-agent-history.test.ts b/packages/app/src/hooks/use-load-older-agent-history.test.ts index 1568c27c0..ca86dacf8 100644 --- a/packages/app/src/hooks/use-load-older-agent-history.test.ts +++ b/packages/app/src/hooks/use-load-older-agent-history.test.ts @@ -1,156 +1,188 @@ -// @vitest-environment jsdom - -import { act, renderHook, waitFor } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import type { ToastApi } from "@/components/toast-host"; -import { useSessionStore } from "@/stores/session-store"; +import { describe, expect, it } from "vitest"; +import type { ToastApi, ToastShowOptions } from "@/components/toast-host"; +import type { AgentTimelineCursorState } from "@/stores/session-store"; import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy"; -import { useLoadOlderAgentHistory } from "./use-load-older-agent-history"; +import { + loadOlderAgentHistory, + type LoadOlderAgentHistoryClient, +} from "./use-load-older-agent-history"; -const serverId = "server-1"; const agentId = "agent-1"; -type FetchAgentTimeline = (agentId: string, request: unknown) => Promise; +interface FakeClient extends LoadOlderAgentHistoryClient { + calls: Array<{ + agentId: string; + request: Parameters[1]; + }>; +} -function createToast(): ToastApi { +function createClient(behavior: () => Promise = async () => undefined): FakeClient { + const calls: FakeClient["calls"] = []; return { - show: vi.fn(), - copied: vi.fn(), - error: vi.fn(), + calls, + fetchAgentTimeline: async (id, request) => { + calls.push({ agentId: id, request }); + await behavior(); + }, }; } -function makeClient( - fetchAgentTimeline: FetchAgentTimeline = vi.fn().mockResolvedValue(undefined), -) { - return { fetchAgentTimeline }; +interface FakeInFlight { + values: boolean[]; + setInFlight(value: boolean): void; + get current(): boolean; } -function initialize(input?: { - cursor?: { epoch: string; startSeq: number; endSeq: number }; - hasOlder?: boolean; - inFlight?: boolean; - fetchAgentTimeline?: FetchAgentTimeline; -}) { - const client = makeClient(input?.fetchAgentTimeline); - useSessionStore.getState().initializeSession(serverId, client as never); - if (input?.cursor) { - useSessionStore.getState().setAgentTimelineCursor(serverId, new Map([[agentId, input.cursor]])); - } - if (input?.hasOlder !== undefined) { - useSessionStore - .getState() - .setAgentTimelineHasOlder(serverId, new Map([[agentId, input.hasOlder]])); - } - if (input?.inFlight !== undefined) { - useSessionStore - .getState() - .setAgentTimelineOlderFetchInFlight(serverId, new Map([[agentId, input.inFlight]])); - } - return client; +function createInFlight(initial = false): FakeInFlight { + const values: boolean[] = [initial]; + return { + values, + setInFlight(value) { + values.push(value); + }, + get current() { + return values[values.length - 1] ?? initial; + }, + }; } -afterEach(() => { - useSessionStore.setState({ sessions: {}, agentLastActivity: new Map() }); - vi.restoreAllMocks(); -}); +interface FakeToast extends ToastApi { + shown: Array<{ message: unknown; options: ToastShowOptions | undefined }>; +} -describe("useLoadOlderAgentHistory", () => { - it("no-ops without a cursor", () => { - const client = initialize({ hasOlder: true }); - const { result } = renderHook(() => useLoadOlderAgentHistory({ serverId, agentId })); +function createToast(): FakeToast { + const shown: FakeToast["shown"] = []; + return { + shown, + show: (message, options) => { + shown.push({ message, options }); + }, + copied: () => {}, + error: () => {}, + }; +} - act(() => { - result.current.loadOlder(); - }); +interface FakeLogger { + warnings: unknown[][]; + warn: (...args: unknown[]) => void; +} - expect(client.fetchAgentTimeline).not.toHaveBeenCalled(); - }); +function createLogger(): FakeLogger { + const warnings: unknown[][] = []; + return { + warnings, + warn: (...args) => { + warnings.push(args); + }, + }; +} - it("no-ops when the daemon says no older history exists", () => { - const client = initialize({ - cursor: { epoch: "epoch-1", startSeq: 10, endSeq: 20 }, - hasOlder: false, - }); - const { result } = renderHook(() => useLoadOlderAgentHistory({ serverId, agentId })); +const someCursor: AgentTimelineCursorState = { epoch: "epoch-1", startSeq: 10, endSeq: 20 }; - act(() => { - result.current.loadOlder(); - }); +describe("loadOlderAgentHistory", () => { + it("no-ops without a cursor", async () => { + const client = createClient(); + const inFlight = createInFlight(); - expect(client.fetchAgentTimeline).not.toHaveBeenCalled(); - }); - - it("dedupes concurrent older-page requests", () => { - const fetchAgentTimeline = vi.fn( - () => - new Promise((resolve) => { - setTimeout(resolve, 20); - }), - ); - const client = initialize({ - cursor: { epoch: "epoch-1", startSeq: 10, endSeq: 20 }, + await loadOlderAgentHistory(agentId, { + client, + cursor: undefined, hasOlder: true, - fetchAgentTimeline, - }); - const { result } = renderHook(() => useLoadOlderAgentHistory({ serverId, agentId })); - - act(() => { - result.current.loadOlder(); - result.current.loadOlder(); + isLoadingOlder: false, + setInFlight: inFlight.setInFlight, }); - expect(client.fetchAgentTimeline).toHaveBeenCalledTimes(1); + expect(client.calls).toEqual([]); + expect(inFlight.values).toEqual([false]); + }); + + it("no-ops when the daemon says no older history exists", async () => { + const client = createClient(); + const inFlight = createInFlight(); + + await loadOlderAgentHistory(agentId, { + client, + cursor: someCursor, + hasOlder: false, + isLoadingOlder: false, + setInFlight: inFlight.setInFlight, + }); + + expect(client.calls).toEqual([]); + expect(inFlight.values).toEqual([false]); + }); + + it("no-ops when a request is already in flight", async () => { + const client = createClient(); + const inFlight = createInFlight(true); + + await loadOlderAgentHistory(agentId, { + client, + cursor: someCursor, + hasOlder: true, + isLoadingOlder: true, + setInFlight: inFlight.setInFlight, + }); + + expect(client.calls).toEqual([]); + expect(inFlight.values).toEqual([true]); }); it("requests the page before the current start cursor and clears in-flight on success", async () => { - const client = initialize({ - cursor: { epoch: "epoch-1", startSeq: 10, endSeq: 20 }, + const client = createClient(); + const inFlight = createInFlight(); + + await loadOlderAgentHistory(agentId, { + client, + cursor: someCursor, hasOlder: true, - }); - const { result } = renderHook(() => useLoadOlderAgentHistory({ serverId, agentId })); - - act(() => { - result.current.loadOlder(); + isLoadingOlder: false, + setInFlight: inFlight.setInFlight, }); - expect(client.fetchAgentTimeline).toHaveBeenCalledWith(agentId, { - direction: "before", - cursor: { epoch: "epoch-1", seq: 10 }, - limit: TIMELINE_FETCH_PAGE_SIZE, - projection: "canonical", - }); - await waitFor(() => { - expect( - useSessionStore.getState().sessions[serverId]?.agentTimelineOlderFetchInFlight.get(agentId), - ).toBe(false); - }); + expect(client.calls).toEqual([ + { + agentId, + request: { + direction: "before", + cursor: { epoch: "epoch-1", seq: 10 }, + limit: TIMELINE_FETCH_PAGE_SIZE, + projection: "canonical", + }, + }, + ]); + expect(inFlight.values).toEqual([false, true, false]); }); - it("shows a panel toast and clears in-flight on failure", async () => { - vi.spyOn(console, "warn").mockImplementation(() => {}); + it("shows a panel toast, warns, and clears in-flight on failure", async () => { const error = new Error("network"); - const client = initialize({ - cursor: { epoch: "epoch-1", startSeq: 10, endSeq: 20 }, - hasOlder: true, - fetchAgentTimeline: vi.fn().mockRejectedValue(error), + const client = createClient(async () => { + throw error; }); + const inFlight = createInFlight(); const toast = createToast(); - const { result } = renderHook(() => useLoadOlderAgentHistory({ serverId, agentId, toast })); + const logger = createLogger(); - act(() => { - result.current.loadOlder(); + await loadOlderAgentHistory(agentId, { + client, + cursor: someCursor, + hasOlder: true, + isLoadingOlder: false, + setInFlight: inFlight.setInFlight, + toast, + logger, }); - expect(client.fetchAgentTimeline).toHaveBeenCalledTimes(1); - await waitFor(() => { - expect(toast.show).toHaveBeenCalledWith("Couldn't load older history", { - durationMs: 2200, - testID: "agent-load-older-history-toast", - }); - }); - expect( - useSessionStore.getState().sessions[serverId]?.agentTimelineOlderFetchInFlight.get(agentId), - ).toBe(false); + expect(client.calls).toHaveLength(1); + expect(toast.shown).toEqual([ + { + message: "Couldn't load older history", + options: { durationMs: 2200, testID: "agent-load-older-history-toast" }, + }, + ]); + expect(logger.warnings).toEqual([ + ["[Timeline] failed to load older agent history", agentId, error], + ]); + expect(inFlight.values).toEqual([false, true, false]); }); }); diff --git a/packages/app/src/hooks/use-load-older-agent-history.ts b/packages/app/src/hooks/use-load-older-agent-history.ts index ba8a3691f..d9260d2ab 100644 --- a/packages/app/src/hooks/use-load-older-agent-history.ts +++ b/packages/app/src/hooks/use-load-older-agent-history.ts @@ -1,8 +1,62 @@ import { useCallback } from "react"; import type { ToastApi } from "@/components/toast-host"; -import { useSessionStore } from "@/stores/session-store"; +import { useSessionStore, type AgentTimelineCursorState } from "@/stores/session-store"; import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy"; +export interface LoadOlderAgentHistoryClient { + fetchAgentTimeline: ( + agentId: string, + request: { + direction: "before"; + cursor: { epoch: string; seq: number }; + limit: number; + projection: "canonical"; + }, + ) => Promise; +} + +export interface LoadOlderAgentHistoryLogger { + warn: (...args: unknown[]) => void; +} + +export interface LoadOlderAgentHistoryDeps { + client: LoadOlderAgentHistoryClient | null; + cursor: AgentTimelineCursorState | undefined; + hasOlder: boolean; + isLoadingOlder: boolean; + setInFlight: (value: boolean) => void; + toast?: ToastApi | null; + logger?: LoadOlderAgentHistoryLogger; +} + +export async function loadOlderAgentHistory( + agentId: string, + deps: LoadOlderAgentHistoryDeps, +): Promise { + const { client, cursor, hasOlder, isLoadingOlder, setInFlight, toast, logger } = deps; + if (!client || !cursor || !hasOlder || isLoadingOlder) { + return; + } + + setInFlight(true); + try { + await client.fetchAgentTimeline(agentId, { + direction: "before", + cursor: { epoch: cursor.epoch, seq: cursor.startSeq }, + limit: TIMELINE_FETCH_PAGE_SIZE, + projection: "canonical", + }); + } catch (error) { + (logger ?? console).warn("[Timeline] failed to load older agent history", agentId, error); + toast?.show("Couldn't load older history", { + durationMs: 2200, + testID: "agent-load-older-history-toast", + }); + } finally { + setInFlight(false); + } +} + export function useLoadOlderAgentHistory({ serverId, agentId, @@ -12,10 +66,6 @@ export function useLoadOlderAgentHistory({ agentId: string; toast?: ToastApi | null; }) { - const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); - const cursor = useSessionStore((state) => - state.sessions[serverId]?.agentTimelineCursor.get(agentId), - ); const hasOlder = useSessionStore((state) => state.sessions[serverId]?.agentTimelineHasOlder.get(agentId)) === true; @@ -42,31 +92,16 @@ export function useLoadOlderAgentHistory({ ); const loadOlder = useCallback(() => { - const latestSession = useSessionStore.getState().sessions[serverId]; - const latestIsLoading = latestSession?.agentTimelineOlderFetchInFlight.get(agentId) === true; - if (!client || !cursor || !hasOlder || isLoadingOlder || latestIsLoading) { - return; - } - - setInFlight(true); - void client - .fetchAgentTimeline(agentId, { - direction: "before", - cursor: { epoch: cursor.epoch, seq: cursor.startSeq }, - limit: TIMELINE_FETCH_PAGE_SIZE, - projection: "canonical", - }) - .catch((error) => { - console.warn("[Timeline] failed to load older agent history", agentId, error); - toast?.show("Couldn't load older history", { - durationMs: 2200, - testID: "agent-load-older-history-toast", - }); - }) - .finally(() => { - setInFlight(false); - }); - }, [agentId, client, cursor, hasOlder, isLoadingOlder, serverId, setInFlight, toast]); + const session = useSessionStore.getState().sessions[serverId]; + void loadOlderAgentHistory(agentId, { + client: (session?.client ?? null) as LoadOlderAgentHistoryClient | null, + cursor: session?.agentTimelineCursor.get(agentId), + hasOlder: session?.agentTimelineHasOlder.get(agentId) === true, + isLoadingOlder: session?.agentTimelineOlderFetchInFlight.get(agentId) === true, + setInFlight, + toast, + }); + }, [agentId, serverId, setInFlight, toast]); return { isLoadingOlder, diff --git a/packages/app/src/hooks/use-open-project.test.ts b/packages/app/src/hooks/use-open-project.test.ts index 41073b136..b291dad7a 100644 --- a/packages/app/src/hooks/use-open-project.test.ts +++ b/packages/app/src/hooks/use-open-project.test.ts @@ -1,129 +1,129 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -vi.hoisted(() => { - (globalThis as typeof globalThis & { __DEV__?: boolean }).__DEV__ = false; -}); - -vi.mock("@react-native-async-storage/async-storage", () => { - const storage = new Map(); - return { - default: { - getItem: vi.fn(async (key: string) => storage.get(key) ?? null), - setItem: vi.fn(async (key: string, value: string) => { - storage.set(key, value); - }), - removeItem: vi.fn(async (key: string) => { - storage.delete(key); - }), - }, - }; -}); - -const { replaceRoute } = vi.hoisted(() => ({ - replaceRoute: vi.fn(), -})); - -vi.mock("@/hooks/use-workspace-navigation", () => ({ - navigateToWorkspace: replaceRoute, -})); - -import { openProjectDirectly } from "@/hooks/use-open-project"; -import { useSessionStore } from "@/stores/session-store"; -import { - buildWorkspaceTabPersistenceKey, - collectAllTabs, - useWorkspaceLayoutStore, -} from "@/stores/workspace-layout-store"; -import { generateDraftId } from "@/stores/draft-keys"; +import { describe, expect, it, vi } from "vitest"; +import { openProjectDirectly } from "@/hooks/open-project"; +import type { WorkspaceDescriptor } from "@/stores/session-store"; const SERVER_ID = "server-1"; -const WORKSPACE_ID = "/repo/project"; +const PROJECT_PATH = "/repo/project"; -function createOpenDraftTab() { - return (workspaceKey: string) => - useWorkspaceLayoutStore.getState().openTabFocused(workspaceKey, { - kind: "draft", - draftId: generateDraftId(), - }); +function buildWorkspacePayload() { + return { + id: "1", + projectId: "1", + projectDisplayName: "project", + projectRootPath: PROJECT_PATH, + workspaceDirectory: PROJECT_PATH, + projectKind: "git" as const, + workspaceKind: "checkout" as const, + name: "project", + archivingAt: null, + status: "done" as const, + activityAt: null, + diffStat: null, + scripts: [], + }; +} + +interface RecordedMerge { + serverId: string; + workspaces: WorkspaceDescriptor[]; +} + +interface RecordedHydrated { + serverId: string; + hydrated: boolean; +} + +interface RecordedOpenDraftTab { + workspaceKey: string; +} + +interface RecordedNavigate { + serverId: string; + workspaceId: string; +} + +function createFakeSession() { + const merges: RecordedMerge[] = []; + const hydrated: RecordedHydrated[] = []; + return { + merges, + hydrated, + mergeWorkspaces: (serverId: string, workspaces: Iterable) => { + merges.push({ serverId, workspaces: Array.from(workspaces) }); + }, + setHasHydratedWorkspaces: (serverId: string, value: boolean) => { + hydrated.push({ serverId, hydrated: value }); + }, + }; +} + +function createFakeWorkspaceLayout() { + const openedTabs: RecordedOpenDraftTab[] = []; + return { + openedTabs, + openDraftTab: (workspaceKey: string) => { + openedTabs.push({ workspaceKey }); + return "tab-1"; + }, + }; +} + +function createFakeNavigator() { + const navigations: RecordedNavigate[] = []; + return { + navigations, + navigateToWorkspace: (serverId: string, workspaceId: string) => { + navigations.push({ serverId, workspaceId }); + }, + }; } describe("openProjectDirectly", () => { - beforeEach(() => { - replaceRoute.mockReset(); - useSessionStore.setState({ - sessions: {}, - }); - useSessionStore.getState().initializeSession(SERVER_ID, {} as never); - useWorkspaceLayoutStore.setState({ - layoutByWorkspace: {}, - splitSizesByWorkspace: {}, - pinnedAgentIdsByWorkspace: {}, - }); - vi.restoreAllMocks(); - }); + it("opens the workspace, marks workspaces hydrated, and seeds a draft tab", async () => { + const session = createFakeSession(); + const layout = createFakeWorkspaceLayout(); + const navigator = createFakeNavigator(); + const workspacePayload = buildWorkspacePayload(); - it("opens the workspace directly, marks workspaces hydrated, and seeds a draft tab", async () => { const result = await openProjectDirectly({ serverId: SERVER_ID, - projectPath: WORKSPACE_ID, + projectPath: PROJECT_PATH, isConnected: true, client: { openProject: vi.fn(async () => ({ requestId: "request-1", error: null, - workspace: { - id: "1", - projectId: "1", - projectDisplayName: "project", - projectRootPath: WORKSPACE_ID, - workspaceDirectory: WORKSPACE_ID, - projectKind: "git" as const, - workspaceKind: "checkout" as const, - name: "project", - archivingAt: null, - status: "done" as const, - activityAt: null, - diffStat: null, - scripts: [], - }, + workspace: workspacePayload, })), }, - mergeWorkspaces: useSessionStore.getState().mergeWorkspaces, - setHasHydratedWorkspaces: useSessionStore.getState().setHasHydratedWorkspaces, - openDraftTab: createOpenDraftTab(), - navigateToWorkspace: replaceRoute, + mergeWorkspaces: session.mergeWorkspaces, + setHasHydratedWorkspaces: session.setHasHydratedWorkspaces, + openDraftTab: layout.openDraftTab, + navigateToWorkspace: navigator.navigateToWorkspace, }); expect(result).toBe(true); - expect(useSessionStore.getState().sessions[SERVER_ID]?.hasHydratedWorkspaces).toBe(true); - expect( - Array.from(useSessionStore.getState().sessions[SERVER_ID]?.workspaces.values() ?? []), - ).toEqual([ - expect.objectContaining({ - id: "1", - projectId: "1", - projectRootPath: WORKSPACE_ID, - workspaceDirectory: WORKSPACE_ID, - }), - ]); - - const workspaceKey = buildWorkspaceTabPersistenceKey({ - serverId: SERVER_ID, - workspaceId: "1", + expect(session.merges).toHaveLength(1); + expect(session.merges[0]?.serverId).toBe(SERVER_ID); + expect(session.merges[0]?.workspaces[0]).toMatchObject({ + id: "1", + projectId: "1", + projectRootPath: PROJECT_PATH, + workspaceDirectory: PROJECT_PATH, }); - expect(workspaceKey).toBeTruthy(); - const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey as string]; - expect(layout.root.kind).toBe("pane"); - const tabs = collectAllTabs(layout.root); - expect(tabs).toHaveLength(1); - expect(tabs[0]?.target.kind).toBe("draft"); - expect(replaceRoute).toHaveBeenCalledWith("server-1", "1"); + expect(session.hydrated).toEqual([{ serverId: SERVER_ID, hydrated: true }]); + expect(layout.openedTabs).toEqual([{ workspaceKey: `${SERVER_ID}:1` }]); + expect(navigator.navigations).toEqual([{ serverId: SERVER_ID, workspaceId: "1" }]); }); it("does not navigate or seed tabs when openProject fails", async () => { + const session = createFakeSession(); + const layout = createFakeWorkspaceLayout(); + const navigator = createFakeNavigator(); + const result = await openProjectDirectly({ serverId: SERVER_ID, - projectPath: WORKSPACE_ID, + projectPath: PROJECT_PATH, isConnected: true, client: { openProject: vi.fn(async () => ({ @@ -132,16 +132,16 @@ describe("openProjectDirectly", () => { workspace: null, })), }, - mergeWorkspaces: useSessionStore.getState().mergeWorkspaces, - setHasHydratedWorkspaces: useSessionStore.getState().setHasHydratedWorkspaces, - openDraftTab: createOpenDraftTab(), - navigateToWorkspace: replaceRoute, + mergeWorkspaces: session.mergeWorkspaces, + setHasHydratedWorkspaces: session.setHasHydratedWorkspaces, + openDraftTab: layout.openDraftTab, + navigateToWorkspace: navigator.navigateToWorkspace, }); expect(result).toBe(false); - expect(useSessionStore.getState().sessions[SERVER_ID]?.hasHydratedWorkspaces).toBe(false); - expect(useSessionStore.getState().sessions[SERVER_ID]?.workspaces.size).toBe(0); - expect(useWorkspaceLayoutStore.getState().layoutByWorkspace).toEqual({}); - expect(replaceRoute).not.toHaveBeenCalled(); + expect(session.merges).toEqual([]); + expect(session.hydrated).toEqual([]); + expect(layout.openedTabs).toEqual([]); + expect(navigator.navigations).toEqual([]); }); }); diff --git a/packages/app/src/hooks/use-open-project.ts b/packages/app/src/hooks/use-open-project.ts index 2fc7bb949..cf09064c9 100644 --- a/packages/app/src/hooks/use-open-project.ts +++ b/packages/app/src/hooks/use-open-project.ts @@ -1,57 +1,10 @@ import { useCallback } from "react"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; -import { - normalizeWorkspaceDescriptor, - type WorkspaceDescriptor, - useSessionStore, -} from "@/stores/session-store"; -import { - buildWorkspaceTabPersistenceKey, - useWorkspaceLayoutStore, -} from "@/stores/workspace-layout-store"; +import { useSessionStore } from "@/stores/session-store"; +import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { generateDraftId } from "@/stores/draft-keys"; -import { navigateToWorkspace } from "@/hooks/use-workspace-navigation"; - -interface OpenProjectDirectlyInput { - serverId: string; - projectPath: string; - isConnected: boolean; - client: Pick | null; - mergeWorkspaces: (serverId: string, workspaces: Iterable) => void; - setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void; - openDraftTab: (workspaceKey: string) => string | null; - navigateToWorkspace: (serverId: string, workspaceId: string) => void; -} - -export async function openProjectDirectly(input: OpenProjectDirectlyInput): Promise { - const normalizedServerId = input.serverId.trim(); - const trimmedPath = input.projectPath.trim(); - if (!normalizedServerId || !trimmedPath || !input.client || !input.isConnected) { - return false; - } - - const payload = await input.client.openProject(trimmedPath); - if (payload.error || !payload.workspace) { - return false; - } - - const workspace = normalizeWorkspaceDescriptor(payload.workspace); - input.mergeWorkspaces(normalizedServerId, [workspace]); - input.setHasHydratedWorkspaces(normalizedServerId, true); - - const workspaceKey = buildWorkspaceTabPersistenceKey({ - serverId: normalizedServerId, - workspaceId: workspace.id, - }); - if (!workspaceKey) { - return false; - } - - input.openDraftTab(workspaceKey); - input.navigateToWorkspace(normalizedServerId, workspace.id); - return true; -} +import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; +import { openProjectDirectly } from "@/hooks/open-project"; export function useOpenProject(serverId: string | null): (path: string) => Promise { const normalizedServerId = serverId?.trim() ?? ""; diff --git a/packages/app/src/hooks/use-pr-pane-data.test.ts b/packages/app/src/hooks/use-pr-pane-data.test.ts index a19d5b9e5..7070b4fcb 100644 --- a/packages/app/src/hooks/use-pr-pane-data.test.ts +++ b/packages/app/src/hooks/use-pr-pane-data.test.ts @@ -1,58 +1,22 @@ -import React, { act } from "react"; -import { createRoot } from "react-dom/client"; -import { JSDOM } from "jsdom"; -import { - QueryClient, - QueryClientProvider, - focusManager, - onlineManager, -} from "@tanstack/react-query"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import type { CheckoutPrStatusResponse, PullRequestTimelineResponse, } from "@getpaseo/protocol/messages"; -import { checkoutPrStatusQueryKey, prPaneTimelineQueryKey } from "@/git/query-keys"; -import { usePrPaneData, type UsePrPaneDataResult } from "./use-pr-pane-data"; -import { useWorkspacePrHint } from "@/git/use-pr-status-query"; +import { + createInMemoryUnsupportedTimelineRegistry, + extractPrRepoIdentity, + fetchPrPaneTimelinePage, + isUnsupportedTimelineError, + type PrPaneTimelineClient, + selectPrPaneState, + shouldFetchTimelineFrom, + unsupportedTimelineKey, +} from "./use-pr-pane-data"; type CheckoutPrStatus = NonNullable; -type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"]; type PullRequestTimelinePayload = PullRequestTimelineResponse["payload"]; - -const { mockRuntime, mockClient, checkoutStatusUpdateHandlers } = vi.hoisted(() => { - const hoistedHandlers = new Set<(message: unknown) => void>(); - const hoistedClient = { - checkoutPrStatus: vi.fn(), - pullRequestTimeline: vi.fn(), - on: vi.fn((type: string, handler: (message: unknown) => void) => { - if (type !== "checkout_status_update") { - return () => {}; - } - hoistedHandlers.add(handler); - return () => { - hoistedHandlers.delete(handler); - }; - }), - }; - - return { - mockClient: hoistedClient, - checkoutStatusUpdateHandlers: hoistedHandlers, - mockRuntime: { - client: hoistedClient, - isConnected: true, - }, - }; -}); - -vi.mock("@/runtime/host-runtime", () => ({ - useHostRuntimeClient: () => mockRuntime.client, - useHostRuntimeIsConnected: () => mockRuntime.isConnected, -})); - -const cwd = "/repo"; -const serverId = "server-1"; +type PullRequestTimelineInput = Parameters[0]; const githubStatus: CheckoutPrStatus["github"] = { mergeStateStatus: null, @@ -72,7 +36,7 @@ const githubStatus: CheckoutPrStatus["github"] = { isInMergeQueue: false, }; -function status(overrides: Partial = {}): CheckoutPrStatus { +function prStatus(overrides: Partial = {}): CheckoutPrStatus { return { number: 42, url: "https://github.com/getpaseo/paseo/pull/42", @@ -92,22 +56,11 @@ function status(overrides: Partial = {}): CheckoutPrStatus { }; } -function statusPayload(overrides: Partial = {}): CheckoutPrStatusPayload { - return { - cwd, - status: status(), - githubFeaturesEnabled: true, - error: null, - requestId: "status-1", - ...overrides, - }; -} - function timelinePayload( overrides: Partial = {}, ): PullRequestTimelinePayload { return { - cwd, + cwd: "/repo", prNumber: 42, items: [], truncated: false, @@ -118,37 +71,26 @@ function timelinePayload( }; } -async function emitCheckoutStatusUpdatePrStatus(payload: CheckoutPrStatusPayload): Promise { - await act(async () => { - for (const handler of checkoutStatusUpdateHandlers) { - handler({ - type: "checkout_status_update", - payload: { - cwd: payload.cwd, - requestId: `subscription:${payload.cwd}`, - isGit: true, - isPaseoOwnedWorktree: false, - repoRoot: payload.cwd, - currentBranch: "main", - isDirty: false, - baseRef: "main", - aheadBehind: { ahead: 0, behind: 0 }, - aheadOfOrigin: 0, - behindOfOrigin: 0, - hasRemote: true, - remoteUrl: "https://github.com/getpaseo/paseo.git", - error: null, - prStatus: payload, - }, - }); - } - await Promise.resolve(); - }); +interface FakeTimelineClient extends PrPaneTimelineClient { + calls: PullRequestTimelineInput[]; +} + +function createTimelineClient( + respond: (input: PullRequestTimelineInput) => Promise, +): FakeTimelineClient { + const calls: PullRequestTimelineInput[] = []; + return { + calls, + pullRequestTimeline: async (input) => { + calls.push(input); + return respond(input); + }, + }; } function unsupportedTimelineError(): Error { const error = new Error( - "Unknown request, try upgrading the daemon (currently v0.1.78) requestType=pull_request_timeline_request code=unknown_schema", + "Unknown request, try upgrading the daemon requestType=pull_request_timeline_request code=unknown_schema", ) as Error & { code: string; requestType: string }; error.name = "DaemonRpcError"; error.code = "unknown_schema"; @@ -156,644 +98,415 @@ function unsupportedTimelineError(): Error { return error; } -function createTestQueryClient(): QueryClient { - return new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, +const baseSelectInput = { + status: null as CheckoutPrStatus | null, + statusPayloadError: null, + statusError: null as Error | null, + statusIsLoading: false, + statusIsFetching: false, + githubFeaturesEnabled: true, + timelineEnabled: true, + shouldFetchTimeline: true, + timelinePayload: undefined as PullRequestTimelinePayload | undefined, + timelineError: null as Error | null, + timelineIsLoading: false, + timelineIsFetching: false, +}; + +describe("extractPrRepoIdentity", () => { + it("reads the PR number, owner, and name from a status payload", () => { + expect(extractPrRepoIdentity(prStatus())).toEqual({ + prNumber: 42, + repoOwner: "getpaseo", + repoName: "paseo", + }); }); -} -function renderPrPaneHook({ - queryClient = createTestQueryClient(), - options = { serverId, cwd }, -}: { - queryClient?: QueryClient; - options?: Parameters[0]; -} = {}) { - let latest: UsePrPaneDataResult | null = null; - let currentOptions = options; + it("returns null fields when the status is missing", () => { + expect(extractPrRepoIdentity(null)).toEqual({ + prNumber: null, + repoOwner: null, + repoName: null, + }); + }); - function Probe({ hookOptions }: { hookOptions: Parameters[0] }) { - latest = usePrPaneData(hookOptions); - return null; - } + it("treats empty owner or name strings as missing", () => { + expect(extractPrRepoIdentity(prStatus({ repoOwner: "", repoName: "" }))).toMatchObject({ + repoOwner: null, + repoName: null, + }); + }); - const container = document.getElementById("root"); - if (!container) { - throw new Error("Missing root container"); - } + it("reports no PR number when the status omits it", () => { + const status = prStatus(); + delete status.number; + expect(extractPrRepoIdentity(status).prNumber).toBeNull(); + }); +}); - const root = createRoot(container); +describe("shouldFetchTimelineFrom", () => { + const baseGate = { + hasClient: true, + isConnected: true, + timelineEnabled: true, + githubFeaturesEnabled: true, + cwd: "/repo", + identity: { prNumber: 42, repoOwner: "getpaseo", repoName: "paseo" }, + timelineUnsupported: false, + }; - function render() { - root.render( - React.createElement( - QueryClientProvider, - { client: queryClient }, - React.createElement(Probe, { hookOptions: currentOptions }), - ), + it("fetches when every gate is open", () => { + expect(shouldFetchTimelineFrom(baseGate)).toBe(true); + }); + + it.each([ + { name: "no client", overrides: { hasClient: false } }, + { name: "disconnected", overrides: { isConnected: false } }, + { name: "timeline disabled", overrides: { timelineEnabled: false } }, + { name: "github features off", overrides: { githubFeaturesEnabled: false } }, + { name: "empty cwd", overrides: { cwd: "" } }, + { + name: "missing PR number", + overrides: { identity: { ...baseGate.identity, prNumber: null } }, + }, + { name: "missing owner", overrides: { identity: { ...baseGate.identity, repoOwner: null } } }, + { name: "missing name", overrides: { identity: { ...baseGate.identity, repoName: null } } }, + { name: "tuple marked unsupported", overrides: { timelineUnsupported: true } }, + ])("skips fetching when $name", ({ overrides }) => { + expect(shouldFetchTimelineFrom({ ...baseGate, ...overrides })).toBe(false); + }); +}); + +describe("createInMemoryUnsupportedTimelineRegistry", () => { + it("remembers added keys and answers has() against them", () => { + const registry = createInMemoryUnsupportedTimelineRegistry(); + const key = unsupportedTimelineKey({ serverId: "host", cwd: "/repo", prNumber: 99 }); + + expect(registry.has(key)).toBe(false); + registry.add(key); + expect(registry.has(key)).toBe(true); + }); + + it("uses serverId, cwd, and prNumber to scope each key", () => { + expect(unsupportedTimelineKey({ serverId: "host-a", cwd: "/repo", prNumber: 1 })).not.toEqual( + unsupportedTimelineKey({ serverId: "host-b", cwd: "/repo", prNumber: 1 }), ); - } + expect(unsupportedTimelineKey({ serverId: "host", cwd: "/repo-a", prNumber: 1 })).not.toEqual( + unsupportedTimelineKey({ serverId: "host", cwd: "/repo-b", prNumber: 1 }), + ); + expect(unsupportedTimelineKey({ serverId: "host", cwd: "/repo", prNumber: 1 })).not.toEqual( + unsupportedTimelineKey({ serverId: "host", cwd: "/repo", prNumber: 2 }), + ); + }); +}); - return { - get latest() { - if (!latest) { - throw new Error("Expected hook result"); - } - return latest; - }, - queryClient, - async mount() { - await act(async () => { - render(); - }); - }, - async rerender(nextOptions: Parameters[0]) { - currentOptions = nextOptions; - await act(async () => { - render(); - }); - }, - async unmount() { - await act(async () => { - root.unmount(); - }); - }, - }; -} +describe("fetchPrPaneTimelinePage", () => { + it("forwards the cwd, PR number, owner, and name to the daemon client", async () => { + const client = createTimelineClient(async () => timelinePayload()); + const registry = createInMemoryUnsupportedTimelineRegistry(); -function renderSharedStatusConsumers({ queryClient = createTestQueryClient() } = {}) { - let latest: UsePrPaneDataResult | null = null; - - function Probe() { - latest = usePrPaneData({ serverId, cwd }); - useWorkspacePrHint({ serverId, cwd }); - return null; - } - - const container = document.getElementById("root"); - if (!container) { - throw new Error("Missing root container"); - } - - const root = createRoot(container); - - return { - get latest() { - if (!latest) { - throw new Error("Expected hook result"); - } - return latest; - }, - async mount() { - await act(async () => { - root.render( - React.createElement( - QueryClientProvider, - { client: queryClient }, - React.createElement(Probe), - ), - ); - }); - }, - }; -} - -async function waitForExpectation(assertion: () => void): Promise { - let lastError: unknown; - - for (let attempt = 0; attempt < 60; attempt += 1) { - try { - assertion(); - return; - } catch (error) { - lastError = error; - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 10)); - }); - } - } - - throw lastError; -} - -describe("usePrPaneData", () => { - beforeEach(() => { - const dom = new JSDOM("
", { - url: "http://localhost", + await fetchPrPaneTimelinePage({ + client, + registry, + serverId: "host", + cwd: "/repo", + prNumber: 42, + repoOwner: "getpaseo", + repoName: "paseo", }); - Object.defineProperty(globalThis, "document", { - value: dom.window.document, - configurable: true, - }); - Object.defineProperty(globalThis, "window", { - value: dom.window, - configurable: true, - }); - Object.defineProperty(globalThis, "navigator", { - value: dom.window.navigator, - configurable: true, - }); - Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { - value: true, - configurable: true, - }); - - mockRuntime.client = mockClient; - mockRuntime.isConnected = true; - mockClient.checkoutPrStatus.mockReset(); - mockClient.pullRequestTimeline.mockReset(); - mockClient.on.mockClear(); - checkoutStatusUpdateHandlers.clear(); + expect(client.calls).toEqual([ + { cwd: "/repo", prNumber: 42, repoOwner: "getpaseo", repoName: "paseo" }, + ]); }); - afterEach(() => { - focusManager.setFocused(undefined); - onlineManager.setOnline(true); - vi.useRealTimers(); - }); + it("returns the daemon's timeline payload on success", async () => { + const payload = timelinePayload({ + items: [ + { + id: "comment-1", + kind: "comment", + author: "octocat", + body: "Looks good", + createdAt: Date.now(), + url: "https://github.com/getpaseo/paseo/pull/42#c1", + }, + ], + }); + const client = createTimelineClient(async () => payload); + const registry = createInMemoryUnsupportedTimelineRegistry(); - it("returns null when status has no PR number", async () => { - const statusWithoutNumber = status(); - delete statusWithoutNumber.number; - mockClient.checkoutPrStatus.mockResolvedValue(statusPayload({ status: statusWithoutNumber })); - mockClient.pullRequestTimeline.mockResolvedValue(timelinePayload()); - - const hook = renderPrPaneHook(); - await hook.mount(); - - await waitForExpectation(() => { - expect(hook.latest.isLoading).toBe(false); + const result = await fetchPrPaneTimelinePage({ + client, + registry, + serverId: "host", + cwd: "/repo", + prNumber: 42, + repoOwner: "getpaseo", + repoName: "paseo", }); - expect(hook.latest.data).toBeNull(); - expect(mockClient.pullRequestTimeline).not.toHaveBeenCalled(); + expect(result).toBe(payload); }); - it("wires the timeline query only when a PR number is known", async () => { - mockClient.checkoutPrStatus.mockResolvedValue(statusPayload({ status: null })); - mockClient.pullRequestTimeline.mockResolvedValue(timelinePayload()); - - const hook = renderPrPaneHook(); - await hook.mount(); - - await waitForExpectation(() => { - expect(hook.latest.isLoading).toBe(false); + it("records the tuple in the registry when the daemon rejects the request as unsupported", async () => { + const error = unsupportedTimelineError(); + const client = createTimelineClient(async () => { + throw error; }); + const registry = createInMemoryUnsupportedTimelineRegistry(); - expect(mockClient.pullRequestTimeline).not.toHaveBeenCalled(); - expect( - hook.queryClient.getQueryState(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 42 })), - ).toBeUndefined(); - - mockClient.checkoutPrStatus.mockResolvedValue(statusPayload()); - hook.queryClient.invalidateQueries({ queryKey: checkoutPrStatusQueryKey(serverId, cwd) }); - - await waitForExpectation(() => { - expect(mockClient.pullRequestTimeline).toHaveBeenCalledWith({ - cwd, - prNumber: 42, + await expect( + fetchPrPaneTimelinePage({ + client, + registry, + serverId: "host", + cwd: "/repo", + prNumber: 99, repoOwner: "getpaseo", repoName: "paseo", - }); - }); - }); - - it("checks PR status while timeline activity is disabled", async () => { - mockClient.checkoutPrStatus.mockResolvedValue(statusPayload()); - mockClient.pullRequestTimeline.mockResolvedValue(timelinePayload()); - - const hook = renderPrPaneHook({ - options: { serverId, cwd, enabled: true, timelineEnabled: false }, - }); - await hook.mount(); - - await waitForExpectation(() => { - expect(hook.latest.prNumber).toBe(42); - }); - - expect(hook.latest.data).toBeNull(); - expect(mockClient.checkoutPrStatus).toHaveBeenCalledTimes(1); - expect(mockClient.pullRequestTimeline).not.toHaveBeenCalled(); - }); - - it("does not request timeline activity until the PR repo identity is known", async () => { - mockClient.checkoutPrStatus.mockResolvedValue( - statusPayload({ - status: status({ - repoOwner: undefined, - repoName: undefined, - }), }), - ); - mockClient.pullRequestTimeline.mockResolvedValue(timelinePayload()); + ).rejects.toBe(error); - const hook = renderPrPaneHook(); - await hook.mount(); - - await waitForExpectation(() => { - expect(hook.latest.isLoading).toBe(false); - }); - - expect(hook.latest.data?.number).toBe(42); - expect(hook.latest.data?.activity).toEqual([]); - expect(mockClient.pullRequestTimeline).not.toHaveBeenCalled(); - }); - - it("shares the checkout PR status query with workspace hint consumers", async () => { - mockClient.checkoutPrStatus.mockResolvedValue(statusPayload()); - mockClient.pullRequestTimeline.mockResolvedValue(timelinePayload()); - - const hook = renderSharedStatusConsumers(); - await hook.mount(); - - await waitForExpectation(() => { - expect(hook.latest.data?.number).toBe(42); - }); - - expect(mockClient.checkoutPrStatus).toHaveBeenCalledTimes(1); - }); - - it("server-pushed PR status update writes into the checkout PR status cache", async () => { - const queryClient = createTestQueryClient(); - mockClient.checkoutPrStatus.mockResolvedValue( - statusPayload({ - status: status({ - checksStatus: "pending", - checks: [{ name: "test", status: "pending", url: "https://github.com/checks/1" }], - }), - }), - ); - mockClient.pullRequestTimeline.mockResolvedValue(timelinePayload()); - - const hook = renderPrPaneHook({ queryClient }); - await hook.mount(); - await waitForExpectation(() => { - expect(hook.latest.data?.checks[0]?.status).toBe("pending"); - }); - - mockClient.checkoutPrStatus.mockClear(); - await emitCheckoutStatusUpdatePrStatus( - statusPayload({ - requestId: "server-push", - status: status({ - checksStatus: "success", - checks: [{ name: "test", status: "success", url: "https://github.com/checks/1" }], - }), - }), - ); - - await waitForExpectation(() => { - expect(hook.latest.data?.checks[0]?.status).toBe("success"); - }); - expect(queryClient.getQueryData(checkoutPrStatusQueryKey(serverId, cwd))).toEqual( - statusPayload({ - requestId: "server-push", - status: status({ - checksStatus: "success", - checks: [{ name: "test", status: "success", url: "https://github.com/checks/1" }], - }), - }), - ); - expect(mockClient.checkoutPrStatus).not.toHaveBeenCalled(); - }); - - it("server-pushed PR status for a different cwd does not overwrite the current cache", async () => { - const queryClient = createTestQueryClient(); - const initial = statusPayload({ - status: status({ - checksStatus: "pending", - checks: [{ name: "test", status: "pending", url: "https://github.com/checks/1" }], - }), - }); - mockClient.checkoutPrStatus.mockResolvedValue(initial); - mockClient.pullRequestTimeline.mockResolvedValue(timelinePayload()); - - const hook = renderPrPaneHook({ queryClient }); - await hook.mount(); - await waitForExpectation(() => { - expect(hook.latest.data?.checks[0]?.status).toBe("pending"); - }); - - await emitCheckoutStatusUpdatePrStatus( - statusPayload({ - cwd: "/other-repo", - requestId: "other-server-push", - status: status({ - checksStatus: "success", - checks: [{ name: "test", status: "success", url: "https://github.com/checks/1" }], - }), - }), - ); - - expect(queryClient.getQueryData(checkoutPrStatusQueryKey(serverId, cwd))).toEqual(initial); expect( - queryClient.getQueryData(checkoutPrStatusQueryKey(serverId, "/other-repo")), - ).toBeUndefined(); + registry.has(unsupportedTimelineKey({ serverId: "host", cwd: "/repo", prNumber: 99 })), + ).toBe(true); }); - it("passes repoOwner and repoName to the timeline request when present", async () => { - mockClient.checkoutPrStatus.mockResolvedValue( - statusPayload({ - status: { - ...status(), - repoOwner: "fork-parent", - repoName: "paseo", - }, - }), - ); - mockClient.pullRequestTimeline.mockResolvedValue(timelinePayload()); - - const hook = renderPrPaneHook(); - await hook.mount(); - - await waitForExpectation(() => { - expect(mockClient.pullRequestTimeline).toHaveBeenCalledWith({ - cwd, - prNumber: 42, - repoOwner: "fork-parent", - repoName: "paseo", - }); + it("leaves the registry alone when the failure is a generic error", async () => { + const error = new Error("network down"); + const client = createTimelineClient(async () => { + throw error; }); + const registry = createInMemoryUnsupportedTimelineRegistry(); + + await expect( + fetchPrPaneTimelinePage({ + client, + registry, + serverId: "host", + cwd: "/repo", + prNumber: 99, + repoOwner: "getpaseo", + repoName: "paseo", + }), + ).rejects.toBe(error); + + expect( + registry.has(unsupportedTimelineKey({ serverId: "host", cwd: "/repo", prNumber: 99 })), + ).toBe(false); }); - it("lets the mapper reject stale timeline activity for a mismatched PR number", async () => { - mockClient.checkoutPrStatus.mockResolvedValue(statusPayload()); - mockClient.pullRequestTimeline.mockResolvedValue( - timelinePayload({ + it("scopes recorded tuples per serverId+cwd+prNumber so other PRs can still be tried", async () => { + const client = createTimelineClient(async (input) => { + if (input.cwd === "/repo-a") { + throw unsupportedTimelineError(); + } + return timelinePayload({ cwd: input.cwd, prNumber: input.prNumber }); + }); + const registry = createInMemoryUnsupportedTimelineRegistry(); + + await expect( + fetchPrPaneTimelinePage({ + client, + registry, + serverId: "host", + cwd: "/repo-a", + prNumber: 1, + repoOwner: "getpaseo", + repoName: "paseo", + }), + ).rejects.toThrow(); + + const result = await fetchPrPaneTimelinePage({ + client, + registry, + serverId: "host", + cwd: "/repo-b", + prNumber: 2, + repoOwner: "getpaseo", + repoName: "paseo", + }); + + expect(result.prNumber).toBe(2); + expect( + registry.has(unsupportedTimelineKey({ serverId: "host", cwd: "/repo-a", prNumber: 1 })), + ).toBe(true); + expect( + registry.has(unsupportedTimelineKey({ serverId: "host", cwd: "/repo-b", prNumber: 2 })), + ).toBe(false); + }); +}); + +describe("isUnsupportedTimelineError", () => { + it("matches the daemon's unknown-schema error for pull_request_timeline_request", () => { + expect(isUnsupportedTimelineError(unsupportedTimelineError())).toBe(true); + }); + + it("rejects unrelated errors", () => { + expect(isUnsupportedTimelineError(new Error("network down"))).toBe(false); + expect(isUnsupportedTimelineError("not an error")).toBe(false); + }); + + it("rejects daemon errors for a different request type", () => { + const error = new Error("nope") as Error & { code: string; requestType: string }; + error.name = "DaemonRpcError"; + error.code = "unknown_schema"; + error.requestType = "something_else_request"; + expect(isUnsupportedTimelineError(error)).toBe(false); + }); +}); + +describe("selectPrPaneState", () => { + it("returns null data and no PR number when the status payload has no PR", () => { + const state = selectPrPaneState({ ...baseSelectInput, status: null }); + + expect(state.data).toBeNull(); + expect(state.prNumber).toBeNull(); + }); + + it("composes data from status and timeline when both are present", () => { + const state = selectPrPaneState({ + ...baseSelectInput, + status: prStatus(), + timelinePayload: timelinePayload(), + }); + + expect(state.data?.number).toBe(42); + expect(state.data?.title).toBe("Wire real PR pane data"); + expect(state.data?.activity).toEqual([]); + }); + + it("returns null data when the consumer disabled timeline rendering", () => { + const state = selectPrPaneState({ + ...baseSelectInput, + status: prStatus(), + timelineEnabled: false, + timelinePayload: timelinePayload(), + }); + + expect(state.data).toBeNull(); + expect(state.prNumber).toBe(42); + }); + + it("drops timeline activity that belongs to a different PR number", () => { + const state = selectPrPaneState({ + ...baseSelectInput, + status: prStatus(), + timelinePayload: timelinePayload({ prNumber: 41, items: [ { id: "comment-1", kind: "comment", author: "octocat", - body: "This belongs to another PR", + body: "Belongs to another PR", createdAt: Date.now(), - url: "https://github.com/getpaseo/paseo/pull/41#issuecomment-1", + url: "https://github.com/getpaseo/paseo/pull/41#c1", }, ], }), - ); - - const hook = renderPrPaneHook(); - await hook.mount(); - - await waitForExpectation(() => { - expect(hook.latest.data?.number).toBe(42); }); - expect(hook.latest.data?.activity).toEqual([]); + expect(state.data?.activity).toEqual([]); }); - it("suppresses old-daemon errors and prevents future timeline requests for that tuple", async () => { - const unsupportedError = unsupportedTimelineError(); - mockClient.checkoutPrStatus.mockResolvedValue( - statusPayload({ status: status({ number: 99 }) }), - ); - mockClient.pullRequestTimeline.mockRejectedValue(unsupportedError); - - const hook = renderPrPaneHook(); - await hook.mount(); - - await waitForExpectation(() => { - expect(mockClient.pullRequestTimeline).toHaveBeenCalledTimes(1); - }); - - await waitForExpectation(() => { - expect(hook.latest.error).toBeNull(); - }); - - await hook.rerender({ serverId, cwd }); - await hook.queryClient.invalidateQueries({ - queryKey: prPaneTimelineQueryKey({ serverId, cwd, prNumber: 99 }), - }); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 20)); - }); - - expect(mockClient.pullRequestTimeline).toHaveBeenCalledTimes(1); - expect(hook.latest.data?.number).toBe(99); + it("reports loading while the status query is loading", () => { + expect(selectPrPaneState({ ...baseSelectInput, statusIsLoading: true }).isLoading).toBe(true); }); - it("surfaces checkout PR status payload errors", async () => { - mockClient.checkoutPrStatus.mockResolvedValue( - statusPayload({ - error: { - code: "UNKNOWN", - message: "bad daemon payload", - }, - }), - ); - mockClient.pullRequestTimeline.mockResolvedValue(timelinePayload()); - - const hook = renderPrPaneHook(); - await hook.mount(); - - await waitForExpectation(() => { - expect(hook.latest.error).not.toBeNull(); - expect(hook.latest.error?.message).toContain("bad daemon payload"); + it("reports loading while the timeline is still pending its first response", () => { + const state = selectPrPaneState({ + ...baseSelectInput, + status: prStatus(), + shouldFetchTimeline: true, + timelineIsLoading: true, }); + expect(state.isLoading).toBe(true); }); - it("retries timeline requests when the unsupported timeline tuple changes", async () => { - const cwdA = "/repo-a"; - const cwdB = "/repo-b"; - const prNumberA = 123; - const prNumberB = 124; - const unsupportedError = unsupportedTimelineError(); - - mockClient.checkoutPrStatus.mockImplementation(async (requestedCwd: string) => - statusPayload({ - cwd: requestedCwd, - status: status({ number: requestedCwd === cwdA ? prNumberA : prNumberB }), - }), - ); - mockClient.pullRequestTimeline.mockImplementation(async (input: { cwd: string }) => { - if (input.cwd === cwdA) { - throw unsupportedError; - } - return timelinePayload({ cwd: input.cwd, prNumber: prNumberB }); + it("does not report loading on the timeline when no fetch was scheduled", () => { + const state = selectPrPaneState({ + ...baseSelectInput, + status: prStatus(), + shouldFetchTimeline: false, + timelineIsLoading: true, }); - - const hook = renderPrPaneHook({ options: { serverId, cwd: cwdA } }); - await hook.mount(); - - await waitForExpectation(() => { - expect(countTimelineCalls({ cwd: cwdA, prNumber: prNumberA })).toBe(1); - }); - - await hook.rerender({ serverId, cwd: cwdA }); - await hook.queryClient.invalidateQueries({ - queryKey: prPaneTimelineQueryKey({ serverId, cwd: cwdA, prNumber: prNumberA }), - }); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 20)); - }); - - expect(countTimelineCalls({ cwd: cwdA, prNumber: prNumberA })).toBe(1); - - await hook.rerender({ serverId, cwd: cwdB }); - - await waitForExpectation(() => { - expect(countTimelineCalls({ cwd: cwdB, prNumber: prNumberB })).toBe(1); - }); - expect(countTimelineCalls({ cwd: cwdA, prNumber: prNumberA })).toBe(1); + expect(state.isLoading).toBe(false); }); - it("disables the timeline query when githubFeaturesEnabled is false", async () => { - mockClient.checkoutPrStatus.mockResolvedValue( - statusPayload({ githubFeaturesEnabled: false, status: status() }), - ); - mockClient.pullRequestTimeline.mockResolvedValue(timelinePayload()); - - const hook = renderPrPaneHook(); - await hook.mount(); - - await waitForExpectation(() => { - expect(hook.latest.data?.number).toBe(42); - }); - - expect(hook.latest.githubFeaturesEnabled).toBe(false); - expect(hook.latest.data?.activity).toEqual([]); - expect(mockClient.pullRequestTimeline).not.toHaveBeenCalled(); + it("reports refreshing during background revalidation of either query", () => { + expect( + selectPrPaneState({ + ...baseSelectInput, + statusIsFetching: true, + }).isRefreshing, + ).toBe(true); + expect( + selectPrPaneState({ + ...baseSelectInput, + timelineIsFetching: true, + }).isRefreshing, + ).toBe(true); }); - it("does not poll PR status or timeline after the initial load", async () => { - vi.useFakeTimers(); - mockClient.checkoutPrStatus.mockResolvedValue(statusPayload()); - mockClient.pullRequestTimeline.mockResolvedValue(timelinePayload()); - - const hook = renderPrPaneHook(); - await hook.mount(); - - await waitForHookResult(() => { - expect(hook.latest.data?.number).toBe(42); + it("surfaces status payload errors with the daemon's message", () => { + const state = selectPrPaneState({ + ...baseSelectInput, + status: prStatus(), + statusPayloadError: { code: "UNKNOWN", message: "bad daemon payload" }, }); - await act(async () => { - vi.advanceTimersByTime(120_000); - await Promise.resolve(); - }); - - expect(mockClient.checkoutPrStatus).toHaveBeenCalledTimes(1); - expect(mockClient.pullRequestTimeline).toHaveBeenCalledTimes(1); + expect(state.error?.message).toContain("bad daemon payload"); }); - it("does not refetch PR data on focus or reconnect", async () => { - mockClient.checkoutPrStatus.mockResolvedValue(statusPayload()); - mockClient.pullRequestTimeline.mockResolvedValue(timelinePayload()); - - const hook = renderPrPaneHook(); - await hook.mount(); - - await waitForExpectation(() => { - expect(hook.latest.data?.number).toBe(42); + it("falls back to a generic message when the status payload error is empty", () => { + const state = selectPrPaneState({ + ...baseSelectInput, + statusPayloadError: { code: "UNKNOWN", message: "" }, }); - await act(async () => { - focusManager.setFocused(false); - focusManager.setFocused(true); - onlineManager.setOnline(false); - onlineManager.setOnline(true); - await Promise.resolve(); - }); - - expect(mockClient.checkoutPrStatus).toHaveBeenCalledTimes(1); - expect(mockClient.pullRequestTimeline).toHaveBeenCalledTimes(1); + expect(state.error?.message).toBe("Unable to load pull request status"); }); - it("reports first-load, background refresh, and non-suppressed errors", async () => { - const statusDeferred = createDeferred(); - mockClient.checkoutPrStatus.mockReturnValue(statusDeferred.promise); - mockClient.pullRequestTimeline.mockResolvedValue( - timelinePayload({ - error: { - kind: "unknown", - message: "rate limited", - }, - }), - ); - - const hook = renderPrPaneHook(); - await hook.mount(); - - expect(hook.latest.isLoading).toBe(true); - expect(hook.latest.isRefreshing).toBe(false); - - await act(async () => { - statusDeferred.resolve(statusPayload()); + it("prefers the status thrown error over the timeline error", () => { + const statusError = new Error("status broke"); + const state = selectPrPaneState({ + ...baseSelectInput, + statusError, + timelineError: new Error("timeline broke"), }); - await waitForExpectation(() => { - expect(hook.latest.data?.number).toBe(42); - expect(hook.latest.error?.message).toBe("rate limited"); + expect(state.error).toBe(statusError); + }); + + it("suppresses unsupported timeline errors", () => { + const state = selectPrPaneState({ + ...baseSelectInput, + status: prStatus(), + timelineError: unsupportedTimelineError(), }); - const refreshDeferred = createDeferred(); - mockClient.checkoutPrStatus.mockReturnValue(refreshDeferred.promise); - hook.queryClient.invalidateQueries({ queryKey: checkoutPrStatusQueryKey(serverId, cwd) }); + expect(state.error).toBeNull(); + }); - await waitForExpectation(() => { - expect(hook.latest.isLoading).toBe(false); - expect(hook.latest.isRefreshing).toBe(true); + it("surfaces timeline payload errors when nothing earlier failed", () => { + const state = selectPrPaneState({ + ...baseSelectInput, + status: prStatus(), + timelinePayload: timelinePayload({ error: { kind: "unknown", message: "rate limited" } }), }); - await act(async () => { - refreshDeferred.resolve(statusPayload()); - }); + expect(state.error?.message).toBe("rate limited"); + }); - await waitForExpectation(() => { - expect(hook.latest.isRefreshing).toBe(false); - }); + it("passes the daemon's githubFeaturesEnabled flag through", () => { + expect( + selectPrPaneState({ + ...baseSelectInput, + status: prStatus(), + githubFeaturesEnabled: false, + }).githubFeaturesEnabled, + ).toBe(false); }); }); - -async function waitForHookResult(assertion: () => void): Promise { - let lastError: unknown; - - for (let attempt = 0; attempt < 60; attempt += 1) { - try { - assertion(); - return; - } catch (error) { - lastError = error; - await act(async () => { - vi.advanceTimersByTime(10); - await Promise.resolve(); - }); - } - } - - throw lastError; -} - -function createDeferred(): { - promise: Promise; - resolve: (value: T) => void; -} { - let resolve: (value: T) => void = () => undefined; - const promise = new Promise((promiseResolve) => { - resolve = promiseResolve; - }); - - return { promise, resolve }; -} - -function countTimelineCalls({ - cwd: targetCwd, - prNumber, -}: { - cwd: string; - prNumber: number; -}): number { - return mockClient.pullRequestTimeline.mock.calls.filter(([input]) => { - const request = input as { cwd?: string; prNumber?: number | null }; - return request.cwd === targetCwd && request.prNumber === prNumber; - }).length; -} diff --git a/packages/app/src/hooks/use-pr-pane-data.ts b/packages/app/src/hooks/use-pr-pane-data.ts index 698e362ed..a9178fead 100644 --- a/packages/app/src/hooks/use-pr-pane-data.ts +++ b/packages/app/src/hooks/use-pr-pane-data.ts @@ -1,18 +1,19 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { useMemo } from "react"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import type { CheckoutPrStatusResponse, PullRequestTimelineResponse, } from "@getpaseo/protocol/messages"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { mapPrPaneData, type PrPaneData } from "@/git/pr-pane-data"; import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query"; import { prPaneTimelineQueryKey } from "@/git/query-keys"; +type CheckoutPrStatus = CheckoutPrStatusResponse["payload"]["status"]; type CheckoutPrStatusPayloadError = CheckoutPrStatusResponse["payload"]["error"]; type PullRequestTimeline = PullRequestTimelineResponse["payload"]; -const unsupportedTimelineKeys = new Set(); - export interface UsePrPaneDataOptions { serverId: string; cwd: string; @@ -29,23 +30,21 @@ export interface UsePrPaneDataResult { githubFeaturesEnabled: boolean; } -interface PrRepoIdentity { +export interface PrRepoIdentity { prNumber: number | null; repoOwner: string | null; repoName: string | null; } -function extractPrRepoIdentity(status: CheckoutPrStatusLike): PrRepoIdentity { +export function extractPrRepoIdentity(status: CheckoutPrStatus | null): PrRepoIdentity { const prNumber = status?.number ?? null; const repoOwner = status?.repoOwner && status.repoOwner.length > 0 ? status.repoOwner : null; const repoName = status?.repoName && status.repoName.length > 0 ? status.repoName : null; return { prNumber, repoOwner, repoName }; } -type CheckoutPrStatusLike = ReturnType["status"]; - -interface ShouldFetchTimelineArgs { - daemonClient: unknown; +export interface ShouldFetchTimelineArgs { + hasClient: boolean; isConnected: boolean; timelineEnabled: boolean; githubFeaturesEnabled: boolean; @@ -54,8 +53,8 @@ interface ShouldFetchTimelineArgs { timelineUnsupported: boolean; } -function shouldFetchTimelineFrom({ - daemonClient, +export function shouldFetchTimelineFrom({ + hasClient, isConnected, timelineEnabled, githubFeaturesEnabled, @@ -64,7 +63,7 @@ function shouldFetchTimelineFrom({ timelineUnsupported, }: ShouldFetchTimelineArgs): boolean { return ( - !!daemonClient && + hasClient && isConnected && timelineEnabled && githubFeaturesEnabled && @@ -76,6 +75,112 @@ function shouldFetchTimelineFrom({ ); } +export type PrPaneTimelineClient = Pick; + +export interface UnsupportedTimelineRegistry { + has(key: string): boolean; + add(key: string): void; +} + +export function unsupportedTimelineKey({ + serverId, + cwd, + prNumber, +}: { + serverId: string; + cwd: string; + prNumber: number; +}): string { + return `${serverId}\0${cwd}\0${prNumber}`; +} + +export function createInMemoryUnsupportedTimelineRegistry(): UnsupportedTimelineRegistry { + const keys = new Set(); + return { + has: (key) => keys.has(key), + add: (key) => { + keys.add(key); + }, + }; +} + +const defaultUnsupportedTimelineRegistry = createInMemoryUnsupportedTimelineRegistry(); + +export interface FetchPrPaneTimelinePageInput { + client: PrPaneTimelineClient; + registry: UnsupportedTimelineRegistry; + serverId: string; + cwd: string; + prNumber: number; + repoOwner: string; + repoName: string; +} + +export async function fetchPrPaneTimelinePage( + input: FetchPrPaneTimelinePageInput, +): Promise { + try { + return await input.client.pullRequestTimeline({ + cwd: input.cwd, + prNumber: input.prNumber, + repoOwner: input.repoOwner, + repoName: input.repoName, + }); + } catch (error) { + if (isUnsupportedTimelineError(error)) { + input.registry.add( + unsupportedTimelineKey({ + serverId: input.serverId, + cwd: input.cwd, + prNumber: input.prNumber, + }), + ); + } + throw error; + } +} + +export interface SelectPrPaneStateInput { + status: CheckoutPrStatus | null; + statusPayloadError: CheckoutPrStatusPayloadError; + statusError: Error | null; + statusIsLoading: boolean; + statusIsFetching: boolean; + githubFeaturesEnabled: boolean; + timelineEnabled: boolean; + shouldFetchTimeline: boolean; + timelinePayload: PullRequestTimeline | undefined; + timelineError: Error | null; + timelineIsLoading: boolean; + timelineIsFetching: boolean; +} + +export function selectPrPaneState(input: SelectPrPaneStateInput): UsePrPaneDataResult { + const identity = extractPrRepoIdentity(input.status); + const data = + identity.prNumber === null || !input.timelineEnabled + ? null + : mapPrPaneData(input.status, input.timelinePayload); + const statusRefreshing = input.statusIsFetching && !input.statusIsLoading; + const timelineRefreshing = input.timelineIsFetching && !input.timelineIsLoading; + + return { + data, + prNumber: identity.prNumber, + isLoading: + input.statusIsLoading || + (input.shouldFetchTimeline && input.timelineIsLoading && input.timelinePayload === undefined), + isRefreshing: statusRefreshing || timelineRefreshing, + error: firstNonSuppressedError({ + statusPayloadError: input.statusPayloadError, + statusError: input.statusError, + timelineError: input.timelineError, + timelinePayloadError: input.timelinePayload?.error ?? null, + }), + githubFeaturesEnabled: input.githubFeaturesEnabled, + }; +} + export function usePrPaneData({ serverId, cwd, @@ -86,41 +191,47 @@ export function usePrPaneData({ const isConnected = useHostRuntimeIsConnected(serverId); const checkoutPrStatus = useCheckoutPrStatusQuery({ serverId, cwd, enabled }); const status = checkoutPrStatus.status; - const { prNumber, repoOwner, repoName } = extractPrRepoIdentity(status); + const identity = extractPrRepoIdentity(status); const githubFeaturesEnabled = checkoutPrStatus.githubFeaturesEnabled; + const registry = defaultUnsupportedTimelineRegistry; const unsupportedKey = - prNumber === null ? null : timelineUnsupportedKey({ serverId, cwd, prNumber }); - const timelineUnsupported = unsupportedKey ? unsupportedTimelineKeys.has(unsupportedKey) : false; + identity.prNumber === null + ? null + : unsupportedTimelineKey({ serverId, cwd, prNumber: identity.prNumber }); + const timelineUnsupported = unsupportedKey ? registry.has(unsupportedKey) : false; const shouldFetchTimeline = shouldFetchTimelineFrom({ - daemonClient, + hasClient: !!daemonClient, isConnected, timelineEnabled, githubFeaturesEnabled, cwd, - identity: { prNumber, repoOwner, repoName }, + identity, timelineUnsupported, }); const timelineQuery = useQuery({ - queryKey: prPaneTimelineQueryKey({ serverId, cwd, prNumber }), + queryKey: useMemo( + () => prPaneTimelineQueryKey({ serverId, cwd, prNumber: identity.prNumber }), + [serverId, cwd, identity.prNumber], + ), queryFn: async () => { - if (!daemonClient || prNumber === null || repoOwner === null || repoName === null) { + if ( + !daemonClient || + identity.prNumber === null || + identity.repoOwner === null || + identity.repoName === null + ) { throw new Error("Daemon client not available"); } - - try { - return await daemonClient.pullRequestTimeline({ - cwd, - prNumber, - repoOwner, - repoName, - }); - } catch (error) { - if (unsupportedKey && isUnsupportedTimelineError(error)) { - unsupportedTimelineKeys.add(unsupportedKey); - } - throw error; - } + return fetchPrPaneTimelinePage({ + client: daemonClient, + registry, + serverId, + cwd, + prNumber: identity.prNumber, + repoOwner: identity.repoOwner, + repoName: identity.repoName, + }); }, enabled: shouldFetchTimeline, staleTime: Infinity, @@ -131,26 +242,20 @@ export function usePrPaneData({ retry: (failureCount, error) => !isUnsupportedTimelineError(error) && failureCount < 3, }); - const data = - prNumber === null || !timelineEnabled ? null : mapPrPaneData(status, timelineQuery.data); - const statusRefreshing = checkoutPrStatus.isFetching && !checkoutPrStatus.isLoading; - const timelineRefreshing = timelineQuery.isFetching && !timelineQuery.isLoading; - - return { - data, - prNumber, - isLoading: - checkoutPrStatus.isLoading || - (shouldFetchTimeline && timelineQuery.isLoading && timelineQuery.data === undefined), - isRefreshing: statusRefreshing || timelineRefreshing, - error: firstNonSuppressedError({ - statusPayloadError: checkoutPrStatus.payloadError, - statusError: checkoutPrStatus.error, - timelineError: timelineQuery.error, - timelinePayloadError: timelineQuery.data?.error ?? null, - }), + return selectPrPaneState({ + status, + statusPayloadError: checkoutPrStatus.payloadError, + statusError: checkoutPrStatus.error, + statusIsLoading: checkoutPrStatus.isLoading, + statusIsFetching: checkoutPrStatus.isFetching, githubFeaturesEnabled, - }; + timelineEnabled, + shouldFetchTimeline, + timelinePayload: timelineQuery.data, + timelineError: timelineQuery.error, + timelineIsLoading: timelineQuery.isLoading, + timelineIsFetching: timelineQuery.isFetching, + }); } function firstNonSuppressedError({ @@ -183,19 +288,7 @@ function firstNonSuppressedError({ return null; } -function timelineUnsupportedKey({ - serverId, - cwd, - prNumber, -}: { - serverId: string; - cwd: string; - prNumber: number; -}): string { - return `${serverId}\0${cwd}\0${prNumber}`; -} - -function isUnsupportedTimelineError(error: unknown): boolean { +export function isUnsupportedTimelineError(error: unknown): boolean { if (!(error instanceof Error)) { return false; } diff --git a/packages/app/src/hooks/use-projects.test.ts b/packages/app/src/hooks/use-projects.test.ts index 0921df725..0a0d36385 100644 --- a/packages/app/src/hooks/use-projects.test.ts +++ b/packages/app/src/hooks/use-projects.test.ts @@ -1,76 +1,71 @@ -/** - * @vitest-environment jsdom - */ -import React from "react"; -import { renderHook, waitFor } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages"; -import { useProjects } from "./use-projects"; +import { + fetchAggregatedProjects, + type ProjectsHostInput, + type ProjectsRuntime, + type ProjectsRuntimeSnapshot, +} from "./use-projects"; -interface MockDaemonClient { - fetchWorkspaces: ReturnType; +type FetchWorkspaces = DaemonClient["fetchWorkspaces"]; +type FetchWorkspacesResult = Awaited>; + +interface HostFixture { + serverId: string; + serverName: string; + status: "online" | "offline" | "missing-snapshot"; + workspaces?: WorkspaceDescriptorPayload[] | Error; } -const { hostState, clientsByServerId, runtimeStore } = vi.hoisted(() => { - const state = { - hosts: [ - { serverId: "local", label: "Local" }, - { serverId: "laptop", label: "Laptop" }, - ], - snapshots: new Map(), - }; - const clients = new Map }>(); - return { - hostState: state, - clientsByServerId: clients, - runtimeStore: { - getClient: (serverId: string) => clients.get(serverId) ?? null, - getSnapshot: (serverId: string) => state.snapshots.get(serverId) ?? null, - }, - }; -}); - -vi.mock("@/runtime/host-runtime", () => ({ - getHostRuntimeStore: () => runtimeStore, - useHosts: () => hostState.hosts, -})); - -function createQueryClient(): QueryClient { - return new QueryClient({ - defaultOptions: { - queries: { retry: false }, - }, - }); +interface RuntimeAdapter extends ProjectsRuntime { + fetchCalls: Map; } -function renderProjectsHook() { - const queryClient = createQueryClient(); - const wrapper = ({ children }: { children: React.ReactNode }) => - React.createElement(QueryClientProvider, { client: queryClient }, children); +function createRuntime(hosts: HostFixture[]): RuntimeAdapter { + const snapshots = new Map(); + const clients = new Map | null>(); + const fetchCalls = new Map(); - return renderHook(() => useProjects(), { wrapper }); -} + for (const host of hosts) { + if (host.status === "missing-snapshot") { + snapshots.set(host.serverId, null); + } else { + snapshots.set(host.serverId, { connectionStatus: host.status }); + } -function makeClient(entries: WorkspaceDescriptorPayload[] | Error): MockDaemonClient { - return { - fetchWorkspaces: vi.fn(async () => { - if (entries instanceof Error) { - throw entries; + if (host.workspaces === undefined) { + clients.set(host.serverId, null); + continue; + } + + const workspaces = host.workspaces; + const fetchWorkspaces: FetchWorkspaces = async () => { + fetchCalls.set(host.serverId, (fetchCalls.get(host.serverId) ?? 0) + 1); + if (workspaces instanceof Error) { + throw workspaces; } return { - requestId: "req-workspaces", - entries, - pageInfo: { - nextCursor: null, - prevCursor: null, - hasMore: false, - }, - }; - }), + requestId: `req-${host.serverId}`, + entries: workspaces, + pageInfo: { nextCursor: null, prevCursor: null, hasMore: false }, + } satisfies FetchWorkspacesResult; + }; + + clients.set(host.serverId, { fetchWorkspaces }); + } + + return { + getClient: (serverId) => clients.get(serverId) ?? null, + getSnapshot: (serverId) => snapshots.get(serverId), + fetchCalls, }; } +function hostInputs(hosts: HostFixture[]): ProjectsHostInput[] { + return hosts.map((host) => ({ serverId: host.serverId, serverName: host.serverName })); +} + function workspace(input: { id: string; projectKey: string; @@ -118,83 +113,82 @@ function workspace(input: { }; } -afterEach(() => { - clientsByServerId.clear(); - hostState.hosts = [ - { serverId: "local", label: "Local" }, - { serverId: "laptop", label: "Laptop" }, - ]; - hostState.snapshots = new Map(); -}); +describe("fetchAggregatedProjects", () => { + it("calls every online host's client and aggregates projects sorted by display name", async () => { + const hosts: HostFixture[] = [ + { + serverId: "local", + serverName: "Local", + status: "online", + workspaces: [ + workspace({ + id: "z-main", + projectKey: "remote:github.com/acme/zeta", + projectName: "acme/zeta", + cwd: "/repo/zeta", + remoteUrl: "https://github.com/acme/zeta.git", + }), + ], + }, + { + serverId: "laptop", + serverName: "Laptop", + status: "online", + workspaces: [ + workspace({ + id: "a-main", + projectKey: "remote:github.com/acme/alpha", + projectName: "acme/alpha", + cwd: "/repo/alpha", + remoteUrl: "https://github.com/acme/alpha.git", + }), + ], + }, + ]; + const runtime = createRuntime(hosts); -describe("useProjects", () => { - it("calls all known daemon clients and returns aggregated projects sorted by display name", async () => { - hostState.snapshots.set("local", { connectionStatus: "online" }); - hostState.snapshots.set("laptop", { connectionStatus: "online" }); - clientsByServerId.set( - "local", - makeClient([ - workspace({ - id: "z-main", - projectKey: "remote:github.com/acme/zeta", - projectName: "acme/zeta", - cwd: "/repo/zeta", - remoteUrl: "https://github.com/acme/zeta.git", - }), - ]), - ); - clientsByServerId.set( - "laptop", - makeClient([ - workspace({ - id: "a-main", - projectKey: "remote:github.com/acme/alpha", - projectName: "acme/alpha", - cwd: "/repo/alpha", - remoteUrl: "https://github.com/acme/alpha.git", - }), - ]), - ); + const result = await fetchAggregatedProjects({ hosts: hostInputs(hosts), runtime }); - const { result } = renderProjectsHook(); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - expect(clientsByServerId.get("local")?.fetchWorkspaces).toHaveBeenCalledTimes(1); - expect(clientsByServerId.get("laptop")?.fetchWorkspaces).toHaveBeenCalledTimes(1); - expect(result.current.projects.map((project) => project.projectName)).toEqual([ + expect(runtime.fetchCalls.get("local")).toBe(1); + expect(runtime.fetchCalls.get("laptop")).toBe(1); + expect(result.projects.map((project) => project.projectName)).toEqual([ "acme/alpha", "acme/zeta", ]); + expect(result.hostErrors).toEqual([]); }); it("surfaces per-host fetch failures without dropping successful hosts", async () => { - hostState.snapshots.set("local", { connectionStatus: "online" }); - hostState.snapshots.set("laptop", { connectionStatus: "online" }); - clientsByServerId.set( - "local", - makeClient([ - workspace({ - id: "main", - projectKey: "remote:github.com/acme/app", - projectName: "acme/app", - cwd: "/repo/app", - remoteUrl: "https://github.com/acme/app.git", - }), - ]), - ); - clientsByServerId.set("laptop", makeClient(new Error("laptop unavailable"))); + const hosts: HostFixture[] = [ + { + serverId: "local", + serverName: "Local", + status: "online", + workspaces: [ + workspace({ + id: "main", + projectKey: "remote:github.com/acme/app", + projectName: "acme/app", + cwd: "/repo/app", + remoteUrl: "https://github.com/acme/app.git", + }), + ], + }, + { + serverId: "laptop", + serverName: "Laptop", + status: "online", + workspaces: new Error("laptop unavailable"), + }, + ]; + const runtime = createRuntime(hosts); - const { result } = renderProjectsHook(); + const result = await fetchAggregatedProjects({ hosts: hostInputs(hosts), runtime }); - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - expect(result.current.projects).toEqual([ + expect(result.projects).toEqual([ expect.objectContaining({ projectKey: "remote:github.com/acme/app" }), ]); - expect(result.current.hostErrors).toEqual([ + expect(result.hostErrors).toEqual([ { serverId: "laptop", serverName: "Laptop", @@ -204,53 +198,60 @@ describe("useProjects", () => { }); it("skips disconnected hosts silently without surfacing them as failures", async () => { - hostState.snapshots.set("local", { connectionStatus: "online" }); - hostState.snapshots.set("laptop", { connectionStatus: "offline" }); - clientsByServerId.set( - "local", - makeClient([ - workspace({ - id: "main", - projectKey: "remote:github.com/acme/app", - projectName: "acme/app", - cwd: "/repo/app", - remoteUrl: "https://github.com/acme/app.git", - }), - ]), - ); + const hosts: HostFixture[] = [ + { + serverId: "local", + serverName: "Local", + status: "online", + workspaces: [ + workspace({ + id: "main", + projectKey: "remote:github.com/acme/app", + projectName: "acme/app", + cwd: "/repo/app", + remoteUrl: "https://github.com/acme/app.git", + }), + ], + }, + { + serverId: "laptop", + serverName: "Laptop", + status: "offline", + }, + ]; + const runtime = createRuntime(hosts); - const { result } = renderProjectsHook(); + const result = await fetchAggregatedProjects({ hosts: hostInputs(hosts), runtime }); - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - expect(result.current.hostErrors).toEqual([]); - expect(result.current.projects).toEqual([ + expect(result.hostErrors).toEqual([]); + expect(result.projects).toEqual([ expect.objectContaining({ projectKey: "remote:github.com/acme/app" }), ]); + expect(runtime.fetchCalls.get("laptop")).toBeUndefined(); }); it("returns only the stable public project and host entry shapes", async () => { - hostState.snapshots.set("local", { connectionStatus: "online" }); - clientsByServerId.set( - "local", - makeClient([ - workspace({ - id: "main", - projectKey: "remote:github.com/acme/app", - projectName: "acme/app", - cwd: "/repo/app", - remoteUrl: "https://github.com/acme/app.git", - }), - ]), - ); + const hosts: HostFixture[] = [ + { + serverId: "local", + serverName: "Local", + status: "online", + workspaces: [ + workspace({ + id: "main", + projectKey: "remote:github.com/acme/app", + projectName: "acme/app", + cwd: "/repo/app", + remoteUrl: "https://github.com/acme/app.git", + }), + ], + }, + ]; + const runtime = createRuntime(hosts); - const { result } = renderProjectsHook(); + const result = await fetchAggregatedProjects({ hosts: hostInputs(hosts), runtime }); - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - expect(Object.keys(result.current.projects[0] ?? {}).sort()).toEqual([ + expect(Object.keys(result.projects[0] ?? {}).sort()).toEqual([ "githubUrl", "hostCount", "hosts", @@ -260,7 +261,7 @@ describe("useProjects", () => { "projectName", "totalWorkspaceCount", ]); - expect(Object.keys(result.current.projects[0]?.hosts[0] ?? {}).sort()).toEqual([ + expect(Object.keys(result.projects[0]?.hosts[0] ?? {}).sort()).toEqual([ "gitRuntime", "githubRuntime", "isOnline", @@ -270,7 +271,7 @@ describe("useProjects", () => { "workspaceCount", "workspaces", ]); - expect(Object.keys(result.current.projects[0]?.hosts[0]?.workspaces[0] ?? {}).sort()).toEqual([ + expect(Object.keys(result.projects[0]?.hosts[0]?.workspaces[0] ?? {}).sort()).toEqual([ "currentBranch", "id", "name", diff --git a/packages/app/src/hooks/use-projects.ts b/packages/app/src/hooks/use-projects.ts index a22d0b61a..e131f0927 100644 --- a/packages/app/src/hooks/use-projects.ts +++ b/packages/app/src/hooks/use-projects.ts @@ -21,8 +21,28 @@ export interface UseProjectsResult { refetch: () => void; } -interface FetchAllWorkspaceDescriptorsInput { - client: Pick; +export interface ProjectsRuntimeSnapshot { + connectionStatus: string; +} + +export interface ProjectsRuntime { + getClient(serverId: string): Pick | null; + getSnapshot(serverId: string): ProjectsRuntimeSnapshot | null | undefined; +} + +export interface ProjectsHostInput { + serverId: string; + serverName: string; +} + +export interface FetchAggregatedProjectsInput { + hosts: ProjectsHostInput[]; + runtime: ProjectsRuntime; +} + +export interface FetchAggregatedProjectsResult { + projects: ProjectSummary[]; + hostErrors: ProjectHostError[]; } interface HostWorkspacesResult { @@ -35,13 +55,13 @@ function toErrorMessage(error: unknown): string { } async function fetchAllWorkspaceDescriptors( - input: FetchAllWorkspaceDescriptorsInput, + client: Pick, ): Promise { const entries: WorkspaceDescriptor[] = []; let cursor: string | null = null; while (true) { - const payload = await input.client.fetchWorkspaces({ + const payload = await client.fetchWorkspaces({ sort: [{ key: "name", direction: "asc" }], page: cursor ? { limit: 200, cursor } : { limit: 200 }, }); @@ -55,10 +75,66 @@ async function fetchAllWorkspaceDescriptors( return entries; } +export async function fetchAggregatedProjects( + input: FetchAggregatedProjectsInput, +): Promise { + const results = await Promise.all( + input.hosts.map(async (host): Promise => { + const snapshot = input.runtime.getSnapshot(host.serverId); + const isOnline = snapshot?.connectionStatus === "online"; + const client = input.runtime.getClient(host.serverId); + + if (!client || !isOnline) { + return { + host: { + serverId: host.serverId, + serverName: host.serverName, + isOnline, + workspaces: [], + }, + error: null, + }; + } + + try { + return { + host: { + serverId: host.serverId, + serverName: host.serverName, + isOnline, + workspaces: await fetchAllWorkspaceDescriptors(client), + }, + error: null, + }; + } catch (error) { + return { + host: { + serverId: host.serverId, + serverName: host.serverName, + isOnline, + workspaces: [], + }, + error: { + serverId: host.serverId, + serverName: host.serverName, + message: toErrorMessage(error), + }, + }; + } + }), + ); + + const hostErrors = results.flatMap((result) => (result.error ? [result.error] : [])); + return { + ...buildProjects({ hosts: results.map((result) => result.host) }), + hostErrors, + }; +} + export function useProjects(): UseProjectsResult { const hosts = useHosts(); const runtime = getHostRuntimeStore(); - const hostInputs = useMemo( + const hostInputs = useMemo( () => hosts.map((host) => ({ serverId: host.serverId, @@ -69,59 +145,7 @@ export function useProjects(): UseProjectsResult { const projectsQuery = useQuery({ queryKey: projectsQueryKey, - queryFn: async () => { - const results = await Promise.all( - hostInputs.map(async (host): Promise => { - const snapshot = runtime.getSnapshot(host.serverId); - const isOnline = snapshot?.connectionStatus === "online"; - const client = runtime.getClient(host.serverId); - - if (!client || !isOnline) { - return { - host: { - serverId: host.serverId, - serverName: host.serverName, - isOnline, - workspaces: [], - }, - error: null, - }; - } - - try { - return { - host: { - serverId: host.serverId, - serverName: host.serverName, - isOnline, - workspaces: await fetchAllWorkspaceDescriptors({ client }), - }, - error: null, - }; - } catch (error) { - return { - host: { - serverId: host.serverId, - serverName: host.serverName, - isOnline, - workspaces: [], - }, - error: { - serverId: host.serverId, - serverName: host.serverName, - message: toErrorMessage(error), - }, - }; - } - }), - ); - - const hostErrors = results.flatMap((result) => (result.error ? [result.error] : [])); - return { - ...buildProjects({ hosts: results.map((result) => result.host) }), - hostErrors, - }; - }, + queryFn: () => fetchAggregatedProjects({ hosts: hostInputs, runtime }), }); return { diff --git a/packages/app/src/hooks/use-providers-snapshot.test.ts b/packages/app/src/hooks/use-providers-snapshot.test.ts index 68bbcbaf2..b57000732 100644 --- a/packages/app/src/hooks/use-providers-snapshot.test.ts +++ b/packages/app/src/hooks/use-providers-snapshot.test.ts @@ -1,92 +1,62 @@ -/** - * @vitest-environment jsdom - */ -import React from "react"; -import { act, renderHook, waitFor } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { QueryClient } from "@tanstack/react-query"; +import { beforeEach, describe, expect, it } from "vitest"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; -import { useSessionStore } from "@/stores/session-store"; -import { providersSnapshotQueryKey, useProvidersSnapshot } from "./use-providers-snapshot"; +import { + applyProvidersSnapshotUpdate, + fetchProvidersSnapshot, + providersSnapshotQueryKey, + refreshAndApplyProvidersSnapshot, + selectorOpenRefetchDecision, + type ProvidersSnapshotClient, + type ProvidersSnapshotUpdateMessage, +} from "./use-providers-snapshot"; -interface ProviderSnapshotUpdateMessage { - type: "providers_snapshot_update"; - payload: { - cwd?: string; - entries: ProviderSnapshotEntry[]; - generatedAt: string; - }; -} -type ProviderSnapshotUpdateListener = (message: ProviderSnapshotUpdateMessage) => void; -interface ProvidersSnapshot { - entries: ProviderSnapshotEntry[]; - generatedAt: string; - requestId: string; -} -type HookResult = ReturnType["result"]; +type GetProvidersSnapshotResult = Awaited>; +type RefreshProvidersSnapshotResult = Awaited>; +type GetProvidersSnapshotOptions = Parameters[0]; +type RefreshProvidersSnapshotOptions = Parameters[0]; -const { mockClient, mockRuntime, snapshotUpdateListeners } = vi.hoisted(() => { - const hoistedListeners: ProviderSnapshotUpdateListener[] = []; - const hoistedClient = { - getProvidersSnapshot: vi.fn(), - refreshProvidersSnapshot: vi.fn(), - on: vi.fn((_event: string, listener: ProviderSnapshotUpdateListener) => { - hoistedListeners.push(listener); - return () => {}; - }), +interface FakeProvidersSnapshotClient extends ProvidersSnapshotClient { + getCalls: GetProvidersSnapshotOptions[]; + refreshCalls: RefreshProvidersSnapshotOptions[]; +} + +function createClient( + input: { + snapshots?: GetProvidersSnapshotResult[]; + refreshResult?: RefreshProvidersSnapshotResult; + } = {}, +): FakeProvidersSnapshotClient { + const snapshots = [...(input.snapshots ?? [])]; + const refreshResult: RefreshProvidersSnapshotResult = input.refreshResult ?? { + acknowledged: true, + requestId: "refresh-1", }; + + const getCalls: GetProvidersSnapshotOptions[] = []; + const refreshCalls: RefreshProvidersSnapshotOptions[] = []; + return { - mockClient: hoistedClient, - mockRuntime: { - client: hoistedClient, - isConnected: true, + getCalls, + refreshCalls, + async getProvidersSnapshot(options) { + getCalls.push(options ?? {}); + const next = snapshots.shift(); + if (!next) { + throw new Error("No snapshot configured for getProvidersSnapshot call"); + } + return next; + }, + async refreshProvidersSnapshot(options) { + refreshCalls.push(options ?? {}); + return refreshResult; }, - snapshotUpdateListeners: hoistedListeners, }; -}); - -vi.mock("@/runtime/host-runtime", () => ({ - useHostRuntimeClient: () => mockRuntime.client, - useHostRuntimeIsConnected: () => mockRuntime.isConnected, -})); - -const serverId = "server-1"; - -function createQueryClient(): QueryClient { - return new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, - }); } -function enableProvidersSnapshot(): void { - act(() => { - useSessionStore.getState().initializeSession(serverId, mockClient as unknown as DaemonClient); - useSessionStore.getState().updateSessionServerInfo(serverId, { - serverId, - hostname: "localhost", - version: "test", - features: { providersSnapshot: true }, - } as never); - }); -} - -function renderProvidersSnapshotHook(options: { cwd?: string | null } = {}) { - const queryClient = createQueryClient(); - const wrapper = ({ children }: { children: React.ReactNode }) => - React.createElement(QueryClientProvider, { client: queryClient }, children); - - return renderHook(() => useProvidersSnapshot(serverId, options), { wrapper }); -} - -const readyCodexModel = { provider: "codex", id: "gpt-5.4", label: "GPT-5.4" } as const; - -function providersSnapshot(entries: ProviderSnapshotEntry[]): ProvidersSnapshot { +function providersSnapshot(entries: ProviderSnapshotEntry[]): GetProvidersSnapshotResult { return { entries, generatedAt: "2026-01-01T00:00:00.000Z", @@ -106,56 +76,11 @@ function codexEntry( }; } -async function waitForSnapshotReads(count: number): Promise { - await waitFor(() => { - expect(mockClient.getProvidersSnapshot).toHaveBeenCalledTimes(count); - }); -} +const readyCodexModel = { provider: "codex", id: "gpt-5.4", label: "GPT-5.4" } as const; +const serverId = "server-1"; -async function waitForSnapshotEntries( - result: HookResult, - entries: ProviderSnapshotEntry[], -): Promise { - await waitFor(() => { - expect(result.current.entries).toEqual(entries); - }); -} - -async function emitProvidersSnapshotUpdate( - entries: ProviderSnapshotEntry[], - cwd?: string, -): Promise { - const listener = snapshotUpdateListeners.at(-1); - expect(listener).toBeDefined(); - - await act(async () => { - listener?.({ - type: "providers_snapshot_update", - payload: { - ...(cwd ? { cwd } : {}), - entries, - generatedAt: "2026-01-01T00:00:01.000Z", - }, - }); - }); -} - -async function openSelectorForSelectedProvider(result: HookResult): Promise { - await act(async () => { - result.current.refetchIfStale("codex"); - }); -} - -afterEach(() => { - act(() => { - useSessionStore.getState().clearSession(serverId); - }); - vi.clearAllMocks(); - snapshotUpdateListeners.length = 0; -}); - -describe("providers snapshot hook cache scope", () => { - it("uses separate query keys for home and workspace scopes", () => { +describe("providersSnapshotQueryKey", () => { + it("uses separate keys for home and workspace scopes", () => { expect(providersSnapshotQueryKey(serverId)).toEqual(["providersSnapshot", serverId, "home"]); expect(providersSnapshotQueryKey(serverId, "/repo-a")).toEqual([ "providersSnapshot", @@ -164,118 +89,209 @@ describe("providers snapshot hook cache scope", () => { "/repo-a", ]); }); +}); - it("sends no cwd for settings snapshot loads and refreshes", async () => { - enableProvidersSnapshot(); - mockClient.getProvidersSnapshot.mockResolvedValue(providersSnapshot([])); - mockClient.refreshProvidersSnapshot.mockResolvedValue({ - acknowledged: true, - requestId: "settings-refresh", - }); +describe("fetchProvidersSnapshot", () => { + it("sends no cwd for the home scope", async () => { + const client = createClient({ snapshots: [providersSnapshot([])] }); - const { result } = renderProvidersSnapshotHook(); + await fetchProvidersSnapshot({ client, cwd: null }); - await waitFor(() => { - expect(mockClient.getProvidersSnapshot).toHaveBeenCalledWith({}); - }); - - await act(async () => { - await result.current.refresh(["codex"]); - }); - - expect(mockClient.refreshProvidersSnapshot).toHaveBeenCalledWith({ providers: ["codex"] }); - expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({}); + expect(client.getCalls).toEqual([{}]); }); - it("sends cwd for workspace snapshot loads and refreshes", async () => { - enableProvidersSnapshot(); - mockClient.getProvidersSnapshot.mockResolvedValue(providersSnapshot([])); - mockClient.refreshProvidersSnapshot.mockResolvedValue({ - acknowledged: true, - requestId: "workspace-refresh", + it("sends the workspace cwd for the workspace scope", async () => { + const client = createClient({ snapshots: [providersSnapshot([])] }); + + await fetchProvidersSnapshot({ client, cwd: "/repo-a" }); + + expect(client.getCalls).toEqual([{ cwd: "/repo-a" }]); + }); +}); + +describe("refreshAndApplyProvidersSnapshot", () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient(); + }); + + it("refreshes then re-fetches the home snapshot and writes it into the home query cache", async () => { + const client = createClient({ + snapshots: [providersSnapshot([codexEntry("ready", [readyCodexModel])])], }); - const { result } = renderProvidersSnapshotHook({ cwd: "/repo-a" }); - - await waitFor(() => { - expect(mockClient.getProvidersSnapshot).toHaveBeenCalledWith({ cwd: "/repo-a" }); + await refreshAndApplyProvidersSnapshot({ + client, + queryClient, + serverId, + cwd: null, + providers: ["codex"], }); - await act(async () => { - await result.current.refresh(["codex"]); + expect(client.refreshCalls).toEqual([{ providers: ["codex"] }]); + expect(client.getCalls).toEqual([{}]); + expect(queryClient.getQueryData(providersSnapshotQueryKey(serverId))).toEqual( + providersSnapshot([codexEntry("ready", [readyCodexModel])]), + ); + }); + + it("refreshes then re-fetches the workspace snapshot with the cwd preserved", async () => { + const client = createClient({ + snapshots: [providersSnapshot([codexEntry("ready", [readyCodexModel])])], }); - expect(mockClient.refreshProvidersSnapshot).toHaveBeenCalledWith({ + await refreshAndApplyProvidersSnapshot({ + client, + queryClient, + serverId, cwd: "/repo-a", providers: ["codex"], }); - expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({ cwd: "/repo-a" }); - }); - it("routes provider snapshot updates by cwd scope", async () => { - enableProvidersSnapshot(); - mockClient.getProvidersSnapshot.mockResolvedValue(providersSnapshot([])); - - const { result } = renderProvidersSnapshotHook({ cwd: "/repo-a" }); - - await waitForSnapshotEntries(result, []); - await emitProvidersSnapshotUpdate([codexEntry("ready", [readyCodexModel])], "/repo-b"); - expect(result.current.entries).toEqual([]); - - await emitProvidersSnapshotUpdate([codexEntry("ready", [readyCodexModel])], "/repo-a"); - await waitForSnapshotEntries(result, [codexEntry("ready", [readyCodexModel])]); - }); - - it("applies loading snapshot updates without refetching through the read path", async () => { - enableProvidersSnapshot(); - mockClient.getProvidersSnapshot.mockResolvedValueOnce( - providersSnapshot([codexEntry("ready", [])]), + expect(client.refreshCalls).toEqual([{ cwd: "/repo-a", providers: ["codex"] }]); + expect(client.getCalls).toEqual([{ cwd: "/repo-a" }]); + expect(queryClient.getQueryData(providersSnapshotQueryKey(serverId, "/repo-a"))).toEqual( + providersSnapshot([codexEntry("ready", [readyCodexModel])]), ); - - const { result } = renderProvidersSnapshotHook(); - - await waitForSnapshotReads(1); - - const loadingEntries = [codexEntry("loading")]; - await emitProvidersSnapshotUpdate(loadingEntries); - - await waitForSnapshotEntries(result, loadingEntries); - expect(mockClient.getProvidersSnapshot).toHaveBeenCalledTimes(1); - expect(mockClient.refreshProvidersSnapshot).not.toHaveBeenCalled(); }); - it.each([ - { name: "missing", entries: [] }, - { name: "loading", entries: [codexEntry("loading")] }, - ])( - "ensures a selected provider snapshot on selector open when it is $name", - async ({ entries }) => { - enableProvidersSnapshot(); - mockClient.getProvidersSnapshot - .mockResolvedValueOnce(providersSnapshot(entries)) - .mockResolvedValueOnce(providersSnapshot([codexEntry("ready", [readyCodexModel])])); + it("invalidates every scope under the server when refreshing the home snapshot", async () => { + const client = createClient({ snapshots: [providersSnapshot([])] }); + queryClient.setQueryData(providersSnapshotQueryKey(serverId, "/repo-a"), providersSnapshot([])); + queryClient.setQueryData(providersSnapshotQueryKey(serverId, "/repo-b"), providersSnapshot([])); - const { result } = renderProvidersSnapshotHook(); + await refreshAndApplyProvidersSnapshot({ + client, + queryClient, + serverId, + cwd: null, + }); - await waitForSnapshotEntries(result, entries); - await openSelectorForSelectedProvider(result); - await waitForSnapshotReads(2); + expect( + queryClient.getQueryState(providersSnapshotQueryKey(serverId, "/repo-a"))?.isInvalidated, + ).toBe(true); + expect( + queryClient.getQueryState(providersSnapshotQueryKey(serverId, "/repo-b"))?.isInvalidated, + ).toBe(true); + }); - expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({}); - expect(mockClient.refreshProvidersSnapshot).not.toHaveBeenCalled(); - }, - ); + it("does not invalidate sibling scopes when refreshing a workspace snapshot", async () => { + const client = createClient({ snapshots: [providersSnapshot([])] }); + queryClient.setQueryData(providersSnapshotQueryKey(serverId), providersSnapshot([])); + queryClient.setQueryData(providersSnapshotQueryKey(serverId, "/repo-b"), providersSnapshot([])); - it("does not ensure a selected provider snapshot on selector open when the provider is ready with no models", async () => { - enableProvidersSnapshot(); - mockClient.getProvidersSnapshot.mockResolvedValue(providersSnapshot([codexEntry("ready", [])])); + await refreshAndApplyProvidersSnapshot({ + client, + queryClient, + serverId, + cwd: "/repo-a", + }); - const { result } = renderProvidersSnapshotHook(); + expect(queryClient.getQueryState(providersSnapshotQueryKey(serverId))?.isInvalidated).toBe( + false, + ); + expect( + queryClient.getQueryState(providersSnapshotQueryKey(serverId, "/repo-b"))?.isInvalidated, + ).toBe(false); + }); +}); - await waitForSnapshotEntries(result, [codexEntry("ready", [])]); - await openSelectorForSelectedProvider(result); +describe("applyProvidersSnapshotUpdate", () => { + let queryClient: QueryClient; - expect(mockClient.getProvidersSnapshot).toHaveBeenCalledTimes(1); - expect(mockClient.refreshProvidersSnapshot).not.toHaveBeenCalled(); + beforeEach(() => { + queryClient = new QueryClient(); + }); + + function updateMessage( + entries: ProviderSnapshotEntry[], + cwd?: string, + ): ProvidersSnapshotUpdateMessage { + return { + type: "providers_snapshot_update", + payload: { + ...(cwd ? { cwd } : {}), + entries, + generatedAt: "2026-01-01T00:00:01.000Z", + }, + }; + } + + it("routes updates to the home query cache when the message carries no cwd", () => { + applyProvidersSnapshotUpdate({ + serverId, + queryClient, + message: updateMessage([codexEntry("ready", [readyCodexModel])]), + }); + + expect(queryClient.getQueryData(providersSnapshotQueryKey(serverId))).toEqual({ + entries: [codexEntry("ready", [readyCodexModel])], + generatedAt: "2026-01-01T00:00:01.000Z", + requestId: "providers_snapshot_update", + }); + }); + + it("routes workspace updates to the matching scope without touching siblings", () => { + queryClient.setQueryData(providersSnapshotQueryKey(serverId, "/repo-b"), providersSnapshot([])); + + applyProvidersSnapshotUpdate({ + serverId, + queryClient, + message: updateMessage([codexEntry("ready", [readyCodexModel])], "/repo-a"), + }); + + expect(queryClient.getQueryData(providersSnapshotQueryKey(serverId, "/repo-a"))).toEqual({ + entries: [codexEntry("ready", [readyCodexModel])], + generatedAt: "2026-01-01T00:00:01.000Z", + requestId: "providers_snapshot_update", + }); + expect(queryClient.getQueryData(providersSnapshotQueryKey(serverId, "/repo-b"))).toEqual( + providersSnapshot([]), + ); + }); +}); + +describe("selectorOpenRefetchDecision", () => { + it("refetches stale entries when no provider is selected", () => { + expect( + selectorOpenRefetchDecision({ + entries: [codexEntry("ready", [readyCodexModel])], + selectedProvider: null, + }), + ).toBe("refetch-stale"); + }); + + it("forces a refetch when the selected provider has no entry", () => { + expect(selectorOpenRefetchDecision({ entries: [], selectedProvider: "codex" })).toBe( + "refetch-always", + ); + }); + + it("forces a refetch when the selected provider is still loading", () => { + expect( + selectorOpenRefetchDecision({ + entries: [codexEntry("loading")], + selectedProvider: "codex", + }), + ).toBe("refetch-always"); + }); + + it("keeps a stale-only refetch when the selected provider is ready with no models", () => { + expect( + selectorOpenRefetchDecision({ + entries: [codexEntry("ready", [])], + selectedProvider: "codex", + }), + ).toBe("refetch-stale"); + }); + + it("keeps a stale-only refetch when the selected provider is ready with models", () => { + expect( + selectorOpenRefetchDecision({ + entries: [codexEntry("ready", [readyCodexModel])], + selectedProvider: "codex", + }), + ).toBe("refetch-stale"); }); }); diff --git a/packages/app/src/hooks/use-providers-snapshot.ts b/packages/app/src/hooks/use-providers-snapshot.ts index 7973f704b..ea383e585 100644 --- a/packages/app/src/hooks/use-providers-snapshot.ts +++ b/packages/app/src/hooks/use-providers-snapshot.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query"; import type { AgentProvider, ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; @@ -13,8 +13,85 @@ import { providersSnapshotRequestOptions, } from "@/hooks/providers-snapshot-query"; +type GetProvidersSnapshotResult = Awaited>; +type RefreshProvidersSnapshotResult = Awaited>; + export { providersSnapshotQueryKey, providersSnapshotQueryRoot }; +export type ProvidersSnapshotClient = Pick< + DaemonClient, + "getProvidersSnapshot" | "refreshProvidersSnapshot" +>; + +export interface ProvidersSnapshotUpdateMessage { + type: "providers_snapshot_update"; + payload: { + cwd?: string; + entries: ProviderSnapshotEntry[]; + generatedAt: string; + }; +} + +export async function fetchProvidersSnapshot(input: { + client: ProvidersSnapshotClient; + cwd: string | null; +}): Promise { + return input.client.getProvidersSnapshot(providersSnapshotRequestOptions({ cwd: input.cwd })); +} + +export async function refreshAndApplyProvidersSnapshot(input: { + client: ProvidersSnapshotClient; + queryClient: QueryClient; + serverId: string; + cwd: string | null; + providers?: AgentProvider[]; +}): Promise { + const refreshResult = await input.client.refreshProvidersSnapshot( + providersSnapshotRequestOptions({ cwd: input.cwd, providers: input.providers }), + ); + const snapshot = await fetchProvidersSnapshot({ client: input.client, cwd: input.cwd }); + input.queryClient.setQueryData(providersSnapshotQueryKey(input.serverId, input.cwd), snapshot); + if (isProvidersSnapshotHomeScope(input.cwd)) { + void input.queryClient.invalidateQueries({ + queryKey: providersSnapshotQueryRoot(input.serverId), + exact: false, + }); + } + return refreshResult; +} + +export function applyProvidersSnapshotUpdate(input: { + serverId: string; + queryClient: QueryClient; + message: ProvidersSnapshotUpdateMessage; +}): void { + if (input.message.type !== "providers_snapshot_update") { + return; + } + const queryKey = providersSnapshotQueryKey(input.serverId, input.message.payload.cwd); + input.queryClient.setQueryData(queryKey, { + entries: input.message.payload.entries, + generatedAt: input.message.payload.generatedAt, + requestId: "providers_snapshot_update", + }); +} + +export type SelectorOpenRefetchDecision = "refetch-stale" | "refetch-always"; + +export function selectorOpenRefetchDecision(input: { + entries: ProviderSnapshotEntry[] | undefined; + selectedProvider: AgentProvider | null | undefined; +}): SelectorOpenRefetchDecision { + if (!input.selectedProvider) { + return "refetch-stale"; + } + const selectedEntry = input.entries?.find((entry) => entry.provider === input.selectedProvider); + if (!selectedEntry || selectedEntry.status === "loading") { + return "refetch-always"; + } + return "refetch-stale"; +} + interface UseProvidersSnapshotResult { entries: ProviderSnapshotEntry[] | undefined; isLoading: boolean; @@ -45,8 +122,6 @@ export function useProvidersSnapshot( ); const queryKey = useMemo(() => providersSnapshotQueryKey(serverId, cwd), [cwd, serverId]); - const queryRoot = useMemo(() => providersSnapshotQueryRoot(serverId), [serverId]); - const requestOptions = useMemo(() => providersSnapshotRequestOptions({ cwd }), [cwd]); const snapshotQuery = useQuery({ queryKey, @@ -56,16 +131,22 @@ export function useProvidersSnapshot( if (!client) { throw new Error("Host is not connected"); } - return client.getProvidersSnapshot(requestOptions); + return fetchProvidersSnapshot({ client, cwd }); }, }); const refreshMutation = useMutation({ mutationFn: async (providers?: AgentProvider[]) => { - if (!client) { + if (!client || !serverId) { return; } - await client.refreshProvidersSnapshot(providersSnapshotRequestOptions({ cwd, providers })); + await refreshAndApplyProvidersSnapshot({ + client, + queryClient, + serverId, + cwd, + providers, + }); }, }); const { mutateAsync: refreshSnapshot, isPending: isRefreshing } = refreshMutation; @@ -79,46 +160,27 @@ export function useProvidersSnapshot( if (message.type !== "providers_snapshot_update") { return; } - const updateQueryKey = providersSnapshotQueryKey(serverId, message.payload.cwd); - queryClient.setQueryData(updateQueryKey, { - entries: message.payload.entries, - generatedAt: message.payload.generatedAt, - requestId: "providers_snapshot_update", - }); + applyProvidersSnapshotUpdate({ serverId, queryClient, message }); }); }, [client, enabled, isConnected, queryClient, serverId, supportsSnapshot]); const refresh = useCallback( async (providers?: AgentProvider[]) => { - if (!client) { - return; - } await refreshSnapshot(providers); - const snapshot = await client.getProvidersSnapshot(requestOptions); - queryClient.setQueryData(queryKey, snapshot); - if (isProvidersSnapshotHomeScope(cwd)) { - void queryClient.invalidateQueries({ queryKey: queryRoot, exact: false }); - } }, - [client, cwd, queryClient, queryKey, queryRoot, refreshSnapshot, requestOptions], + [refreshSnapshot], ); const refetchIfStale = useCallback( (selectedProvider?: AgentProvider | null) => { - if (!selectedProvider) { - void queryClient.refetchQueries({ queryKey, type: "active", stale: true }); - return; - } - - const selectedEntry = snapshotQuery.data?.entries.find( - (entry) => entry.provider === selectedProvider, - ); - - if (!selectedEntry || selectedEntry.status === "loading") { + const decision = selectorOpenRefetchDecision({ + entries: snapshotQuery.data?.entries, + selectedProvider, + }); + if (decision === "refetch-always") { void queryClient.refetchQueries({ queryKey, type: "active" }); return; } - void queryClient.refetchQueries({ queryKey, type: "active", stale: true }); }, [queryClient, queryKey, snapshotQuery.data?.entries], @@ -143,10 +205,9 @@ export function prefetchProvidersSnapshot( ): void { const cwd = normalizeProvidersSnapshotCwd(options.cwd); const queryKey = providersSnapshotQueryKey(serverId, cwd); - const requestOptions = providersSnapshotRequestOptions({ cwd }); void singletonQueryClient.prefetchQuery({ queryKey, staleTime: 60_000, - queryFn: () => client.getProvidersSnapshot(requestOptions), + queryFn: () => fetchProvidersSnapshot({ client, cwd }), }); } diff --git a/packages/app/src/hooks/use-settings.test.ts b/packages/app/src/hooks/use-settings.test.ts deleted file mode 100644 index cef8146dc..000000000 --- a/packages/app/src/hooks/use-settings.test.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { QueryClient } from "@tanstack/react-query"; - -const asyncStorageMock = vi.hoisted(() => ({ - getItem: vi.fn<(_: string) => Promise>(), - setItem: vi.fn<(_: string, __: string) => Promise>(), -})); - -const electronRuntimeState = vi.hoisted(() => ({ - isElectron: false, -})); - -const desktopSettingsMock = vi.hoisted(() => ({ - loadDesktopSettings: vi.fn<() => Promise>(), - migrateLegacyDesktopSettings: vi.fn<(_: unknown) => Promise>(), -})); - -vi.mock("@react-native-async-storage/async-storage", () => ({ - default: asyncStorageMock, -})); - -vi.mock("@/desktop/host", () => ({ - isElectronRuntime: () => electronRuntimeState.isElectron, -})); - -vi.mock("@/desktop/settings/desktop-settings", () => desktopSettingsMock); - -describe("use-settings", () => { - beforeEach(() => { - vi.resetModules(); - asyncStorageMock.getItem.mockReset(); - asyncStorageMock.setItem.mockReset(); - electronRuntimeState.isElectron = false; - desktopSettingsMock.loadDesktopSettings.mockReset(); - desktopSettingsMock.migrateLegacyDesktopSettings.mockReset(); - }); - - it("defaults built-in daemon management to enabled when storage is empty", async () => { - asyncStorageMock.getItem.mockResolvedValue(null); - asyncStorageMock.setItem.mockResolvedValue(); - - const mod = await import("./use-settings"); - const result = await mod.loadSettingsFromStorage(); - - expect(result).toEqual(mod.DEFAULT_APP_SETTINGS); - expect(asyncStorageMock.setItem).toHaveBeenCalledWith( - mod.APP_SETTINGS_KEY, - JSON.stringify(mod.DEFAULT_CLIENT_SETTINGS), - ); - }); - - it("defaults theme to auto when storage is empty", async () => { - asyncStorageMock.getItem.mockResolvedValue(null); - asyncStorageMock.setItem.mockResolvedValue(); - - const mod = await import("./use-settings"); - const result = await mod.loadSettingsFromStorage(); - - expect(result.theme).toBe("auto"); - }); - - it("defaults release channel to stable when storage is empty", async () => { - asyncStorageMock.getItem.mockResolvedValue(null); - asyncStorageMock.setItem.mockResolvedValue(); - - const mod = await import("./use-settings"); - const result = await mod.loadSettingsFromStorage(); - - expect(result.releaseChannel).toBe("stable"); - }); - - it("ignores renderer-owned daemon management state outside Electron", async () => { - asyncStorageMock.getItem.mockImplementation(async (key: string) => { - if (key === "@paseo:app-settings") { - return JSON.stringify({ - theme: "light", - manageBuiltInDaemon: false, - }); - } - return null; - }); - - const mod = await import("./use-settings"); - const result = await mod.loadSettingsFromStorage(); - - expect(result).toEqual({ - theme: "light", - manageBuiltInDaemon: true, - sendBehavior: "interrupt", - serviceUrlBehavior: "ask", - terminalScrollbackLines: 10_000, - releaseChannel: "stable", - }); - }); - - it("ignores renderer-owned release channel outside Electron", async () => { - asyncStorageMock.getItem.mockImplementation(async (key: string) => { - if (key === "@paseo:app-settings") { - return JSON.stringify({ - releaseChannel: "beta", - }); - } - return null; - }); - - const mod = await import("./use-settings"); - const result = await mod.loadSettingsFromStorage(); - - expect(result.releaseChannel).toBe("stable"); - }); - - it("keeps legacy AsyncStorage migration for client settings only", async () => { - asyncStorageMock.getItem.mockImplementation(async (key: string) => { - if (key === "@paseo:app-settings") { - return null; - } - if (key === "@paseo:settings") { - return JSON.stringify({ - theme: "dark", - manageBuiltInDaemon: false, - releaseChannel: "beta", - }); - } - return null; - }); - asyncStorageMock.setItem.mockResolvedValue(); - - const mod = await import("./use-settings"); - const result = await mod.loadAppSettingsFromStorage(); - - expect(result).toEqual({ - theme: "dark", - sendBehavior: "interrupt", - serviceUrlBehavior: "ask", - terminalScrollbackLines: 10_000, - }); - expect(asyncStorageMock.setItem).toHaveBeenCalledWith( - mod.APP_SETTINGS_KEY, - JSON.stringify(result), - ); - }); - - it("migrates legacy desktop-owned settings through Electron before reading effective settings", async () => { - electronRuntimeState.isElectron = true; - asyncStorageMock.getItem.mockImplementation(async (key: string) => { - if (key === "@paseo:app-settings") { - return JSON.stringify({ - theme: "light", - manageBuiltInDaemon: false, - releaseChannel: "beta", - }); - } - return null; - }); - desktopSettingsMock.migrateLegacyDesktopSettings.mockResolvedValue(); - desktopSettingsMock.loadDesktopSettings.mockResolvedValue({ - releaseChannel: "beta", - daemon: { - manageBuiltInDaemon: false, - keepRunningAfterQuit: true, - }, - }); - - const mod = await import("./use-settings"); - const result = await mod.loadSettingsFromStorage(); - - expect(desktopSettingsMock.migrateLegacyDesktopSettings).toHaveBeenCalledWith({ - manageBuiltInDaemon: false, - releaseChannel: "beta", - }); - expect(result).toEqual({ - theme: "light", - sendBehavior: "interrupt", - serviceUrlBehavior: "ask", - terminalScrollbackLines: 10_000, - manageBuiltInDaemon: false, - releaseChannel: "beta", - }); - }); - - it("skips desktop IPC when loading effective settings outside Electron", async () => { - asyncStorageMock.getItem.mockResolvedValue( - JSON.stringify({ - theme: "light", - }), - ); - - const mod = await import("./use-settings"); - const result = await mod.loadSettingsFromStorage(); - - expect(result).toEqual({ - theme: "light", - sendBehavior: "interrupt", - serviceUrlBehavior: "ask", - terminalScrollbackLines: 10_000, - manageBuiltInDaemon: true, - releaseChannel: "stable", - }); - }); - - it("loads configured terminal scrollback lines from app settings", async () => { - asyncStorageMock.getItem.mockResolvedValue( - JSON.stringify({ - terminalScrollbackLines: 42_000, - }), - ); - - const mod = await import("./use-settings"); - const result = await mod.loadAppSettingsFromStorage(); - - expect(result.terminalScrollbackLines).toBe(42_000); - }); - - it("saves terminal scrollback through app settings persistence", async () => { - asyncStorageMock.setItem.mockResolvedValue(); - - const mod = await import("./use-settings"); - asyncStorageMock.getItem.mockResolvedValue(JSON.stringify(mod.DEFAULT_CLIENT_SETTINGS)); - const queryClient = new QueryClient(); - - await mod.saveAppSettings({ - queryClient, - updates: { terminalScrollbackLines: 42_000 }, - }); - - expect(asyncStorageMock.setItem).toHaveBeenLastCalledWith( - mod.APP_SETTINGS_KEY, - JSON.stringify({ - ...mod.DEFAULT_CLIENT_SETTINGS, - terminalScrollbackLines: 42_000, - }), - ); - }); - - it("normalizes terminal scrollback lines from storage", async () => { - asyncStorageMock.getItem.mockResolvedValue( - JSON.stringify({ - terminalScrollbackLines: 1_000_000.9, - }), - ); - - const mod = await import("./use-settings"); - const result = await mod.loadAppSettingsFromStorage(); - - expect(result.terminalScrollbackLines).toBe(1_000_000); - expect(mod.parseTerminalScrollbackLines("-10")).toBe(0); - expect(mod.parseTerminalScrollbackLines("abc")).toBeNull(); - }); -}); diff --git a/packages/app/src/hooks/use-settings.ts b/packages/app/src/hooks/use-settings.ts deleted file mode 100644 index ecc3a8d78..000000000 --- a/packages/app/src/hooks/use-settings.ts +++ /dev/null @@ -1,325 +0,0 @@ -import { useCallback } from "react"; -import AsyncStorage from "@react-native-async-storage/async-storage"; -import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query"; -import { queryClient as appQueryClient } from "@/query/query-client"; -import { - DEFAULT_DESKTOP_SETTINGS, - loadDesktopSettings, - migrateLegacyDesktopSettings, - useDesktopSettings, -} from "@/desktop/settings/desktop-settings"; -import { isElectronRuntime } from "@/desktop/host"; -import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme"; - -export const APP_SETTINGS_KEY = "@paseo:app-settings"; -const LEGACY_SETTINGS_KEY = "@paseo:settings"; -const APP_SETTINGS_QUERY_KEY = ["app-settings"]; - -export type SendBehavior = "interrupt" | "queue"; -export type ReleaseChannel = "stable" | "beta"; -export type ServiceUrlBehavior = "ask" | "in-app" | "external"; - -const VALID_THEMES = new Set([...Object.keys(THEME_TO_UNISTYLES), "auto"]); -const VALID_SERVICE_URL_BEHAVIORS = new Set(["ask", "in-app", "external"]); -export const DEFAULT_TERMINAL_SCROLLBACK_LINES = 10_000; -export const MIN_TERMINAL_SCROLLBACK_LINES = 0; -export const MAX_TERMINAL_SCROLLBACK_LINES = 1_000_000; - -export interface AppSettings { - theme: ThemeName | "auto"; - sendBehavior: SendBehavior; - serviceUrlBehavior: ServiceUrlBehavior; - terminalScrollbackLines: number; -} - -export interface Settings extends AppSettings { - manageBuiltInDaemon: boolean; - releaseChannel: ReleaseChannel; -} - -export const DEFAULT_CLIENT_SETTINGS: AppSettings = { - theme: "auto", - sendBehavior: "interrupt", - serviceUrlBehavior: "ask", - terminalScrollbackLines: DEFAULT_TERMINAL_SCROLLBACK_LINES, -}; - -export const DEFAULT_APP_SETTINGS: Settings = { - ...DEFAULT_CLIENT_SETTINGS, - manageBuiltInDaemon: true, - releaseChannel: "stable", -}; - -export interface UseAppSettingsReturn { - settings: AppSettings; - isLoading: boolean; - error: unknown; - updateSettings: (updates: Partial) => Promise; - resetSettings: () => Promise; -} - -export interface UseSettingsReturn { - settings: Settings; - isLoading: boolean; - error: unknown; - updateSettings: (updates: Partial) => Promise; - resetSettings: () => Promise; -} - -export function useAppSettings(): UseAppSettingsReturn { - const queryClient = useQueryClient(); - const { data, isPending, error } = useQuery({ - queryKey: APP_SETTINGS_QUERY_KEY, - queryFn: loadAppSettingsFromStorage, - staleTime: Infinity, - gcTime: Infinity, - }); - - const updateSettings = useCallback( - async (updates: Partial) => { - try { - await saveAppSettings({ queryClient, updates }); - } catch (err) { - console.error("[AppSettings] Failed to save settings:", err); - throw err; - } - }, - [queryClient], - ); - - const resetSettings = useCallback(async () => { - try { - const next = { ...DEFAULT_CLIENT_SETTINGS }; - queryClient.setQueryData(APP_SETTINGS_QUERY_KEY, next); - await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(next)); - } catch (err) { - console.error("[AppSettings] Failed to reset settings:", err); - throw err; - } - }, [queryClient]); - - return { - settings: data ?? DEFAULT_CLIENT_SETTINGS, - isLoading: isPending, - error: error ?? null, - updateSettings, - resetSettings, - }; -} - -export function useSettings(): UseSettingsReturn { - const appSettings = useAppSettings(); - const desktopSettings = useDesktopSettings(); - - const updateSettings = useCallback( - async (updates: Partial) => { - const appUpdates: Partial = {}; - if (updates.theme !== undefined) { - appUpdates.theme = updates.theme; - } - if (updates.sendBehavior !== undefined) { - appUpdates.sendBehavior = updates.sendBehavior; - } - if (updates.serviceUrlBehavior !== undefined) { - appUpdates.serviceUrlBehavior = updates.serviceUrlBehavior; - } - if (updates.terminalScrollbackLines !== undefined) { - appUpdates.terminalScrollbackLines = updates.terminalScrollbackLines; - } - const promises: Promise[] = []; - if (Object.keys(appUpdates).length > 0) { - promises.push(appSettings.updateSettings(appUpdates)); - } - - if (isElectronRuntime()) { - const desktopUpdates: Parameters[0] = {}; - if (updates.manageBuiltInDaemon !== undefined) { - desktopUpdates.daemon = { - manageBuiltInDaemon: updates.manageBuiltInDaemon, - }; - } - if (updates.releaseChannel !== undefined) { - desktopUpdates.releaseChannel = updates.releaseChannel; - } - if (Object.keys(desktopUpdates).length > 0) { - promises.push(desktopSettings.updateSettings(desktopUpdates)); - } - } - - await Promise.all(promises); - }, - [appSettings, desktopSettings], - ); - - const resetSettings = useCallback(async () => { - const resets: Promise[] = [appSettings.resetSettings()]; - if (isElectronRuntime()) { - resets.push(desktopSettings.updateSettings(DEFAULT_DESKTOP_SETTINGS)); - } - await Promise.all(resets); - }, [appSettings, desktopSettings]); - - return { - settings: { - ...DEFAULT_APP_SETTINGS, - ...appSettings.settings, - manageBuiltInDaemon: desktopSettings.settings.daemon.manageBuiltInDaemon, - releaseChannel: desktopSettings.settings.releaseChannel, - }, - isLoading: appSettings.isLoading || desktopSettings.isLoading, - error: appSettings.error ?? desktopSettings.error, - updateSettings, - resetSettings, - }; -} - -export async function persistAppSettings(updates: Partial): Promise { - await saveAppSettings({ queryClient: appQueryClient, updates }); -} - -export async function saveAppSettings(input: { - queryClient: QueryClient; - updates: Partial; -}): Promise { - const current = - input.queryClient.getQueryData(APP_SETTINGS_QUERY_KEY) ?? - (await loadAppSettingsFromStorage()); - const next = { ...current, ...input.updates }; - input.queryClient.setQueryData(APP_SETTINGS_QUERY_KEY, next); - await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(next)); -} - -export async function loadAppSettingsFromStorage(): Promise { - try { - const stored = await AsyncStorage.getItem(APP_SETTINGS_KEY); - if (stored) { - const parsed = JSON.parse(stored) as Partial; - return { ...DEFAULT_CLIENT_SETTINGS, ...pickAppSettings(parsed) }; - } - - const legacyStored = await AsyncStorage.getItem(LEGACY_SETTINGS_KEY); - if (legacyStored) { - const legacyParsed = JSON.parse(legacyStored) as Record; - const next = { - ...DEFAULT_CLIENT_SETTINGS, - ...pickAppSettingsFromLegacy(legacyParsed), - } satisfies AppSettings; - await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(next)); - return next; - } - - await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(DEFAULT_CLIENT_SETTINGS)); - return DEFAULT_CLIENT_SETTINGS; - } catch (error) { - console.error("[AppSettings] Failed to load settings:", error); - throw error; - } -} - -export async function loadSettingsFromStorage(): Promise { - const legacyDesktopSettings = isElectronRuntime() - ? await loadLegacyDesktopSettingsFromStorage() - : null; - const appSettings = await loadAppSettingsFromStorage(); - - if (!isElectronRuntime()) { - return { - ...DEFAULT_APP_SETTINGS, - ...appSettings, - }; - } - - if (legacyDesktopSettings) { - await migrateLegacyDesktopSettings(legacyDesktopSettings); - } - - const desktopSettings = await loadDesktopSettings(); - return { - ...DEFAULT_APP_SETTINGS, - ...appSettings, - manageBuiltInDaemon: desktopSettings.daemon.manageBuiltInDaemon, - releaseChannel: desktopSettings.releaseChannel, - }; -} - -function pickAppSettings(stored: Partial): Partial { - const result: Partial = {}; - if (typeof stored.theme === "string" && VALID_THEMES.has(stored.theme)) { - result.theme = stored.theme; - } - if (stored.sendBehavior === "interrupt" || stored.sendBehavior === "queue") { - result.sendBehavior = stored.sendBehavior; - } - if ( - typeof stored.serviceUrlBehavior === "string" && - VALID_SERVICE_URL_BEHAVIORS.has(stored.serviceUrlBehavior) - ) { - result.serviceUrlBehavior = stored.serviceUrlBehavior; - } - const terminalScrollbackLines = parseTerminalScrollbackLines(stored.terminalScrollbackLines); - if (terminalScrollbackLines !== null) { - result.terminalScrollbackLines = terminalScrollbackLines; - } - return result; -} - -function pickAppSettingsFromLegacy(legacy: Record): Partial { - const result: Partial = {}; - if (legacy.theme === "dark" || legacy.theme === "light" || legacy.theme === "auto") { - result.theme = legacy.theme; - } - return result; -} - -export function parseTerminalScrollbackLines(value: unknown): number | null { - let numericValue = NaN; - if (typeof value === "number") { - numericValue = value; - } else if (typeof value === "string" && value.trim().length > 0) { - numericValue = Number(value); - } - if (!Number.isFinite(numericValue)) { - return null; - } - return Math.min( - MAX_TERMINAL_SCROLLBACK_LINES, - Math.max(MIN_TERMINAL_SCROLLBACK_LINES, Math.floor(numericValue)), - ); -} - -async function loadLegacyDesktopSettingsFromStorage(): Promise<{ - manageBuiltInDaemon?: boolean; - releaseChannel?: ReleaseChannel; -} | null> { - const stored = await loadRendererSettingsPayload(); - if (!stored) { - return null; - } - - const result: { - manageBuiltInDaemon?: boolean; - releaseChannel?: ReleaseChannel; - } = {}; - - if (typeof stored.manageBuiltInDaemon === "boolean") { - result.manageBuiltInDaemon = stored.manageBuiltInDaemon; - } - if (stored.releaseChannel === "stable" || stored.releaseChannel === "beta") { - result.releaseChannel = stored.releaseChannel; - } - - return Object.keys(result).length > 0 ? result : null; -} - -async function loadRendererSettingsPayload(): Promise | null> { - const current = await AsyncStorage.getItem(APP_SETTINGS_KEY); - if (current) { - return JSON.parse(current) as Record; - } - - const legacy = await AsyncStorage.getItem(LEGACY_SETTINGS_KEY); - if (!legacy) { - return null; - } - return JSON.parse(legacy) as Record; -} diff --git a/packages/app/src/hooks/use-settings/fakes.ts b/packages/app/src/hooks/use-settings/fakes.ts new file mode 100644 index 000000000..95d46ad94 --- /dev/null +++ b/packages/app/src/hooks/use-settings/fakes.ts @@ -0,0 +1,57 @@ +import type { DesktopSettings } from "@/desktop/settings/desktop-settings"; +import type { DesktopSettingsBridge, KeyValueStorage, ReleaseChannel } from "./storage"; + +export interface InMemoryKeyValueStorage extends KeyValueStorage { + readonly entries: Map; +} + +export function createInMemoryKeyValueStorage( + initial: Record = {}, +): InMemoryKeyValueStorage { + const entries = new Map(Object.entries(initial)); + return { + entries, + async getItem(key) { + return entries.get(key) ?? null; + }, + async setItem(key, value) { + entries.set(key, value); + }, + }; +} + +export interface FakeDesktopBridge extends DesktopSettingsBridge { + readonly migrationsApplied: Array<{ + manageBuiltInDaemon?: boolean; + releaseChannel?: ReleaseChannel; + }>; +} + +const DEFAULT_DESKTOP: DesktopSettings = { + releaseChannel: "stable", + daemon: { + manageBuiltInDaemon: true, + keepRunningAfterQuit: true, + }, +}; + +export function createFakeDesktopBridge( + options: { + isElectron?: boolean; + settings?: DesktopSettings; + } = {}, +): FakeDesktopBridge { + const isElectron = options.isElectron ?? false; + const settings = options.settings ?? DEFAULT_DESKTOP; + const migrationsApplied: FakeDesktopBridge["migrationsApplied"] = []; + return { + migrationsApplied, + isElectron: () => isElectron, + async loadDesktopSettings() { + return settings; + }, + async migrateLegacyDesktopSettings(input) { + migrationsApplied.push(input); + }, + }; +} diff --git a/packages/app/src/hooks/use-settings/index.ts b/packages/app/src/hooks/use-settings/index.ts new file mode 100644 index 000000000..f58c44483 --- /dev/null +++ b/packages/app/src/hooks/use-settings/index.ts @@ -0,0 +1,208 @@ +import { useCallback } from "react"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query"; +import { queryClient as appQueryClient } from "@/query/query-client"; +import { + DEFAULT_DESKTOP_SETTINGS, + loadDesktopSettings, + migrateLegacyDesktopSettings, + useDesktopSettings, +} from "@/desktop/settings/desktop-settings"; +import { isElectronRuntime } from "@/desktop/host"; +import { + APP_SETTINGS_KEY, + APP_SETTINGS_QUERY_KEY, + DEFAULT_APP_SETTINGS, + DEFAULT_CLIENT_SETTINGS, + DEFAULT_TERMINAL_SCROLLBACK_LINES, + MAX_TERMINAL_SCROLLBACK_LINES, + MIN_TERMINAL_SCROLLBACK_LINES, + loadAppSettingsFromStorage as loadAppSettingsFromStoragePure, + loadSettingsFromStorage as loadSettingsFromStoragePure, + parseTerminalScrollbackLines, + saveAppSettings as saveAppSettingsPure, + type AppSettings, + type DesktopSettingsBridge, + type KeyValueStorage, + type ReleaseChannel, + type SendBehavior, + type ServiceUrlBehavior, + type Settings, + type SettingsDeps, +} from "./storage"; + +export { + APP_SETTINGS_KEY, + DEFAULT_APP_SETTINGS, + DEFAULT_CLIENT_SETTINGS, + DEFAULT_TERMINAL_SCROLLBACK_LINES, + MAX_TERMINAL_SCROLLBACK_LINES, + MIN_TERMINAL_SCROLLBACK_LINES, + parseTerminalScrollbackLines, +}; +export type { + AppSettings, + DesktopSettingsBridge, + KeyValueStorage, + ReleaseChannel, + SendBehavior, + ServiceUrlBehavior, + Settings, + SettingsDeps, +}; + +const productionDeps: SettingsDeps = { + storage: AsyncStorage, + desktop: { + isElectron: isElectronRuntime, + loadDesktopSettings, + migrateLegacyDesktopSettings, + }, +}; + +export interface UseAppSettingsReturn { + settings: AppSettings; + isLoading: boolean; + error: unknown; + updateSettings: (updates: Partial) => Promise; + resetSettings: () => Promise; +} + +export interface UseSettingsReturn { + settings: Settings; + isLoading: boolean; + error: unknown; + updateSettings: (updates: Partial) => Promise; + resetSettings: () => Promise; +} + +export function useAppSettings(): UseAppSettingsReturn { + const queryClient = useQueryClient(); + const { data, isPending, error } = useQuery({ + queryKey: APP_SETTINGS_QUERY_KEY, + queryFn: () => loadAppSettingsFromStorage(), + staleTime: Infinity, + gcTime: Infinity, + }); + + const updateSettings = useCallback( + async (updates: Partial) => { + try { + await saveAppSettings({ queryClient, updates }); + } catch (err) { + console.error("[AppSettings] Failed to save settings:", err); + throw err; + } + }, + [queryClient], + ); + + const resetSettings = useCallback(async () => { + try { + const next = { ...DEFAULT_CLIENT_SETTINGS }; + queryClient.setQueryData(APP_SETTINGS_QUERY_KEY, next); + await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(next)); + } catch (err) { + console.error("[AppSettings] Failed to reset settings:", err); + throw err; + } + }, [queryClient]); + + return { + settings: data ?? DEFAULT_CLIENT_SETTINGS, + isLoading: isPending, + error: error ?? null, + updateSettings, + resetSettings, + }; +} + +export function useSettings(): UseSettingsReturn { + const appSettings = useAppSettings(); + const desktopSettings = useDesktopSettings(); + + const updateSettings = useCallback( + async (updates: Partial) => { + const appUpdates: Partial = {}; + if (updates.theme !== undefined) { + appUpdates.theme = updates.theme; + } + if (updates.sendBehavior !== undefined) { + appUpdates.sendBehavior = updates.sendBehavior; + } + if (updates.serviceUrlBehavior !== undefined) { + appUpdates.serviceUrlBehavior = updates.serviceUrlBehavior; + } + if (updates.terminalScrollbackLines !== undefined) { + appUpdates.terminalScrollbackLines = updates.terminalScrollbackLines; + } + const promises: Promise[] = []; + if (Object.keys(appUpdates).length > 0) { + promises.push(appSettings.updateSettings(appUpdates)); + } + + if (isElectronRuntime()) { + const desktopUpdates: Parameters[0] = {}; + if (updates.manageBuiltInDaemon !== undefined) { + desktopUpdates.daemon = { + manageBuiltInDaemon: updates.manageBuiltInDaemon, + }; + } + if (updates.releaseChannel !== undefined) { + desktopUpdates.releaseChannel = updates.releaseChannel; + } + if (Object.keys(desktopUpdates).length > 0) { + promises.push(desktopSettings.updateSettings(desktopUpdates)); + } + } + + await Promise.all(promises); + }, + [appSettings, desktopSettings], + ); + + const resetSettings = useCallback(async () => { + const resets: Promise[] = [appSettings.resetSettings()]; + if (isElectronRuntime()) { + resets.push(desktopSettings.updateSettings(DEFAULT_DESKTOP_SETTINGS)); + } + await Promise.all(resets); + }, [appSettings, desktopSettings]); + + return { + settings: { + ...DEFAULT_APP_SETTINGS, + ...appSettings.settings, + manageBuiltInDaemon: desktopSettings.settings.daemon.manageBuiltInDaemon, + releaseChannel: desktopSettings.settings.releaseChannel, + }, + isLoading: appSettings.isLoading || desktopSettings.isLoading, + error: appSettings.error ?? desktopSettings.error, + updateSettings, + resetSettings, + }; +} + +export async function persistAppSettings(updates: Partial): Promise { + await saveAppSettings({ queryClient: appQueryClient, updates }); +} + +export async function saveAppSettings(input: { + queryClient: QueryClient; + updates: Partial; + deps?: SettingsDeps; +}): Promise { + await saveAppSettingsPure({ + queryClient: input.queryClient, + updates: input.updates, + deps: input.deps ?? productionDeps, + }); +} + +export async function loadAppSettingsFromStorage(deps?: SettingsDeps): Promise { + return loadAppSettingsFromStoragePure(deps ?? productionDeps); +} + +export async function loadSettingsFromStorage(deps?: SettingsDeps): Promise { + return loadSettingsFromStoragePure(deps ?? productionDeps); +} diff --git a/packages/app/src/hooks/use-settings/storage.test.ts b/packages/app/src/hooks/use-settings/storage.test.ts new file mode 100644 index 000000000..9bd2a2904 --- /dev/null +++ b/packages/app/src/hooks/use-settings/storage.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, it } from "vitest"; +import { QueryClient } from "@tanstack/react-query"; +import { + APP_SETTINGS_KEY, + DEFAULT_APP_SETTINGS, + DEFAULT_CLIENT_SETTINGS, + loadAppSettingsFromStorage, + loadSettingsFromStorage, + parseTerminalScrollbackLines, + saveAppSettings, + type SettingsDeps, +} from "./storage"; +import { createFakeDesktopBridge, createInMemoryKeyValueStorage } from "./fakes"; + +const LEGACY_SETTINGS_KEY = "@paseo:settings"; + +function makeDeps( + overrides: { + storage?: ReturnType; + desktop?: ReturnType; + } = {}, +): SettingsDeps & { + storage: ReturnType; + desktop: ReturnType; +} { + return { + storage: overrides.storage ?? createInMemoryKeyValueStorage(), + desktop: overrides.desktop ?? createFakeDesktopBridge(), + }; +} + +describe("loadAppSettingsFromStorage", () => { + it("defaults theme to auto when storage is empty", async () => { + const deps = makeDeps(); + + const result = await loadAppSettingsFromStorage(deps); + + expect(result.theme).toBe("auto"); + }); + + it("seeds storage with the client defaults when nothing is persisted", async () => { + const deps = makeDeps(); + + const result = await loadAppSettingsFromStorage(deps); + + expect(result).toEqual(DEFAULT_CLIENT_SETTINGS); + expect(deps.storage.entries.get(APP_SETTINGS_KEY)).toBe( + JSON.stringify(DEFAULT_CLIENT_SETTINGS), + ); + }); + + it("loads configured terminal scrollback lines from app settings", async () => { + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ terminalScrollbackLines: 42_000 }), + }), + }); + + const result = await loadAppSettingsFromStorage(deps); + + expect(result.terminalScrollbackLines).toBe(42_000); + }); + + it("normalizes terminal scrollback lines from storage", async () => { + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ terminalScrollbackLines: 1_000_000.9 }), + }), + }); + + const result = await loadAppSettingsFromStorage(deps); + + expect(result.terminalScrollbackLines).toBe(1_000_000); + }); + + it("migrates the legacy theme key into the new settings object", async () => { + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [LEGACY_SETTINGS_KEY]: JSON.stringify({ + theme: "dark", + manageBuiltInDaemon: false, + releaseChannel: "beta", + }), + }), + }); + + const result = await loadAppSettingsFromStorage(deps); + + expect(result).toEqual({ + theme: "dark", + sendBehavior: "interrupt", + serviceUrlBehavior: "ask", + terminalScrollbackLines: 10_000, + }); + expect(deps.storage.entries.get(APP_SETTINGS_KEY)).toBe(JSON.stringify(result)); + }); +}); + +describe("loadSettingsFromStorage", () => { + it("defaults built-in daemon management to enabled when storage is empty", async () => { + const deps = makeDeps(); + + const result = await loadSettingsFromStorage(deps); + + expect(result).toEqual(DEFAULT_APP_SETTINGS); + }); + + it("defaults release channel to stable when storage is empty", async () => { + const deps = makeDeps(); + + const result = await loadSettingsFromStorage(deps); + + expect(result.releaseChannel).toBe("stable"); + }); + + it("ignores renderer-owned daemon management state outside Electron", async () => { + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ + theme: "light", + manageBuiltInDaemon: false, + }), + }), + }); + + const result = await loadSettingsFromStorage(deps); + + expect(result).toEqual({ + theme: "light", + manageBuiltInDaemon: true, + sendBehavior: "interrupt", + serviceUrlBehavior: "ask", + terminalScrollbackLines: 10_000, + releaseChannel: "stable", + }); + }); + + it("ignores renderer-owned release channel outside Electron", async () => { + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ releaseChannel: "beta" }), + }), + }); + + const result = await loadSettingsFromStorage(deps); + + expect(result.releaseChannel).toBe("stable"); + }); + + it("migrates legacy desktop-owned settings through the bridge before reading effective settings", async () => { + const desktop = createFakeDesktopBridge({ + isElectron: true, + settings: { + releaseChannel: "beta", + daemon: { manageBuiltInDaemon: false, keepRunningAfterQuit: true }, + }, + }); + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ + theme: "light", + manageBuiltInDaemon: false, + releaseChannel: "beta", + }), + }), + desktop, + }); + + const result = await loadSettingsFromStorage(deps); + + expect(desktop.migrationsApplied).toEqual([ + { manageBuiltInDaemon: false, releaseChannel: "beta" }, + ]); + expect(result).toEqual({ + theme: "light", + sendBehavior: "interrupt", + serviceUrlBehavior: "ask", + terminalScrollbackLines: 10_000, + manageBuiltInDaemon: false, + releaseChannel: "beta", + }); + }); + + it("does not call the desktop bridge outside Electron", async () => { + const desktop = createFakeDesktopBridge({ isElectron: false }); + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ theme: "light" }), + }), + desktop, + }); + + const result = await loadSettingsFromStorage(deps); + + expect(desktop.migrationsApplied).toEqual([]); + expect(result).toEqual({ + theme: "light", + sendBehavior: "interrupt", + serviceUrlBehavior: "ask", + terminalScrollbackLines: 10_000, + manageBuiltInDaemon: true, + releaseChannel: "stable", + }); + }); +}); + +describe("saveAppSettings", () => { + it("saves terminal scrollback through app settings persistence", async () => { + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify(DEFAULT_CLIENT_SETTINGS), + }), + }); + const queryClient = new QueryClient(); + + await saveAppSettings({ + queryClient, + updates: { terminalScrollbackLines: 42_000 }, + deps, + }); + + expect(deps.storage.entries.get(APP_SETTINGS_KEY)).toBe( + JSON.stringify({ + ...DEFAULT_CLIENT_SETTINGS, + terminalScrollbackLines: 42_000, + }), + ); + }); +}); + +describe("parseTerminalScrollbackLines", () => { + it("clamps negative values to the minimum and rejects non-numeric strings", () => { + expect(parseTerminalScrollbackLines("-10")).toBe(0); + expect(parseTerminalScrollbackLines("abc")).toBeNull(); + }); +}); diff --git a/packages/app/src/hooks/use-settings/storage.ts b/packages/app/src/hooks/use-settings/storage.ts new file mode 100644 index 000000000..86ea66ba9 --- /dev/null +++ b/packages/app/src/hooks/use-settings/storage.ts @@ -0,0 +1,211 @@ +import type { QueryClient } from "@tanstack/react-query"; +import type { DesktopSettings } from "@/desktop/settings/desktop-settings"; +import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme"; + +export const APP_SETTINGS_KEY = "@paseo:app-settings"; +export const APP_SETTINGS_QUERY_KEY = ["app-settings"]; +const LEGACY_SETTINGS_KEY = "@paseo:settings"; + +export type SendBehavior = "interrupt" | "queue"; +export type ReleaseChannel = "stable" | "beta"; +export type ServiceUrlBehavior = "ask" | "in-app" | "external"; + +const VALID_THEMES = new Set([...Object.keys(THEME_TO_UNISTYLES), "auto"]); +const VALID_SERVICE_URL_BEHAVIORS = new Set(["ask", "in-app", "external"]); +export const DEFAULT_TERMINAL_SCROLLBACK_LINES = 10_000; +export const MIN_TERMINAL_SCROLLBACK_LINES = 0; +export const MAX_TERMINAL_SCROLLBACK_LINES = 1_000_000; + +export interface AppSettings { + theme: ThemeName | "auto"; + sendBehavior: SendBehavior; + serviceUrlBehavior: ServiceUrlBehavior; + terminalScrollbackLines: number; +} + +export interface Settings extends AppSettings { + manageBuiltInDaemon: boolean; + releaseChannel: ReleaseChannel; +} + +export const DEFAULT_CLIENT_SETTINGS: AppSettings = { + theme: "auto", + sendBehavior: "interrupt", + serviceUrlBehavior: "ask", + terminalScrollbackLines: DEFAULT_TERMINAL_SCROLLBACK_LINES, +}; + +export const DEFAULT_APP_SETTINGS: Settings = { + ...DEFAULT_CLIENT_SETTINGS, + manageBuiltInDaemon: true, + releaseChannel: "stable", +}; + +export interface KeyValueStorage { + getItem(key: string): Promise; + setItem(key: string, value: string): Promise; +} + +export interface DesktopSettingsBridge { + isElectron(): boolean; + loadDesktopSettings(): Promise; + migrateLegacyDesktopSettings(input: { + manageBuiltInDaemon?: boolean; + releaseChannel?: ReleaseChannel; + }): Promise; +} + +export interface SettingsDeps { + storage: KeyValueStorage; + desktop: DesktopSettingsBridge; +} + +export async function saveAppSettings(input: { + queryClient: QueryClient; + updates: Partial; + deps: SettingsDeps; +}): Promise { + const current = + input.queryClient.getQueryData(APP_SETTINGS_QUERY_KEY) ?? + (await loadAppSettingsFromStorage(input.deps)); + const next = { ...current, ...input.updates }; + input.queryClient.setQueryData(APP_SETTINGS_QUERY_KEY, next); + await input.deps.storage.setItem(APP_SETTINGS_KEY, JSON.stringify(next)); +} + +export async function loadAppSettingsFromStorage(deps: SettingsDeps): Promise { + try { + const stored = await deps.storage.getItem(APP_SETTINGS_KEY); + if (stored) { + const parsed = JSON.parse(stored) as Partial; + return { ...DEFAULT_CLIENT_SETTINGS, ...pickAppSettings(parsed) }; + } + + const legacyStored = await deps.storage.getItem(LEGACY_SETTINGS_KEY); + if (legacyStored) { + const legacyParsed = JSON.parse(legacyStored) as Record; + const next = { + ...DEFAULT_CLIENT_SETTINGS, + ...pickAppSettingsFromLegacy(legacyParsed), + } satisfies AppSettings; + await deps.storage.setItem(APP_SETTINGS_KEY, JSON.stringify(next)); + return next; + } + + await deps.storage.setItem(APP_SETTINGS_KEY, JSON.stringify(DEFAULT_CLIENT_SETTINGS)); + return DEFAULT_CLIENT_SETTINGS; + } catch (error) { + console.error("[AppSettings] Failed to load settings:", error); + throw error; + } +} + +export async function loadSettingsFromStorage(deps: SettingsDeps): Promise { + const legacyDesktopSettings = deps.desktop.isElectron() + ? await loadLegacyDesktopSettingsFromStorage(deps.storage) + : null; + const appSettings = await loadAppSettingsFromStorage(deps); + + if (!deps.desktop.isElectron()) { + return { + ...DEFAULT_APP_SETTINGS, + ...appSettings, + }; + } + + if (legacyDesktopSettings) { + await deps.desktop.migrateLegacyDesktopSettings(legacyDesktopSettings); + } + + const desktopSettings = await deps.desktop.loadDesktopSettings(); + return { + ...DEFAULT_APP_SETTINGS, + ...appSettings, + manageBuiltInDaemon: desktopSettings.daemon.manageBuiltInDaemon, + releaseChannel: desktopSettings.releaseChannel, + }; +} + +function pickAppSettings(stored: Partial): Partial { + const result: Partial = {}; + if (typeof stored.theme === "string" && VALID_THEMES.has(stored.theme)) { + result.theme = stored.theme; + } + if (stored.sendBehavior === "interrupt" || stored.sendBehavior === "queue") { + result.sendBehavior = stored.sendBehavior; + } + if ( + typeof stored.serviceUrlBehavior === "string" && + VALID_SERVICE_URL_BEHAVIORS.has(stored.serviceUrlBehavior) + ) { + result.serviceUrlBehavior = stored.serviceUrlBehavior; + } + const terminalScrollbackLines = parseTerminalScrollbackLines(stored.terminalScrollbackLines); + if (terminalScrollbackLines !== null) { + result.terminalScrollbackLines = terminalScrollbackLines; + } + return result; +} + +function pickAppSettingsFromLegacy(legacy: Record): Partial { + const result: Partial = {}; + if (legacy.theme === "dark" || legacy.theme === "light" || legacy.theme === "auto") { + result.theme = legacy.theme; + } + return result; +} + +export function parseTerminalScrollbackLines(value: unknown): number | null { + let numericValue = NaN; + if (typeof value === "number") { + numericValue = value; + } else if (typeof value === "string" && value.trim().length > 0) { + numericValue = Number(value); + } + if (!Number.isFinite(numericValue)) { + return null; + } + return Math.min( + MAX_TERMINAL_SCROLLBACK_LINES, + Math.max(MIN_TERMINAL_SCROLLBACK_LINES, Math.floor(numericValue)), + ); +} + +async function loadLegacyDesktopSettingsFromStorage(storage: KeyValueStorage): Promise<{ + manageBuiltInDaemon?: boolean; + releaseChannel?: ReleaseChannel; +} | null> { + const stored = await loadRendererSettingsPayload(storage); + if (!stored) { + return null; + } + + const result: { + manageBuiltInDaemon?: boolean; + releaseChannel?: ReleaseChannel; + } = {}; + + if (typeof stored.manageBuiltInDaemon === "boolean") { + result.manageBuiltInDaemon = stored.manageBuiltInDaemon; + } + if (stored.releaseChannel === "stable" || stored.releaseChannel === "beta") { + result.releaseChannel = stored.releaseChannel; + } + + return Object.keys(result).length > 0 ? result : null; +} + +async function loadRendererSettingsPayload( + storage: KeyValueStorage, +): Promise | null> { + const current = await storage.getItem(APP_SETTINGS_KEY); + if (current) { + return JSON.parse(current) as Record; + } + + const legacy = await storage.getItem(LEGACY_SETTINGS_KEY); + if (!legacy) { + return null; + } + return JSON.parse(legacy) as Record; +} diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts deleted file mode 100644 index 3cda6be87..000000000 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts +++ /dev/null @@ -1,298 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import { act } from "@testing-library/react"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { createRoot, type Root } from "react-dom/client"; -import React from "react"; -import { afterEach, describe, expect, it, vi } from "vitest"; - -vi.hoisted(() => { - (globalThis as unknown as { __DEV__: boolean }).__DEV__ = false; -}); - -import { - appendMissingOrderKeys, - applyStoredOrdering, - buildSidebarProjectsFromStructure, - useSidebarWorkspacesList, -} from "./use-sidebar-workspaces-list"; -import type { WorkspaceStructureProject } from "@/stores/session-store-hooks"; -import { getHostRuntimeStore } from "@/runtime/host-runtime"; -import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; -import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; - -interface OrderedItem { - key: string; -} - -function item(key: string): OrderedItem { - return { key }; -} - -function project(input: { - projectKey: string; - projectName?: string; - projectKind?: WorkspaceStructureProject["projectKind"]; - iconWorkingDir?: string; - workspaceKeys: string[]; -}): WorkspaceStructureProject { - return { - projectKey: input.projectKey, - projectName: input.projectName ?? input.projectKey, - projectKind: input.projectKind ?? "git", - iconWorkingDir: input.iconWorkingDir ?? input.projectKey, - workspaceKeys: input.workspaceKeys, - }; -} - -function workspaceDescriptor(id: string): WorkspaceDescriptor { - return { - id, - projectId: "project-1", - projectDisplayName: "Project 1", - projectRootPath: "/repo/main", - workspaceDirectory: `/repo/main/${id}`, - projectKind: "git", - workspaceKind: "worktree", - name: id, - status: "done", - archivingAt: null, - diffStat: null, - scripts: [], - }; -} - -function DisabledHookProbe({ serverId }: { serverId: string }): null { - const result = useSidebarWorkspacesList({ serverId, enabled: false }); - - expect(result.projects).toEqual([]); - expect(result.isLoading).toBe(false); - expect(result.isInitialLoad).toBe(false); - expect(result.isRevalidating).toBe(false); - - return null; -} - -function DisabledRenderCountProbe({ - onRender, - serverId, -}: { - onRender: () => void; - serverId: string; -}): null { - useSidebarWorkspacesList({ serverId, enabled: false }); - onRender(); - return null; -} - -function HookResultProbe({ - onResult, - serverId, -}: { - onResult: (result: ReturnType) => void; - serverId: string; -}): null { - onResult(useSidebarWorkspacesList({ serverId })); - return null; -} - -describe("applyStoredOrdering", () => { - it("keeps unknown items on the baseline while applying stored order", () => { - const result = applyStoredOrdering({ - items: [item("new"), item("a"), item("b")], - storedOrder: ["b", "a"], - getKey: (entry) => entry.key, - }); - - expect(result.map((entry) => entry.key)).toEqual(["new", "b", "a"]); - }); - - it("ignores stale and duplicate stored keys", () => { - const result = applyStoredOrdering({ - items: [item("x"), item("y")], - storedOrder: ["missing", "y", "y", "x"], - getKey: (entry) => entry.key, - }); - - expect(result.map((entry) => entry.key)).toEqual(["y", "x"]); - }); - - it("returns baseline when there is no persisted order", () => { - const baseline = [item("first"), item("second")]; - const result = applyStoredOrdering({ - items: baseline, - storedOrder: [], - getKey: (entry) => entry.key, - }); - - expect(result).toBe(baseline); - }); -}); - -describe("appendMissingOrderKeys", () => { - it("appends unseen keys while preserving existing order", () => { - const result = appendMissingOrderKeys({ - currentOrder: ["project-b", "project-a"], - visibleKeys: ["project-a", "project-b", "project-c"], - }); - - expect(result).toEqual(["project-b", "project-a", "project-c"]); - }); - - it("returns the same array when there are no unseen keys", () => { - const currentOrder = ["project-a", "project-b"]; - - const result = appendMissingOrderKeys({ - currentOrder, - visibleKeys: ["project-b", "project-a"], - }); - - expect(result).toBe(currentOrder); - }); -}); - -describe("buildSidebarProjectsFromStructure", () => { - it("creates structural workspace rows from ordered workspace keys", () => { - const projects = buildSidebarProjectsFromStructure({ - serverId: "srv", - projects: [ - project({ - projectKey: "project-1", - projectName: "Project 1", - iconWorkingDir: "/repo/main", - workspaceKeys: ["ws-main"], - }), - ], - }); - - expect(projects).toHaveLength(1); - expect(projects[0]?.projectName).toBe("Project 1"); - expect(projects[0]?.workspaces[0]).toMatchObject({ - workspaceKey: "srv:ws-main", - serverId: "srv", - workspaceId: "ws-main", - projectRootPath: "/repo/main", - projectKind: "git", - }); - }); - - it("preserves the structure hook project order", () => { - const projects = buildSidebarProjectsFromStructure({ - serverId: "srv", - projects: [ - project({ projectKey: "project-b", workspaceKeys: ["ws-b"] }), - project({ projectKey: "project-a", workspaceKeys: ["ws-a"] }), - ], - }); - - expect(projects.map((entry) => entry.projectKey)).toEqual(["project-b", "project-a"]); - }); - - it("preserves the structure hook workspace order", () => { - const projects = buildSidebarProjectsFromStructure({ - serverId: "srv", - projects: [project({ projectKey: "project-1", workspaceKeys: ["feature", "main"] })], - }); - - expect(projects[0]?.workspaces.map((workspace) => workspace.workspaceId)).toEqual([ - "feature", - "main", - ]); - }); -}); - -describe("useSidebarWorkspacesList", () => { - let root: Root | null = null; - let container: HTMLElement | null = null; - - afterEach(() => { - if (root) { - act(() => { - root?.unmount(); - }); - } - root = null; - container?.remove(); - container = null; - act(() => { - getHostRuntimeStore().syncHosts([]); - useSessionStore.getState().clearSession("srv-disabled"); - useSessionStore.getState().clearSession("srv-loading"); - useSidebarOrderStore.setState({ - projectOrderByServerId: {}, - workspaceOrderByServerAndProject: {}, - }); - }); - }); - - it("honors enabled false without appending persisted order keys", async () => { - act(() => { - useSessionStore.getState().initializeSession("srv-disabled", null as unknown as DaemonClient); - useSessionStore - .getState() - .setWorkspaces("srv-disabled", new Map([["ws-main", workspaceDescriptor("ws-main")]])); - useSessionStore.getState().setHasHydratedWorkspaces("srv-disabled", true); - }); - - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - - await act(async () => { - root?.render(React.createElement(DisabledHookProbe, { serverId: "srv-disabled" })); - }); - - expect(useSidebarOrderStore.getState().projectOrderByServerId).toEqual({}); - expect(useSidebarOrderStore.getState().workspaceOrderByServerAndProject).toEqual({}); - }); - - it("keeps the sidebar in initial load until workspace hydration succeeds", async () => { - const onResult = vi.fn(); - - act(() => { - useSessionStore.getState().initializeSession("srv-loading", null as unknown as DaemonClient); - }); - - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - - await act(async () => { - root?.render(React.createElement(HookResultProbe, { serverId: "srv-loading", onResult })); - }); - - expect(onResult).toHaveBeenLastCalledWith( - expect.objectContaining({ - projects: [], - isLoading: true, - isInitialLoad: true, - }), - ); - }); - - it("does not subscribe to order updates while disabled", async () => { - const onRender = vi.fn(); - - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - - await act(async () => { - root?.render( - React.createElement(DisabledRenderCountProbe, { - serverId: "srv-disabled", - onRender, - }), - ); - }); - - expect(onRender).toHaveBeenCalledTimes(1); - - await act(async () => { - useSidebarOrderStore.getState().setProjectOrder("srv-disabled", ["project-a"]); - }); - - expect(onRender).toHaveBeenCalledTimes(1); - }); -}); diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.ts index 31ddb96e8..5d6d0c4e1 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.ts @@ -5,81 +5,31 @@ import { useSessionStore, type WorkspaceDescriptor, } from "@/stores/session-store"; -import { selectPrHintFromStatus, type PrHint } from "@/git/use-pr-status-query"; -import { - useWorkspaceStructure, - type WorkspaceStructureProject, -} from "@/stores/session-store-hooks"; +import { selectPrHintFromStatus } from "@/git/use-pr-status-query"; +import { useWorkspaceStructure } from "@/stores/session-store-hooks"; import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; import { shouldSuppressWorkspaceForLocalArchive } from "@/contexts/session-workspace-upserts"; +import { + buildSidebarProjectsFromStructure, + computeSidebarOrderUpdates, + deriveSidebarLoadingState, + type SidebarProjectEntry, + type SidebarWorkspaceEntry, +} from "./sidebar-workspaces-view-model"; -const EMPTY_ORDER: string[] = []; -const EMPTY_PROJECTS: SidebarProjectEntry[] = []; - -export type SidebarStateBucket = WorkspaceDescriptor["status"]; - -export interface SidebarWorkspaceEntry { - workspaceKey: string; - serverId: string; - workspaceId: string; - projectKey: string; - projectRootPath?: string; - workspaceDirectory?: string; - projectKind: WorkspaceDescriptor["projectKind"]; - workspaceKind: WorkspaceDescriptor["workspaceKind"]; - name: string; - statusBucket: SidebarStateBucket; - archivingAt: string | null; - diffStat: { additions: number; deletions: number } | null; - prHint: PrHint | null; - archiveHasUncommittedChanges: boolean | null; - archiveUnpushedCommitCount: number | null; - scripts: WorkspaceDescriptor["scripts"]; - hasRunningScripts: boolean; -} - -export interface SidebarProjectEntry { - projectKey: string; - projectName: string; - projectKind: WorkspaceDescriptor["projectKind"]; - iconWorkingDir: string; - workspaces: SidebarWorkspaceEntry[]; -} - -export interface SidebarWorkspacesListResult { - projects: SidebarProjectEntry[]; - isLoading: boolean; - isInitialLoad: boolean; - isRevalidating: boolean; - refreshAll: () => void; -} - -function createStructuralWorkspaceEntry(input: { - serverId: string; - project: WorkspaceStructureProject; - workspaceId: string; -}): SidebarWorkspaceEntry { - return { - workspaceKey: `${input.serverId}:${input.workspaceId}`, - serverId: input.serverId, - workspaceId: input.workspaceId, - projectKey: input.project.projectKey, - projectRootPath: input.project.iconWorkingDir, - workspaceDirectory: undefined, - projectKind: input.project.projectKind, - workspaceKind: "checkout", - name: input.workspaceId, - statusBucket: "done", - archivingAt: null, - diffStat: null, - prHint: null, - archiveHasUncommittedChanges: null, - archiveUnpushedCommitCount: null, - scripts: [], - hasRunningScripts: false, - }; -} +export { + appendMissingOrderKeys, + applyStoredOrdering, + buildSidebarProjectsFromStructure, + computeSidebarOrderUpdates, + deriveSidebarLoadingState, + type SidebarLoadingState, + type SidebarOrderUpdates, + type SidebarProjectEntry, + type SidebarStateBucket, + type SidebarWorkspaceEntry, +} from "./sidebar-workspaces-view-model"; export function createSidebarWorkspaceEntry(input: { serverId: string; @@ -106,91 +56,15 @@ export function createSidebarWorkspaceEntry(input: { }; } -export function buildSidebarProjectsFromStructure(input: { - serverId: string; - projects: WorkspaceStructureProject[]; -}): SidebarProjectEntry[] { - if (input.projects.length === 0) { - return EMPTY_PROJECTS; - } +const EMPTY_ORDER: string[] = []; +const EMPTY_PROJECTS: SidebarProjectEntry[] = []; - return input.projects.map((project) => ({ - projectKey: project.projectKey, - projectName: project.projectName, - projectKind: project.projectKind, - iconWorkingDir: project.iconWorkingDir, - workspaces: project.workspaceKeys.map((workspaceId) => - createStructuralWorkspaceEntry({ - serverId: input.serverId, - project, - workspaceId, - }), - ), - })); -} - -export function applyStoredOrdering(input: { - items: T[]; - storedOrder: string[]; - getKey: (item: T) => string; -}): T[] { - if (input.items.length <= 1 || input.storedOrder.length === 0) { - return input.items; - } - - const itemByKey = new Map(); - for (const item of input.items) { - itemByKey.set(input.getKey(item), item); - } - - const prunedOrder: string[] = []; - const seen = new Set(); - for (const key of input.storedOrder) { - if (!itemByKey.has(key) || seen.has(key)) { - continue; - } - seen.add(key); - prunedOrder.push(key); - } - - if (prunedOrder.length === 0) { - return input.items; - } - - const orderedSet = new Set(prunedOrder); - const ordered: T[] = []; - let orderedIndex = 0; - - for (const item of input.items) { - const key = input.getKey(item); - if (!orderedSet.has(key)) { - ordered.push(item); - continue; - } - - const targetKey = prunedOrder[orderedIndex] ?? key; - orderedIndex += 1; - ordered.push(itemByKey.get(targetKey) ?? item); - } - - return ordered; -} - -export function appendMissingOrderKeys(input: { - currentOrder: string[]; - visibleKeys: string[]; -}): string[] { - if (input.visibleKeys.length === 0) { - return input.currentOrder; - } - - const existingKeys = new Set(input.currentOrder); - const missingKeys = input.visibleKeys.filter((key) => !existingKeys.has(key)); - if (missingKeys.length === 0) { - return input.currentOrder; - } - - return [...input.currentOrder, ...missingKeys]; +export interface SidebarWorkspacesListResult { + projects: SidebarProjectEntry[]; + isLoading: boolean; + isInitialLoad: boolean; + isRevalidating: boolean; + refreshAll: () => void; } function toWorkspaceDescriptor(payload: WorkspaceDescriptorPayload): WorkspaceDescriptor { @@ -252,31 +126,22 @@ export function useSidebarWorkspacesList(options?: { }, [connectionStatus, hasHydratedWorkspaces, projects, serverId]); useEffect(() => { - if (!serverId || projects.length === 0) { + if (!serverId) { return; } - const nextProjectOrder = appendMissingOrderKeys({ - currentOrder: persistedProjectOrder, - visibleKeys: projects.map((project) => project.projectKey), + const orderStore = useSidebarOrderStore.getState(); + const updates = computeSidebarOrderUpdates({ + projects, + persistedProjectOrder, + getWorkspaceOrder: (projectKey) => orderStore.getWorkspaceOrder(serverId, projectKey), }); - if (nextProjectOrder !== persistedProjectOrder) { - useSidebarOrderStore.getState().setProjectOrder(serverId, nextProjectOrder); - } - for (const project of projects) { - const persistedWorkspaceOrder = useSidebarOrderStore - .getState() - .getWorkspaceOrder(serverId, project.projectKey); - const nextWorkspaceOrder = appendMissingOrderKeys({ - currentOrder: persistedWorkspaceOrder, - visibleKeys: project.workspaces.map((workspace) => workspace.workspaceKey), - }); - if (nextWorkspaceOrder !== persistedWorkspaceOrder) { - useSidebarOrderStore - .getState() - .setWorkspaceOrder(serverId, project.projectKey, nextWorkspaceOrder); - } + if (updates.projectOrder) { + orderStore.setProjectOrder(serverId, updates.projectOrder); + } + for (const { projectKey, order } of updates.workspaceOrders) { + orderStore.setWorkspaceOrder(serverId, projectKey, order); } }, [persistedProjectOrder, projects, serverId]); @@ -323,15 +188,16 @@ export function useSidebarWorkspacesList(options?: { })(); }, [connectionStatus, isActive, runtime, serverId]); - const isLoading = isActive && Boolean(serverId) && !hasHydratedWorkspaces; - const isInitialLoad = isLoading && projects.length === 0; - const isRevalidating = false; + const loadingState = deriveSidebarLoadingState({ + isActive, + serverId, + hasHydratedWorkspaces, + hasProjects: projects.length > 0, + }); return { projects, - isLoading, - isInitialLoad, - isRevalidating, + ...loadingState, refreshAll, }; } diff --git a/packages/app/src/hooks/use-workspace-navigation.test.ts b/packages/app/src/hooks/use-workspace-navigation.test.ts deleted file mode 100644 index bb9e7c6e9..000000000 --- a/packages/app/src/hooks/use-workspace-navigation.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import { renderHook } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; - -const navigateToWorkspaceMock = vi.hoisted(() => vi.fn()); - -vi.mock("@/stores/navigation-active-workspace-store", () => ({ - navigateToWorkspace: navigateToWorkspaceMock, -})); - -import { navigateToWorkspace, useWorkspaceNavigation } from "@/hooks/use-workspace-navigation"; - -describe("useWorkspaceNavigation", () => { - it("re-exports the workspace navigation action", () => { - expect(navigateToWorkspace).toBe(navigateToWorkspaceMock); - }); - - it("returns a stable callback wrapper", () => { - const { result } = renderHook(() => useWorkspaceNavigation()); - - result.current.navigateToWorkspace("server-1", "workspace-a"); - - expect(navigateToWorkspaceMock).toHaveBeenCalledWith("server-1", "workspace-a", undefined); - }); -}); diff --git a/packages/app/src/hooks/use-workspace-navigation.ts b/packages/app/src/hooks/use-workspace-navigation.ts deleted file mode 100644 index 829206139..000000000 --- a/packages/app/src/hooks/use-workspace-navigation.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { useCallback } from "react"; -import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; - -export { navigateToWorkspace }; - -export function useWorkspaceNavigation() { - return { - navigateToWorkspace: useCallback( - ( - serverId: string, - workspaceId: string, - options?: Parameters[2], - ) => { - navigateToWorkspace(serverId, workspaceId, options); - }, - [], - ), - }; -} diff --git a/packages/app/src/native/ios-hardware-keyboard-submit.ios.ts b/packages/app/src/native/ios-hardware-keyboard-submit.ios.ts index cffff5c40..981d71933 100644 --- a/packages/app/src/native/ios-hardware-keyboard-submit.ios.ts +++ b/packages/app/src/native/ios-hardware-keyboard-submit.ios.ts @@ -19,11 +19,3 @@ export function setHardwareKeyboardSubmitEnabled(enabled: boolean) { export function addHardwareKeyboardSubmitListener(handler: HardwareKeyboardSubmitHandler) { return module.addListener("onHardwareKeyboardSubmit", handler); } - -export function emitHardwareKeyboardSubmitForTest() {} - -export function resetHardwareKeyboardSubmitForTest() {} - -export function getHardwareKeyboardSubmitEnabledForTest() { - return false; -} diff --git a/packages/app/src/native/ios-hardware-keyboard-submit.ts b/packages/app/src/native/ios-hardware-keyboard-submit.ts index 0a1833b7f..41dde26e5 100644 --- a/packages/app/src/native/ios-hardware-keyboard-submit.ts +++ b/packages/app/src/native/ios-hardware-keyboard-submit.ts @@ -2,33 +2,12 @@ import type { EventSubscription } from "expo-modules-core"; type HardwareKeyboardSubmitHandler = () => void; -const testHandlers = new Set(); -let isEnabledForTest = false; - -export function setHardwareKeyboardSubmitEnabled(enabled: boolean) { - isEnabledForTest = enabled; -} +export function setHardwareKeyboardSubmitEnabled(_enabled: boolean) {} export function addHardwareKeyboardSubmitListener( - handler: HardwareKeyboardSubmitHandler, + _handler: HardwareKeyboardSubmitHandler, ): EventSubscription { - testHandlers.add(handler); return { - remove: () => { - testHandlers.delete(handler); - }, + remove: () => {}, }; } - -export function emitHardwareKeyboardSubmitForTest() { - testHandlers.forEach((handler) => handler()); -} - -export function resetHardwareKeyboardSubmitForTest() { - testHandlers.clear(); - isEnabledForTest = false; -} - -export function getHardwareKeyboardSubmitEnabledForTest() { - return isEnabledForTest; -} diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 9185fa8b1..9f4dd1b08 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -59,7 +59,8 @@ import { type Agent, useSessionStore } from "@/stores/session-store"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store"; import type { Theme } from "@/styles/theme"; -import { SubagentsTrack, useArchiveSubagent, useSubagentsForParent } from "@/subagents"; +import { useArchiveSubagent, useSubagentsForParent } from "@/subagents"; +import { SubagentsTrack } from "@/subagents/track"; import type { PendingPermission } from "@/types/shared"; import type { StreamItem } from "@/types/stream"; import { getInitDeferred, getInitKey } from "@/utils/agent-initialization"; diff --git a/packages/app/src/polyfills/crypto.test.ts b/packages/app/src/polyfills/crypto.test.ts deleted file mode 100644 index 2eec3cc79..000000000 --- a/packages/app/src/polyfills/crypto.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const expoCryptoMock = vi.hoisted(() => ({ - getRandomValues: vi.fn((array: T): T => array), - randomUUID: vi.fn(() => { - throw new Error("ExpoCrypto.randomUUID should not be used for the web fallback"); - }), -})); - -vi.mock("expo-crypto", () => expoCryptoMock); - -describe("polyfillCrypto", () => { - beforeEach(() => { - vi.resetModules(); - vi.unstubAllGlobals(); - expoCryptoMock.getRandomValues.mockClear(); - expoCryptoMock.randomUUID.mockClear(); - }); - - it("generates randomUUID from getRandomValues when Web Crypto randomUUID is unavailable", async () => { - const sourceBytes = Uint8Array.from([ - 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, - 0xff, - ]); - const getRandomValues = vi.fn((array: T): T => { - if (array && ArrayBuffer.isView(array)) { - new Uint8Array(array.buffer, array.byteOffset, array.byteLength).set(sourceBytes); - } - return array; - }); - vi.stubGlobal("crypto", { getRandomValues }); - - const { polyfillCrypto } = await import("./crypto"); - polyfillCrypto(); - - expect(globalThis.crypto.randomUUID()).toBe("00112233-4455-4677-8899-aabbccddeeff"); - expect(getRandomValues).toHaveBeenCalledTimes(1); - expect(expoCryptoMock.randomUUID).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/app/src/polyfills/crypto.ts b/packages/app/src/polyfills/crypto.ts index 968ee68f5..f498281fb 100644 --- a/packages/app/src/polyfills/crypto.ts +++ b/packages/app/src/polyfills/crypto.ts @@ -1,85 +1,11 @@ import * as ExpoCrypto from "expo-crypto"; -import { Buffer } from "buffer"; - -declare global { - interface Crypto { - randomUUID(): `${string}-${string}-${string}-${string}-${string}`; - } -} - -interface MutableGlobal { - TextEncoder?: typeof TextEncoder; - TextDecoder?: typeof TextDecoder; - crypto?: Crypto; -} - -type RandomUUID = `${string}-${string}-${string}-${string}-${string}`; -type FillRandomValues = (array: T) => T; - -function createUuidV4(fillRandomValues: FillRandomValues): RandomUUID { - const bytes = fillRandomValues(new Uint8Array(16)); - bytes[6] = (bytes[6]! & 0x0f) | 0x40; - bytes[8] = (bytes[8]! & 0x3f) | 0x80; - - const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")); - return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex - .slice(6, 8) - .join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10, 16).join("")}` as RandomUUID; -} +import { installCryptoPolyfills, type CryptoPolyfillTarget } from "./install-crypto-polyfills"; export function polyfillCrypto(): void { - const g = globalThis as unknown as MutableGlobal; - const nativeGetRandomValues = - typeof g.crypto?.getRandomValues === "function" - ? g.crypto.getRandomValues.bind(g.crypto) - : null; - - // Ensure TextEncoder/TextDecoder exist for shared E2EE code (tweetnacl + relay transport). - // Hermes may not provide them in all configurations. - if (typeof g.TextEncoder !== "function") { - class BufferTextEncoder { - encode(input = ""): Uint8Array { - return Uint8Array.from(Buffer.from(input, "utf8")); - } - } - g.TextEncoder = BufferTextEncoder as unknown as typeof TextEncoder; - } - - if (typeof g.TextDecoder !== "function") { - class BufferTextDecoder { - decode(input?: ArrayBuffer | ArrayBufferView): string { - if (input == null) return ""; - if (input instanceof ArrayBuffer) { - return Buffer.from(input).toString("utf8"); - } - if (ArrayBuffer.isView(input)) { - return Buffer.from(input.buffer, input.byteOffset, input.byteLength).toString("utf8"); - } - return Buffer.from(String(input), "utf8").toString("utf8"); - } - } - g.TextDecoder = BufferTextDecoder as unknown as typeof TextDecoder; - } - - if (!g.crypto) { - g.crypto = {} as Crypto; - } - - const fillRandomValues: FillRandomValues = (array: T): T => { - if (array === null) return array; - if (nativeGetRandomValues) { - return nativeGetRandomValues(array as unknown as ArrayBufferView) as T; - } - return ExpoCrypto.getRandomValues( - array as unknown as Parameters[0], - ) as unknown as T; - }; - - if (typeof g.crypto.randomUUID !== "function") { - g.crypto.randomUUID = () => createUuidV4(fillRandomValues); - } - - if (typeof g.crypto.getRandomValues !== "function") { - g.crypto.getRandomValues = fillRandomValues; - } + installCryptoPolyfills(globalThis as unknown as CryptoPolyfillTarget, { + expoGetRandomValues: (array: T): T => + ExpoCrypto.getRandomValues( + array as unknown as Parameters[0], + ) as unknown as T, + }); } diff --git a/packages/app/src/polyfills/install-crypto-polyfills.test.ts b/packages/app/src/polyfills/install-crypto-polyfills.test.ts new file mode 100644 index 000000000..3b146faf9 --- /dev/null +++ b/packages/app/src/polyfills/install-crypto-polyfills.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { + installCryptoPolyfills, + type CryptoPolyfillSources, + type CryptoPolyfillTarget, +} from "./install-crypto-polyfills"; + +interface RecordingFillRandomValues { + fill: CryptoPolyfillSources["expoGetRandomValues"]; + calls: number; +} + +function recordingFillFromBytes(bytes: Uint8Array): RecordingFillRandomValues { + const recorder = { calls: 0 } as RecordingFillRandomValues; + recorder.fill = (array: T): T => { + recorder.calls += 1; + if (array && ArrayBuffer.isView(array)) { + new Uint8Array(array.buffer, array.byteOffset, array.byteLength).set(bytes); + } + return array; + }; + return recorder; +} + +const TEST_BYTES = Uint8Array.from([ + 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, +]); + +describe("installCryptoPolyfills", () => { + it("derives randomUUID from the target's native getRandomValues when available", () => { + const native = recordingFillFromBytes(TEST_BYTES); + const expo = recordingFillFromBytes(TEST_BYTES); + const target: CryptoPolyfillTarget = { + crypto: { getRandomValues: native.fill } as unknown as Crypto, + }; + + installCryptoPolyfills(target, { expoGetRandomValues: expo.fill }); + + expect(target.crypto!.randomUUID()).toBe("00112233-4455-4677-8899-aabbccddeeff"); + expect(native.calls).toBe(1); + expect(expo.calls).toBe(0); + }); + + it("falls back to the injected expo source when the target has no crypto", () => { + const expo = recordingFillFromBytes(TEST_BYTES); + const target: CryptoPolyfillTarget = {}; + + installCryptoPolyfills(target, { expoGetRandomValues: expo.fill }); + + expect(target.crypto!.randomUUID()).toBe("00112233-4455-4677-8899-aabbccddeeff"); + expect(expo.calls).toBe(1); + }); + + it("installs getRandomValues that delegates to the expo source", () => { + const expo = recordingFillFromBytes(TEST_BYTES); + const target: CryptoPolyfillTarget = {}; + + installCryptoPolyfills(target, { expoGetRandomValues: expo.fill }); + + const buf = new Uint8Array(16); + target.crypto!.getRandomValues(buf); + expect(Array.from(buf)).toEqual(Array.from(TEST_BYTES)); + expect(expo.calls).toBe(1); + }); + + it("installs Buffer-backed TextEncoder and TextDecoder when missing", () => { + const expo = recordingFillFromBytes(TEST_BYTES); + const target: CryptoPolyfillTarget = {}; + + installCryptoPolyfills(target, { expoGetRandomValues: expo.fill }); + + expect(typeof target.TextEncoder).toBe("function"); + expect(typeof target.TextDecoder).toBe("function"); + const encoded = new target.TextEncoder!().encode("hello"); + expect(Array.from(encoded)).toEqual([104, 101, 108, 108, 111]); + expect(new target.TextDecoder!().decode(encoded)).toBe("hello"); + }); +}); diff --git a/packages/app/src/polyfills/install-crypto-polyfills.ts b/packages/app/src/polyfills/install-crypto-polyfills.ts new file mode 100644 index 000000000..f9fb2b839 --- /dev/null +++ b/packages/app/src/polyfills/install-crypto-polyfills.ts @@ -0,0 +1,88 @@ +import { Buffer } from "buffer"; + +declare global { + interface Crypto { + randomUUID(): `${string}-${string}-${string}-${string}-${string}`; + } +} + +export interface CryptoPolyfillTarget { + TextEncoder?: typeof TextEncoder; + TextDecoder?: typeof TextDecoder; + crypto?: Crypto; +} + +type RandomUUID = `${string}-${string}-${string}-${string}-${string}`; +type FillRandomValues = (array: T) => T; + +export interface CryptoPolyfillSources { + expoGetRandomValues: FillRandomValues; +} + +function createUuidV4(fillRandomValues: FillRandomValues): RandomUUID { + const bytes = fillRandomValues(new Uint8Array(16)); + bytes[6] = (bytes[6]! & 0x0f) | 0x40; + bytes[8] = (bytes[8]! & 0x3f) | 0x80; + + const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")); + return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex + .slice(6, 8) + .join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10, 16).join("")}` as RandomUUID; +} + +export function installCryptoPolyfills( + target: CryptoPolyfillTarget, + sources: CryptoPolyfillSources, +): void { + const nativeGetRandomValues = + typeof target.crypto?.getRandomValues === "function" + ? target.crypto.getRandomValues.bind(target.crypto) + : null; + + // Ensure TextEncoder/TextDecoder exist for shared E2EE code (tweetnacl + relay transport). + // Hermes may not provide them in all configurations. + if (typeof target.TextEncoder !== "function") { + class BufferTextEncoder { + encode(input = ""): Uint8Array { + return Uint8Array.from(Buffer.from(input, "utf8")); + } + } + target.TextEncoder = BufferTextEncoder as unknown as typeof TextEncoder; + } + + if (typeof target.TextDecoder !== "function") { + class BufferTextDecoder { + decode(input?: ArrayBuffer | ArrayBufferView): string { + if (input == null) return ""; + if (input instanceof ArrayBuffer) { + return Buffer.from(input).toString("utf8"); + } + if (ArrayBuffer.isView(input)) { + return Buffer.from(input.buffer, input.byteOffset, input.byteLength).toString("utf8"); + } + return Buffer.from(String(input), "utf8").toString("utf8"); + } + } + target.TextDecoder = BufferTextDecoder as unknown as typeof TextDecoder; + } + + if (!target.crypto) { + target.crypto = {} as Crypto; + } + + const fillRandomValues: FillRandomValues = (array: T): T => { + if (array === null) return array; + if (nativeGetRandomValues) { + return nativeGetRandomValues(array as unknown as ArrayBufferView) as T; + } + return sources.expoGetRandomValues(array); + }; + + if (typeof target.crypto.randomUUID !== "function") { + target.crypto.randomUUID = () => createUuidV4(fillRandomValues); + } + + if (typeof target.crypto.getRandomValues !== "function") { + target.crypto.getRandomValues = fillRandomValues; + } +} diff --git a/packages/app/src/review/state.ts b/packages/app/src/review/state.ts new file mode 100644 index 000000000..422b351b8 --- /dev/null +++ b/packages/app/src/review/state.ts @@ -0,0 +1,187 @@ +export type ReviewDraftMode = "uncommitted" | "base"; +export type ReviewDraftSide = "old" | "new"; + +export interface ReviewDraftComment { + id: string; + filePath: string; + side: ReviewDraftSide; + lineNumber: number; + body: string; + createdAt: string; + updatedAt: string; +} + +export interface ReviewDraftStoreState { + drafts: Record; + activeModesByScope: Record; +} + +export interface SerializedReviewDraftState { + drafts: Record; + activeModesByScope: Record; +} + +export function setActiveModeInState( + state: ReviewDraftStoreState, + input: { scopeKey: string; mode: ReviewDraftMode }, +): ReviewDraftStoreState { + if (state.activeModesByScope[input.scopeKey] === input.mode) { + return state; + } + return { + ...state, + activeModesByScope: { + ...state.activeModesByScope, + [input.scopeKey]: input.mode, + }, + }; +} + +export function addCommentToState( + state: ReviewDraftStoreState, + input: { key: string; comment: ReviewDraftComment }, +): ReviewDraftStoreState { + return { + ...state, + drafts: { + ...state.drafts, + [input.key]: [...(state.drafts[input.key] ?? []), input.comment], + }, + }; +} + +export function updateCommentInState( + state: ReviewDraftStoreState, + input: { + key: string; + id: string; + updates: Partial>; + updatedAt: string; + }, +): ReviewDraftStoreState { + const comments = state.drafts[input.key] ?? []; + if (!comments.some((comment) => comment.id === input.id)) { + return state; + } + return { + ...state, + drafts: { + ...state.drafts, + [input.key]: comments.map((comment) => + applyCommentUpdates(comment, input.id, input.updates, input.updatedAt), + ), + }, + }; +} + +export function deleteCommentFromState( + state: ReviewDraftStoreState, + input: { key: string; id: string }, +): ReviewDraftStoreState { + const comments = state.drafts[input.key] ?? []; + if (!comments.some((comment) => comment.id === input.id)) { + return state; + } + return { + ...state, + drafts: { + ...state.drafts, + [input.key]: comments.filter((comment) => comment.id !== input.id), + }, + }; +} + +export function clearReviewInState( + state: ReviewDraftStoreState, + input: { key: string }, +): ReviewDraftStoreState { + if (!state.drafts[input.key]) { + return state; + } + const nextDrafts = { ...state.drafts }; + delete nextDrafts[input.key]; + return { ...state, drafts: nextDrafts }; +} + +export function serializeReviewDraftState( + state: ReviewDraftStoreState, +): SerializedReviewDraftState { + return { + drafts: state.drafts, + activeModesByScope: state.activeModesByScope, + }; +} + +export function normalizePersistedState(state: unknown): ReviewDraftStoreState { + if (!state || typeof state !== "object") { + return { drafts: {}, activeModesByScope: {} }; + } + const persisted = state as { drafts?: unknown; activeModesByScope?: unknown }; + const drafts = persisted.drafts; + if (!drafts || typeof drafts !== "object" || Array.isArray(drafts)) { + return { drafts: {}, activeModesByScope: {} }; + } + + const normalized: Record = {}; + for (const [key, value] of Object.entries(drafts)) { + if (!Array.isArray(value)) { + continue; + } + normalized[key] = value.filter((comment): comment is ReviewDraftComment => + isReviewDraftComment(comment), + ); + } + + const activeModesByScope: Record = {}; + const persistedActiveModes = persisted.activeModesByScope; + if ( + persistedActiveModes && + typeof persistedActiveModes === "object" && + !Array.isArray(persistedActiveModes) + ) { + for (const [key, mode] of Object.entries(persistedActiveModes)) { + if (mode === "base" || mode === "uncommitted") { + activeModesByScope[key] = mode; + } + } + } + return { drafts: normalized, activeModesByScope }; +} + +export function isReviewDraftComment(value: unknown): value is ReviewDraftComment { + if (!value || typeof value !== "object") { + return false; + } + const record = value as Record; + return ( + typeof record.id === "string" && + typeof record.filePath === "string" && + (record.side === "old" || record.side === "new") && + typeof record.lineNumber === "number" && + Number.isInteger(record.lineNumber) && + record.lineNumber > 0 && + typeof record.body === "string" && + typeof record.createdAt === "string" && + typeof record.updatedAt === "string" + ); +} + +function applyCommentUpdates( + comment: ReviewDraftComment, + targetId: string, + updates: Partial>, + updatedAt: string, +): ReviewDraftComment { + if (comment.id !== targetId) { + return comment; + } + return { + id: comment.id, + filePath: comment.filePath, + side: comment.side, + lineNumber: comment.lineNumber, + body: updates.body ?? comment.body, + createdAt: comment.createdAt, + updatedAt, + }; +} diff --git a/packages/app/src/review/store.test.ts b/packages/app/src/review/store.test.ts index e8dd83079..294c12d42 100644 --- a/packages/app/src/review/store.test.ts +++ b/packages/app/src/review/store.test.ts @@ -1,15 +1,37 @@ -import "@/test/window-local-storage"; -import AsyncStorage from "@react-native-async-storage/async-storage"; import { describe, expect, it } from "vitest"; import type { ParsedDiffFile } from "@/git/use-diff-query"; import { buildReviewAttachmentSnapshot, buildReviewDraftKey, buildReviewDraftScopeKey, - useReviewDraftStore, } from "./store"; +import { + addCommentToState, + clearReviewInState, + deleteCommentFromState, + normalizePersistedState, + type ReviewDraftComment, + type ReviewDraftStoreState, + setActiveModeInState, + updateCommentInState, +} from "./state"; -const STORE_STORAGE_KEY = "@paseo:review-draft-store"; +function emptyState(): ReviewDraftStoreState { + return { drafts: {}, activeModesByScope: {} }; +} + +function makeComment(overrides: Partial = {}): ReviewDraftComment { + return { + id: "comment-1", + filePath: "src/example.ts", + side: "new", + lineNumber: 41, + body: "Please simplify this.", + createdAt: "2026-04-21T00:00:00.000Z", + updatedAt: "2026-04-21T00:00:00.000Z", + ...overrides, + }; +} function makeFile(): ParsedDiffFile { return { @@ -89,44 +111,35 @@ describe("buildReviewDraftKey", () => { }); }); -describe("review draft store", () => { - it("normalizes persisted active review modes with draft comments", async () => { - await AsyncStorage.setItem( - STORE_STORAGE_KEY, - JSON.stringify({ - version: 0, - state: { - drafts: { - "review:key": [ - { - id: "comment-1", - filePath: "src/example.ts", - side: "new", - lineNumber: 41, - body: "Keep me.", - createdAt: "2026-04-21T00:00:00.000Z", - updatedAt: "2026-04-21T00:00:00.000Z", - }, - { id: "bad", filePath: "src/example.ts" }, - ], +describe("normalizePersistedState", () => { + it("filters invalid draft comments and drops unknown active modes", () => { + const normalized = normalizePersistedState({ + drafts: { + "review:key": [ + { + id: "comment-1", + filePath: "src/example.ts", + side: "new", + lineNumber: 41, + body: "Keep me.", + createdAt: "2026-04-21T00:00:00.000Z", + updatedAt: "2026-04-21T00:00:00.000Z", }, - activeModesByScope: { - "review:scope:base": "base", - "review:scope:dirty": "uncommitted", - "review:scope:bad": "other", - }, - }, - }), - ); + { id: "bad", filePath: "src/example.ts" }, + ], + }, + activeModesByScope: { + "review:scope:base": "base", + "review:scope:dirty": "uncommitted", + "review:scope:bad": "other", + }, + }); - await useReviewDraftStore.persist.rehydrate(); - - const state = useReviewDraftStore.getState(); - expect(state.activeModesByScope).toEqual({ + expect(normalized.activeModesByScope).toEqual({ "review:scope:base": "base", "review:scope:dirty": "uncommitted", }); - expect(state.drafts["review:key"]).toEqual([ + expect(normalized.drafts["review:key"]).toEqual([ { id: "comment-1", filePath: "src/example.ts", @@ -139,54 +152,68 @@ describe("review draft store", () => { ]); }); - it("persists compact draft comments separately from workspace attachment wire context", () => { - const key = buildReviewDraftKey({ - serverId: "local", - workspaceId: "workspace-1", - cwd: "/repo", - mode: "uncommitted", - baseRef: null, - ignoreWhitespace: false, + it("returns empty state for null, non-object, or malformed inputs", () => { + expect(normalizePersistedState(null)).toEqual({ drafts: {}, activeModesByScope: {} }); + expect(normalizePersistedState("nope")).toEqual({ drafts: {}, activeModesByScope: {} }); + expect(normalizePersistedState({ drafts: [] })).toEqual({ + drafts: {}, + activeModesByScope: {}, }); + }); +}); - useReviewDraftStore.getState().clearReview({ key }); - useReviewDraftStore.getState().addComment({ - key, - comment: { - id: "comment-1", - filePath: "src/example.ts", - side: "new", - lineNumber: 41, - body: "Please simplify this.", - createdAt: "2026-04-21T00:00:00.000Z", - updatedAt: "2026-04-21T00:00:00.000Z", - }, - }); +describe("review draft reducers", () => { + it("adds, updates, and deletes draft comments by key", () => { + let state = emptyState(); + const comment = makeComment(); - expect(useReviewDraftStore.getState().drafts[key]).toEqual([ - { - id: "comment-1", - filePath: "src/example.ts", - side: "new", - lineNumber: 41, - body: "Please simplify this.", - createdAt: "2026-04-21T00:00:00.000Z", - updatedAt: "2026-04-21T00:00:00.000Z", - }, - ]); + state = addCommentToState(state, { key: "review:key", comment }); + expect(state.drafts["review:key"]).toEqual([comment]); - useReviewDraftStore.getState().updateComment({ - key, - id: "comment-1", + state = updateCommentInState(state, { + key: "review:key", + id: comment.id, updates: { body: "Please simplify this condition." }, updatedAt: "2026-04-21T00:01:00.000Z", }); - expect(useReviewDraftStore.getState().drafts[key]?.[0]?.body).toBe( - "Please simplify this condition.", - ); + expect(state.drafts["review:key"]?.[0]).toEqual({ + ...comment, + body: "Please simplify this condition.", + updatedAt: "2026-04-21T00:01:00.000Z", + }); - useReviewDraftStore.getState().deleteComment({ key, id: "comment-1" }); - expect(useReviewDraftStore.getState().drafts[key]).toEqual([]); + state = deleteCommentFromState(state, { key: "review:key", id: comment.id }); + expect(state.drafts["review:key"]).toEqual([]); + }); + + it("keeps state identity on no-op updates, deletes, and clears", () => { + const state = addCommentToState(emptyState(), { + key: "review:key", + comment: makeComment(), + }); + + expect( + updateCommentInState(state, { + key: "review:key", + id: "missing", + updates: { body: "x" }, + updatedAt: "2026-04-21T00:01:00.000Z", + }), + ).toBe(state); + expect(deleteCommentFromState(state, { key: "review:key", id: "missing" })).toBe(state); + expect(clearReviewInState(state, { key: "other-key" })).toBe(state); + }); + + it("keeps state identity when setActiveMode does not change the mode", () => { + const state = setActiveModeInState(emptyState(), { + scopeKey: "review:scope", + mode: "base", + }); + + expect(setActiveModeInState(state, { scopeKey: "review:scope", mode: "base" })).toBe(state); + expect(setActiveModeInState(state, { scopeKey: "review:scope", mode: "uncommitted" })).not.toBe( + state, + ); }); }); diff --git a/packages/app/src/review/store.ts b/packages/app/src/review/store.ts index 72da9f131..168381a42 100644 --- a/packages/app/src/review/store.ts +++ b/packages/app/src/review/store.ts @@ -4,10 +4,25 @@ import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import type { ComposerAttachment } from "@/attachments/types"; import type { ParsedDiffFile } from "@/git/use-diff-query"; +import { + addCommentToState, + clearReviewInState, + deleteCommentFromState, + normalizePersistedState, + type ReviewDraftComment, + type ReviewDraftMode, + type ReviewDraftSide, + type ReviewDraftStoreState, + serializeReviewDraftState, + setActiveModeInState, + updateCommentInState, +} from "@/review/state"; import { generateMessageId } from "@/types/stream"; import { buildNumberedDiffHunks, type NumberedDiffLine } from "@/utils/diff-layout"; import type { AgentAttachment } from "@getpaseo/protocol/messages"; +export type { ReviewDraftComment, ReviewDraftMode, ReviewDraftSide } from "@/review/state"; + const STORE_VERSION = 1; const CONTEXT_RADIUS = 3; const EMPTY_REVIEW_DRAFT_COMMENTS: ReviewDraftComment[] = []; @@ -16,19 +31,6 @@ type ReviewAttachment = Extract; type ReviewAttachmentContextLine = ReviewAttachment["comments"][number]["context"]["targetLine"]; type ReviewComposerAttachment = Extract; -export type ReviewDraftMode = "uncommitted" | "base"; -export type ReviewDraftSide = "old" | "new"; - -export interface ReviewDraftComment { - id: string; - filePath: string; - side: ReviewDraftSide; - lineNumber: number; - body: string; - createdAt: string; - updatedAt: string; -} - export interface BuildReviewDraftKeyInput { serverId: string; workspaceId?: string | null; @@ -49,10 +51,8 @@ export interface BuildReviewAttachmentSnapshotInput { diffFiles: readonly ParsedDiffFile[]; } -interface ReviewDraftStoreState { - drafts: Record; - activeModesByScope: Record; -} +export type ReviewDraftCommentInput = Omit & + Partial>; interface ReviewDraftStoreActions { setActiveMode: (input: { scopeKey: string; mode: ReviewDraftMode }) => void; @@ -67,9 +67,6 @@ interface ReviewDraftStoreActions { clearReview: (input: { key: string }) => void; } -export type ReviewDraftCommentInput = Omit & - Partial>; - type ReviewDraftStore = ReviewDraftStoreState & ReviewDraftStoreActions; function encodeKeyPart(value: string): string { @@ -128,158 +125,41 @@ function createDraftComment(input: ReviewDraftCommentInput): ReviewDraftComment }; } -function applyCommentUpdates( - comment: ReviewDraftComment, - targetId: string, - updates: Partial>, - updatedAt: string, -): ReviewDraftComment { - if (comment.id !== targetId) { - return comment; - } - return { - id: comment.id, - filePath: comment.filePath, - side: comment.side, - lineNumber: comment.lineNumber, - body: updates.body ?? comment.body, - createdAt: comment.createdAt, - updatedAt, - }; -} - -function normalizePersistedState(state: unknown): ReviewDraftStoreState { - if (!state || typeof state !== "object") { - return { drafts: {}, activeModesByScope: {} }; - } - const persisted = state as { drafts?: unknown; activeModesByScope?: unknown }; - const drafts = persisted.drafts; - if (!drafts || typeof drafts !== "object" || Array.isArray(drafts)) { - return { drafts: {}, activeModesByScope: {} }; - } - - const normalized: Record = {}; - for (const [key, value] of Object.entries(drafts)) { - if (!Array.isArray(value)) { - continue; - } - normalized[key] = value.filter((comment): comment is ReviewDraftComment => - isReviewDraftComment(comment), - ); - } - - const activeModesByScope: Record = {}; - const persistedActiveModes = persisted.activeModesByScope; - if ( - persistedActiveModes && - typeof persistedActiveModes === "object" && - !Array.isArray(persistedActiveModes) - ) { - for (const [key, mode] of Object.entries(persistedActiveModes)) { - if (mode === "base" || mode === "uncommitted") { - activeModesByScope[key] = mode; - } - } - } - return { drafts: normalized, activeModesByScope }; -} - -function isReviewDraftComment(value: unknown): value is ReviewDraftComment { - if (!value || typeof value !== "object") { - return false; - } - const record = value as Record; - return ( - typeof record.id === "string" && - typeof record.filePath === "string" && - (record.side === "old" || record.side === "new") && - typeof record.lineNumber === "number" && - Number.isInteger(record.lineNumber) && - record.lineNumber > 0 && - typeof record.body === "string" && - typeof record.createdAt === "string" && - typeof record.updatedAt === "string" - ); -} - export const useReviewDraftStore = create()( persist( (set) => ({ drafts: {}, activeModesByScope: {}, - setActiveMode: ({ scopeKey, mode }) => { - set((state) => { - if (state.activeModesByScope[scopeKey] === mode) { - return state; - } - return { - activeModesByScope: { - ...state.activeModesByScope, - [scopeKey]: mode, - }, - }; - }); + setActiveMode: (input) => { + set((state) => setActiveModeInState(state, input)); }, addComment: ({ key, comment }) => { const nextComment = createDraftComment(comment); - set((state) => ({ - drafts: { - ...state.drafts, - [key]: [...(state.drafts[key] ?? []), nextComment], - }, - })); + set((state) => addCommentToState(state, { key, comment: nextComment })); return nextComment; }, updateComment: ({ key, id, updates, updatedAt }) => { - set((state) => { - const comments = state.drafts[key] ?? []; - if (!comments.some((comment) => comment.id === id)) { - return state; - } - const nextUpdatedAt = updatedAt ?? new Date().toISOString(); - return { - drafts: { - ...state.drafts, - [key]: comments.map((comment) => - applyCommentUpdates(comment, id, updates, nextUpdatedAt), - ), - }, - }; - }); + set((state) => + updateCommentInState(state, { + key, + id, + updates, + updatedAt: updatedAt ?? new Date().toISOString(), + }), + ); }, - deleteComment: ({ key, id }) => { - set((state) => { - const comments = state.drafts[key] ?? []; - if (!comments.some((comment) => comment.id === id)) { - return state; - } - return { - drafts: { - ...state.drafts, - [key]: comments.filter((comment) => comment.id !== id), - }, - }; - }); + deleteComment: (input) => { + set((state) => deleteCommentFromState(state, input)); }, - clearReview: ({ key }) => { - set((state) => { - if (!state.drafts[key]) { - return state; - } - const next = { ...state.drafts }; - delete next[key]; - return { drafts: next }; - }); + clearReview: (input) => { + set((state) => clearReviewInState(state, input)); }, }), { name: "@paseo:review-draft-store", version: STORE_VERSION, storage: createJSONStorage(() => AsyncStorage), - partialize: (state) => ({ - drafts: state.drafts, - activeModesByScope: state.activeModesByScope, - }), + partialize: (state) => serializeReviewDraftState(state), migrate: async (state) => normalizePersistedState(state), }, ), diff --git a/packages/app/src/screens/new-workspace-empty.test.ts b/packages/app/src/screens/new-workspace-empty.test.ts index 3b56e184e..8065464bb 100644 --- a/packages/app/src/screens/new-workspace-empty.test.ts +++ b/packages/app/src/screens/new-workspace-empty.test.ts @@ -1,16 +1,7 @@ -import { beforeAll, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { ComposerAttachment } from "@/attachments/types"; import type { MessagePayload } from "@/composer/types"; - -const navigateToWorkspace = vi.hoisted(() => vi.fn()); -vi.mock("@/hooks/use-workspace-navigation", () => ({ navigateToWorkspace })); - -let isEmptyWorkspaceSubmission: typeof import("./new-workspace-empty").isEmptyWorkspaceSubmission; -let runCreateEmptyWorkspace: typeof import("./new-workspace-empty").runCreateEmptyWorkspace; - -beforeAll(async () => { - ({ isEmptyWorkspaceSubmission, runCreateEmptyWorkspace } = await import("./new-workspace-empty")); -}); +import { isEmptyWorkspaceSubmission, runCreateEmptyWorkspace } from "./new-workspace-empty"; function payload( input: { text?: string; attachments?: ComposerAttachment[] } = {}, @@ -18,12 +9,28 @@ function payload( return { text: input.text ?? "", attachments: input.attachments ?? [], cwd: "/sample/repo" }; } +function createRecordingNavigate() { + const recorded: Array<{ serverId: string; workspaceId: string }> = []; + return { + recorded, + navigate: (serverId: string, workspaceId: string) => { + recorded.push({ serverId, workspaceId }); + }, + }; +} + describe("runCreateEmptyWorkspace", () => { it("creates a workspace without prompt or attachments and navigates to it", async () => { const workspace = { id: "workspace-123" }; const ensureWorkspace = vi.fn().mockResolvedValue(workspace); + const { navigate, recorded } = createRecordingNavigate(); - await runCreateEmptyWorkspace({ payload: payload(), ensureWorkspace, serverId: "server-abc" }); + await runCreateEmptyWorkspace({ + payload: payload(), + ensureWorkspace, + serverId: "server-abc", + navigate, + }); expect(ensureWorkspace).toHaveBeenCalledOnce(); expect(ensureWorkspace).toHaveBeenCalledWith({ @@ -31,8 +38,7 @@ describe("runCreateEmptyWorkspace", () => { prompt: "", attachments: [], }); - expect(navigateToWorkspace).toHaveBeenCalledOnce(); - expect(navigateToWorkspace).toHaveBeenCalledWith("server-abc", workspace.id); + expect(recorded).toEqual([{ serverId: "server-abc", workspaceId: "workspace-123" }]); }); }); diff --git a/packages/app/src/screens/new-workspace-empty.ts b/packages/app/src/screens/new-workspace-empty.ts index 72c49985b..40fdcdf27 100644 --- a/packages/app/src/screens/new-workspace-empty.ts +++ b/packages/app/src/screens/new-workspace-empty.ts @@ -1,4 +1,3 @@ -import { navigateToWorkspace } from "@/hooks/use-workspace-navigation"; import type { normalizeWorkspaceDescriptor } from "@/stores/session-store"; import type { MessagePayload } from "@/composer/types"; import type { AgentAttachment } from "@getpaseo/protocol/messages"; @@ -15,14 +14,15 @@ export interface CreateEmptyWorkspaceInput { attachments: AgentAttachment[]; }) => Promise>; serverId: string; + navigate: (serverId: string, workspaceId: string) => void; } export async function runCreateEmptyWorkspace(input: CreateEmptyWorkspaceInput): Promise { - const { payload, ensureWorkspace, serverId } = input; + const { payload, ensureWorkspace, serverId, navigate } = input; const ensuredWorkspace = await ensureWorkspace({ cwd: payload.cwd, prompt: "", attachments: [], }); - navigateToWorkspace(serverId, ensuredWorkspace.id); + navigate(serverId, ensuredWorkspace.id); } diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 7380aa777..cc0971dd7 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -21,6 +21,7 @@ import { useToast } from "@/contexts/toast-context"; import { useAgentInputDraft } from "@/composer/draft/input-draft"; import { useGithubSearchQuery } from "@/git/use-github-search-query"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store"; import { generateDraftId } from "@/stores/draft-keys"; import { useDraftStore } from "@/stores/draft-store"; @@ -759,6 +760,7 @@ export function NewWorkspaceScreen({ payload, ensureWorkspace, serverId, + navigate: navigateToWorkspace, }); return; } diff --git a/packages/app/src/stores/browser-store.test.ts b/packages/app/src/stores/browser-store.test.ts deleted file mode 100644 index d7d039656..000000000 --- a/packages/app/src/stores/browser-store.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { normalizeWorkspaceBrowserUrl, useBrowserStore } from "./browser-store"; - -vi.mock("@react-native-async-storage/async-storage", () => ({ - default: { - getItem: vi.fn(), - setItem: vi.fn(), - removeItem: vi.fn(), - }, -})); - -describe("browser store", () => { - it("normalizes local development hosts to http by default", () => { - expect(normalizeWorkspaceBrowserUrl("localhost:8081")).toBe("http://localhost:8081"); - expect(normalizeWorkspaceBrowserUrl("localhost/path")).toBe("http://localhost/path"); - expect(normalizeWorkspaceBrowserUrl("127.0.0.1:3000/path")).toBe("http://127.0.0.1:3000/path"); - expect(normalizeWorkspaceBrowserUrl("192.168.0.8")).toBe("http://192.168.0.8"); - expect(normalizeWorkspaceBrowserUrl("[::1]:5173")).toBe("http://[::1]:5173"); - }); - - it("normalizes public hosts to https by default", () => { - expect(normalizeWorkspaceBrowserUrl("example.com")).toBe("https://example.com"); - expect(normalizeWorkspaceBrowserUrl("//example.com/path")).toBe("https://example.com/path"); - }); - - it("keeps explicit protocols unchanged", () => { - expect(normalizeWorkspaceBrowserUrl("http://localhost:8081")).toBe("http://localhost:8081"); - expect(normalizeWorkspaceBrowserUrl("https://localhost:8081")).toBe("https://localhost:8081"); - expect(normalizeWorkspaceBrowserUrl("file:///tmp/example.html")).toBe( - "file:///tmp/example.html", - ); - }); - - it("normalizes browser URLs when creating and updating records", () => { - useBrowserStore.setState({ browsersById: {} }); - - const browserId = useBrowserStore.getState().createBrowser({ initialUrl: "localhost:8081" }); - expect(useBrowserStore.getState().browsersById[browserId]?.url).toBe("http://localhost:8081"); - - useBrowserStore.getState().updateBrowser(browserId, { url: "example.com/path" }); - expect(useBrowserStore.getState().browsersById[browserId]?.url).toBe( - "https://example.com/path", - ); - }); -}); diff --git a/packages/app/src/stores/browser-store.ts b/packages/app/src/stores/browser-store.ts deleted file mode 100644 index e18d359cb..000000000 --- a/packages/app/src/stores/browser-store.ts +++ /dev/null @@ -1,177 +0,0 @@ -import AsyncStorage from "@react-native-async-storage/async-storage"; -import { create } from "zustand"; -import { createJSONStorage, persist } from "zustand/middleware"; - -export interface BrowserRecord { - browserId: string; - url: string; - title: string; - isLoading: boolean; - canGoBack: boolean; - canGoForward: boolean; - faviconUrl: string | null; - lastError: string | null; - createdAt: number; -} - -type BrowserRecordPatch = Partial>; - -interface BrowserStoreState { - browsersById: Record; - createBrowser: (input?: { initialUrl?: string }) => string; - updateBrowser: (browserId: string, patch: BrowserRecordPatch) => void; - removeBrowser: (browserId: string) => void; -} - -function trimNonEmpty(value: string | null | undefined): string | null { - if (typeof value !== "string") { - return null; - } - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -function normalizeBrowserUrl(value: string | null | undefined): string { - const trimmed = trimNonEmpty(value); - if (!trimmed) { - return "https://example.com"; - } - if (/^(localhost|\d{1,3}(?:\.\d{1,3}){3}|\[[\da-fA-F:.]+])(?::\d+)?(?:[/?#]|$)/.test(trimmed)) { - return `http://${trimmed}`; - } - if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(trimmed)) { - return trimmed; - } - if (trimmed.startsWith("//")) { - return `https:${trimmed}`; - } - return `https://${trimmed}`; -} - -function createBrowserId(): string { - if (typeof globalThis.crypto?.randomUUID === "function") { - return globalThis.crypto.randomUUID(); - } - return `${Date.now()}-${Math.random().toString(16).slice(2)}`; -} - -export const useBrowserStore = create()( - persist( - (set) => ({ - browsersById: {}, - createBrowser: (input) => { - const browserId = createBrowserId(); - const now = Date.now(); - const initialUrl = normalizeBrowserUrl(input?.initialUrl); - - set((state) => ({ - browsersById: { - ...state.browsersById, - [browserId]: { - browserId, - url: initialUrl, - title: "", - isLoading: false, - canGoBack: false, - canGoForward: false, - faviconUrl: null, - lastError: null, - createdAt: now, - }, - }, - })); - - return browserId; - }, - updateBrowser: (browserId, patch) => { - const normalizedBrowserId = trimNonEmpty(browserId); - if (!normalizedBrowserId) { - return; - } - - set((state) => { - const existing = state.browsersById[normalizedBrowserId]; - if (!existing) { - return state; - } - - const nextRecord: BrowserRecord = { - ...existing, - ...patch, - url: normalizeBrowserUrl(patch.url ?? existing.url), - }; - - if ( - nextRecord.url === existing.url && - nextRecord.title === existing.title && - nextRecord.isLoading === existing.isLoading && - nextRecord.canGoBack === existing.canGoBack && - nextRecord.canGoForward === existing.canGoForward && - nextRecord.faviconUrl === existing.faviconUrl && - nextRecord.lastError === existing.lastError - ) { - return state; - } - - return { - browsersById: { - ...state.browsersById, - [normalizedBrowserId]: nextRecord, - }, - }; - }); - }, - removeBrowser: (browserId) => { - const normalizedBrowserId = trimNonEmpty(browserId); - if (!normalizedBrowserId) { - return; - } - - set((state) => { - if (!state.browsersById[normalizedBrowserId]) { - return state; - } - const next = { ...state.browsersById }; - delete next[normalizedBrowserId]; - return { browsersById: next }; - }); - }, - }), - { - name: "workspace-browser-store", - storage: createJSONStorage(() => AsyncStorage), - partialize: (state) => ({ - browsersById: Object.fromEntries( - Object.entries(state.browsersById).map(([browserId, browser]) => [ - browserId, - { ...browser, isLoading: false, lastError: null }, - ]), - ), - }), - }, - ), -); - -export function getBrowserRecord(browserId: string): BrowserRecord | null { - const normalizedBrowserId = trimNonEmpty(browserId); - if (!normalizedBrowserId) { - return null; - } - return useBrowserStore.getState().browsersById[normalizedBrowserId] ?? null; -} - -export function createWorkspaceBrowser(input?: { initialUrl?: string }): { - browserId: string; - url: string; -} { - const browserId = useBrowserStore.getState().createBrowser(input); - const record = getBrowserRecord(browserId); - return { - browserId, - url: record?.url ?? normalizeBrowserUrl(input?.initialUrl), - }; -} - -export function normalizeWorkspaceBrowserUrl(value: string | null | undefined): string { - return normalizeBrowserUrl(value); -} diff --git a/packages/app/src/stores/browser-store/index.ts b/packages/app/src/stores/browser-store/index.ts new file mode 100644 index 000000000..c16a6f4a0 --- /dev/null +++ b/packages/app/src/stores/browser-store/index.ts @@ -0,0 +1,89 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; +import { + applyBrowserPatch, + type BrowserIndexState, + type BrowserRecord, + type BrowserRecordPatch, + createBrowserRecord, + normalizeBrowserUrl, + removeBrowserFromIndex, + sanitizeBrowsersForPersist, + trimNonEmpty, +} from "./state"; + +export type { BrowserRecord } from "./state"; + +interface BrowserStoreState extends BrowserIndexState { + createBrowser: (input?: { initialUrl?: string }) => string; + updateBrowser: (browserId: string, patch: BrowserRecordPatch) => void; + removeBrowser: (browserId: string) => void; +} + +function createBrowserId(): string { + if (typeof globalThis.crypto?.randomUUID === "function") { + return globalThis.crypto.randomUUID(); + } + return `${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +export const useBrowserStore = create()( + persist( + (set) => ({ + browsersById: {}, + createBrowser: (input) => { + const browserId = createBrowserId(); + const record = createBrowserRecord({ + browserId, + initialUrl: input?.initialUrl, + now: Date.now(), + }); + + set((state) => ({ + browsersById: { + ...state.browsersById, + [browserId]: record, + }, + })); + + return browserId; + }, + updateBrowser: (browserId, patch) => { + set((state) => applyBrowserPatch(state, browserId, patch)); + }, + removeBrowser: (browserId) => { + set((state) => removeBrowserFromIndex(state, browserId)); + }, + }), + { + name: "workspace-browser-store", + storage: createJSONStorage(() => AsyncStorage), + partialize: (state) => sanitizeBrowsersForPersist(state), + }, + ), +); + +export function getBrowserRecord(browserId: string): BrowserRecord | null { + const normalizedBrowserId = trimNonEmpty(browserId); + if (!normalizedBrowserId) { + return null; + } + return useBrowserStore.getState().browsersById[normalizedBrowserId] ?? null; +} + +export function createWorkspaceBrowser(input?: { initialUrl?: string }): { + browserId: string; + url: string; +} { + const browserId = useBrowserStore.getState().createBrowser(input); + const record = getBrowserRecord(browserId); + return { + browserId, + url: record?.url ?? normalizeBrowserUrl(input?.initialUrl), + }; +} + +export function normalizeWorkspaceBrowserUrl(value: string | null | undefined): string { + return normalizeBrowserUrl(value); +} diff --git a/packages/app/src/stores/browser-store/state.test.ts b/packages/app/src/stores/browser-store/state.test.ts new file mode 100644 index 000000000..02a258796 --- /dev/null +++ b/packages/app/src/stores/browser-store/state.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from "vitest"; +import { + applyBrowserPatch, + type BrowserIndexState, + createBrowserRecord, + normalizeBrowserUrl, + removeBrowserFromIndex, + sanitizeBrowsersForPersist, +} from "./state"; + +function withRecords(records: ReturnType[]): BrowserIndexState { + return { + browsersById: Object.fromEntries(records.map((record) => [record.browserId, record])), + }; +} + +describe("normalizeBrowserUrl", () => { + it("normalizes local development hosts to http by default", () => { + expect(normalizeBrowserUrl("localhost:8081")).toBe("http://localhost:8081"); + expect(normalizeBrowserUrl("localhost/path")).toBe("http://localhost/path"); + expect(normalizeBrowserUrl("127.0.0.1:3000/path")).toBe("http://127.0.0.1:3000/path"); + expect(normalizeBrowserUrl("192.168.0.8")).toBe("http://192.168.0.8"); + expect(normalizeBrowserUrl("[::1]:5173")).toBe("http://[::1]:5173"); + }); + + it("normalizes public hosts to https by default", () => { + expect(normalizeBrowserUrl("example.com")).toBe("https://example.com"); + expect(normalizeBrowserUrl("//example.com/path")).toBe("https://example.com/path"); + }); + + it("keeps explicit protocols unchanged", () => { + expect(normalizeBrowserUrl("http://localhost:8081")).toBe("http://localhost:8081"); + expect(normalizeBrowserUrl("https://localhost:8081")).toBe("https://localhost:8081"); + expect(normalizeBrowserUrl("file:///tmp/example.html")).toBe("file:///tmp/example.html"); + }); + + it("falls back to a default URL when input is blank", () => { + expect(normalizeBrowserUrl(null)).toBe("https://example.com"); + expect(normalizeBrowserUrl(" ")).toBe("https://example.com"); + }); +}); + +describe("createBrowserRecord", () => { + it("normalizes the initial URL and starts with idle state", () => { + const record = createBrowserRecord({ + browserId: "b1", + initialUrl: "localhost:8081", + now: 1000, + }); + + expect(record).toEqual({ + browserId: "b1", + url: "http://localhost:8081", + title: "", + isLoading: false, + canGoBack: false, + canGoForward: false, + faviconUrl: null, + lastError: null, + createdAt: 1000, + }); + }); +}); + +describe("applyBrowserPatch", () => { + it("normalizes URL updates", () => { + const initial = withRecords([ + createBrowserRecord({ browserId: "b1", initialUrl: "https://a.test", now: 0 }), + ]); + + const next = applyBrowserPatch(initial, "b1", { url: "example.com/path" }); + + expect(next.browsersById.b1?.url).toBe("https://example.com/path"); + }); + + it("returns the same state reference when nothing changes", () => { + const initial = withRecords([ + createBrowserRecord({ browserId: "b1", initialUrl: "https://a.test", now: 0 }), + ]); + + const next = applyBrowserPatch(initial, "b1", { url: "https://a.test", title: "" }); + + expect(next).toBe(initial); + }); + + it("returns the same state when the browser id is unknown", () => { + const initial = withRecords([ + createBrowserRecord({ browserId: "b1", initialUrl: "https://a.test", now: 0 }), + ]); + + const next = applyBrowserPatch(initial, "missing", { title: "x" }); + + expect(next).toBe(initial); + }); + + it("returns the same state when the browser id is blank", () => { + const initial = withRecords([ + createBrowserRecord({ browserId: "b1", initialUrl: "https://a.test", now: 0 }), + ]); + + expect(applyBrowserPatch(initial, " ", { title: "x" })).toBe(initial); + }); +}); + +describe("removeBrowserFromIndex", () => { + it("removes the named browser", () => { + const initial = withRecords([ + createBrowserRecord({ browserId: "b1", initialUrl: "https://a.test", now: 0 }), + createBrowserRecord({ browserId: "b2", initialUrl: "https://b.test", now: 0 }), + ]); + + const next = removeBrowserFromIndex(initial, "b1"); + + expect(Object.keys(next.browsersById)).toEqual(["b2"]); + }); + + it("returns the same state when the browser id is unknown", () => { + const initial = withRecords([ + createBrowserRecord({ browserId: "b1", initialUrl: "https://a.test", now: 0 }), + ]); + + expect(removeBrowserFromIndex(initial, "missing")).toBe(initial); + }); + + it("returns the same state when the browser id is blank", () => { + const initial = withRecords([ + createBrowserRecord({ browserId: "b1", initialUrl: "https://a.test", now: 0 }), + ]); + + expect(removeBrowserFromIndex(initial, " ")).toBe(initial); + }); +}); + +describe("sanitizeBrowsersForPersist", () => { + it("clears transient fields on every record", () => { + const base = createBrowserRecord({ browserId: "b1", initialUrl: "https://a.test", now: 0 }); + const state: BrowserIndexState = { + browsersById: { + b1: { ...base, isLoading: true, lastError: "network down" }, + }, + }; + + const persisted = sanitizeBrowsersForPersist(state); + + expect(persisted.browsersById.b1?.isLoading).toBe(false); + expect(persisted.browsersById.b1?.lastError).toBe(null); + }); +}); diff --git a/packages/app/src/stores/browser-store/state.ts b/packages/app/src/stores/browser-store/state.ts new file mode 100644 index 000000000..1c4125e17 --- /dev/null +++ b/packages/app/src/stores/browser-store/state.ts @@ -0,0 +1,130 @@ +export interface BrowserRecord { + browserId: string; + url: string; + title: string; + isLoading: boolean; + canGoBack: boolean; + canGoForward: boolean; + faviconUrl: string | null; + lastError: string | null; + createdAt: number; +} + +export type BrowserRecordPatch = Partial>; + +export interface BrowserIndexState { + browsersById: Record; +} + +export function trimNonEmpty(value: string | null | undefined): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +export function normalizeBrowserUrl(value: string | null | undefined): string { + const trimmed = trimNonEmpty(value); + if (!trimmed) { + return "https://example.com"; + } + if (/^(localhost|\d{1,3}(?:\.\d{1,3}){3}|\[[\da-fA-F:.]+])(?::\d+)?(?:[/?#]|$)/.test(trimmed)) { + return `http://${trimmed}`; + } + if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(trimmed)) { + return trimmed; + } + if (trimmed.startsWith("//")) { + return `https:${trimmed}`; + } + return `https://${trimmed}`; +} + +export function createBrowserRecord(input: { + browserId: string; + initialUrl: string | null | undefined; + now: number; +}): BrowserRecord { + return { + browserId: input.browserId, + url: normalizeBrowserUrl(input.initialUrl), + title: "", + isLoading: false, + canGoBack: false, + canGoForward: false, + faviconUrl: null, + lastError: null, + createdAt: input.now, + }; +} + +export function applyBrowserPatch( + state: S, + browserId: string, + patch: BrowserRecordPatch, +): S { + const normalizedBrowserId = trimNonEmpty(browserId); + if (!normalizedBrowserId) { + return state; + } + const existing = state.browsersById[normalizedBrowserId]; + if (!existing) { + return state; + } + + const nextRecord: BrowserRecord = { + ...existing, + ...patch, + url: normalizeBrowserUrl(patch.url ?? existing.url), + }; + + if ( + nextRecord.url === existing.url && + nextRecord.title === existing.title && + nextRecord.isLoading === existing.isLoading && + nextRecord.canGoBack === existing.canGoBack && + nextRecord.canGoForward === existing.canGoForward && + nextRecord.faviconUrl === existing.faviconUrl && + nextRecord.lastError === existing.lastError + ) { + return state; + } + + return { + ...state, + browsersById: { + ...state.browsersById, + [normalizedBrowserId]: nextRecord, + }, + }; +} + +export function removeBrowserFromIndex( + state: S, + browserId: string, +): S { + const normalizedBrowserId = trimNonEmpty(browserId); + if (!normalizedBrowserId) { + return state; + } + if (!state.browsersById[normalizedBrowserId]) { + return state; + } + const next = { ...state.browsersById }; + delete next[normalizedBrowserId]; + return { ...state, browsersById: next }; +} + +export function sanitizeBrowsersForPersist(state: BrowserIndexState): { + browsersById: Record; +} { + return { + browsersById: Object.fromEntries( + Object.entries(state.browsersById).map(([browserId, browser]) => [ + browserId, + { ...browser, isLoading: false, lastError: null }, + ]), + ), + }; +} diff --git a/packages/app/src/stores/draft-store.ts b/packages/app/src/stores/draft-store/index.ts similarity index 52% rename from packages/app/src/stores/draft-store.ts rename to packages/app/src/stores/draft-store/index.ts index f56941e6d..6199772ac 100644 --- a/packages/app/src/stores/draft-store.ts +++ b/packages/app/src/stores/draft-store/index.ts @@ -1,8 +1,7 @@ import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import AsyncStorage from "@react-native-async-storage/async-storage"; -import type { AttachmentMetadata, UserComposerAttachment } from "@/attachments/types"; -import { GitHubSearchItemSchema } from "@getpaseo/protocol/messages"; +import type { AttachmentMetadata } from "@/attachments/types"; import { garbageCollectAttachments, persistAttachmentFromDataUrl, @@ -10,37 +9,24 @@ import { } from "@/attachments/service"; import { useCreateFlowStore } from "@/stores/create-flow-store"; import { useSessionStore, type SessionState } from "@/stores/session-store"; +import { + applyClearDraftRecord, + collectReferencedAttachmentIdsFromState, + DRAFT_STORE_VERSION, + isAttachmentMetadata, + isCanonicalDraftInput, + isLegacyDraftImage, + normalizeComposerAttachment, + pruneFinalizedDraftRecords, + toDraftInputIfReady, + type DraftInput, + type DraftLifecycleState, + type DraftRecord, + type DraftStoreState, +} from "./state"; +import { migrateDraftInput, migratePersistedState, type MigrateLegacyImages } from "./migration"; -const DRAFT_STORE_VERSION = 4; -const FINALIZED_DRAFT_TTL_MS = 5 * 60 * 1000; - -interface LegacyDraftImage { - uri: string; - mimeType?: string; -} - -type PersistedDraftImage = AttachmentMetadata | LegacyDraftImage; - -export interface DraftInput { - text: string; - attachments: UserComposerAttachment[]; -} - -export type DraftLifecycleState = "active" | "abandoned" | "sent"; - -type CanonicalDraftInput = DraftInput; - -interface DraftRecord { - input: CanonicalDraftInput; - lifecycle: DraftLifecycleState; - updatedAt: number; - version: number; -} - -interface DraftStoreState { - drafts: Record; - createModalDraft: DraftRecord | null; -} +export type { DraftInput, DraftLifecycleState } from "./state"; interface DraftStoreActions { getDraftInput: (draftKey: string) => DraftInput | undefined; @@ -79,189 +65,44 @@ function createDraftRecord(input: { }; } -function isAttachmentMetadata(value: unknown): value is AttachmentMetadata { - if (!value || typeof value !== "object") { - return false; +const migrateLegacyImages: MigrateLegacyImages = async (images) => { + if (images.length === 0) { + return []; } - const record = value as Record; - return ( - typeof record.id === "string" && - typeof record.mimeType === "string" && - typeof record.storageType === "string" && - typeof record.storageKey === "string" && - typeof record.createdAt === "number" - ); -} -function isLegacyDraftImage(value: unknown): value is LegacyDraftImage { - if (!value || typeof value !== "object") { - return false; - } - const record = value as Record; - return typeof record.uri === "string"; -} - -function normalizeAttachmentMetadata(image: AttachmentMetadata): AttachmentMetadata { - return { - id: image.id, - mimeType: image.mimeType, - storageType: image.storageType, - storageKey: image.storageKey, - createdAt: image.createdAt, - ...(typeof image.fileName === "string" || image.fileName === null - ? { fileName: image.fileName } - : {}), - ...(typeof image.byteSize === "number" || image.byteSize === null - ? { byteSize: image.byteSize } - : {}), - }; -} - -function normalizePersistedImage(value: unknown): PersistedDraftImage | null { - if (isAttachmentMetadata(value)) { - return normalizeAttachmentMetadata(value); - } - if (isLegacyDraftImage(value)) { - return { - uri: value.uri, - ...(value.mimeType ? { mimeType: value.mimeType } : {}), - }; - } - return null; -} - -function isUserComposerAttachment(value: unknown): value is UserComposerAttachment { - if (!value || typeof value !== "object") { - return false; - } - const record = value as Record; - if (record.kind === "image") { - const metadata = record.metadata; - return isAttachmentMetadata(metadata); - } - if (record.kind !== "github_issue" && record.kind !== "github_pr") { - return false; - } - return GitHubSearchItemSchema.safeParse(record.item).success; -} - -function normalizeComposerAttachment(attachment: UserComposerAttachment): UserComposerAttachment { - if (attachment.kind === "image") { - return { - kind: "image", - metadata: normalizeAttachmentMetadata(attachment.metadata), - }; - } - return attachment; -} - -function normalizePersistedComposerAttachment(value: unknown): UserComposerAttachment | null { - if (!isUserComposerAttachment(value)) { - return null; - } - return normalizeComposerAttachment(value); -} - -function legacyImagesToAttachments( - images: readonly AttachmentMetadata[], -): UserComposerAttachment[] { - return images.map((metadata) => ({ - kind: "image", - metadata, - })); -} - -function isCanonicalDraftInput(value: unknown): value is CanonicalDraftInput { - if (!value || typeof value !== "object") { - return false; - } - const input = value as Record; - // COMPAT(draft-cwd): accept legacy persisted drafts that include cwd. Stop accepting after 2026-11-09. - return ( - typeof input.text === "string" && - Array.isArray(input.attachments) && - input.attachments.every(isUserComposerAttachment) - ); -} - -function toDraftInputIfReady(record: DraftRecord | null | undefined): DraftInput | undefined { - if (!record) { - return undefined; - } - if (record.lifecycle !== "active") { - return undefined; - } - if (!isCanonicalDraftInput(record.input)) { - return undefined; - } - return { - text: record.input.text, - attachments: record.input.attachments.map(normalizeComposerAttachment), - }; -} - -function collectReferencedAttachmentIdsFromState(state: DraftStoreState): Set { - const referencedIds = new Set(); - - for (const draftRecord of Object.values(state.drafts)) { - if (draftRecord.lifecycle !== "active") { - continue; - } - if (!isCanonicalDraftInput(draftRecord.input)) { - continue; - } - for (const attachment of draftRecord.input.attachments) { - if (attachment.kind === "image") { - referencedIds.add(attachment.metadata.id); + const migrated = await Promise.all( + images.map(async (entry) => { + if (isAttachmentMetadata(entry)) { + return entry; } - } - } - - const modalRecord = state.createModalDraft; - if (modalRecord?.lifecycle === "active" && isCanonicalDraftInput(modalRecord.input)) { - for (const attachment of modalRecord.input.attachments) { - if (attachment.kind === "image") { - referencedIds.add(attachment.metadata.id); + if (!isLegacyDraftImage(entry)) { + return null; } - } - } - return referencedIds; -} + try { + if (entry.uri.startsWith("data:")) { + return await persistAttachmentFromDataUrl({ + dataUrl: entry.uri, + mimeType: entry.mimeType, + }); + } -function pruneFinalizedDraftRecords(input: { - drafts: Record; - nowMs: number; -}): Record { - let changed = false; - const next: Record = {}; - for (const [draftKey, record] of Object.entries(input.drafts)) { - if (record.lifecycle !== "active" && input.nowMs - record.updatedAt >= FINALIZED_DRAFT_TTL_MS) { - changed = true; - continue; - } - next[draftKey] = record; - } - return changed ? next : input.drafts; -} + return await persistAttachmentFromFileUri({ + uri: entry.uri, + mimeType: entry.mimeType, + }); + } catch (error) { + console.warn("[DraftStore] Failed to migrate legacy draft attachment", { + uri: entry.uri, + error, + }); + return null; + } + }), + ); -function applyClearDraftRecord(input: { - record: DraftRecord; - lifecycle?: Exclude; - nowMs: number; -}): DraftRecord | null { - if (!input.lifecycle) { - return null; - } - - return { - ...input.record, - input: { text: "", attachments: [] }, - lifecycle: input.lifecycle, - updatedAt: input.nowMs, - version: input.record.version + 1, - }; -} + return migrated.filter((entry): entry is AttachmentMetadata => entry !== null); +}; async function runAttachmentGc(): Promise { gcScheduled = false; @@ -370,120 +211,6 @@ async function migrateAllLegacyDrafts(): Promise { } } -async function migrateLegacyImages( - images: readonly PersistedDraftImage[], -): Promise { - if (images.length === 0) { - return []; - } - - const migrated = await Promise.all( - images.map(async (entry) => { - if (isAttachmentMetadata(entry)) { - return entry; - } - if (!isLegacyDraftImage(entry)) { - return null; - } - - try { - if (entry.uri.startsWith("data:")) { - return await persistAttachmentFromDataUrl({ - dataUrl: entry.uri, - mimeType: entry.mimeType, - }); - } - - return await persistAttachmentFromFileUri({ - uri: entry.uri, - mimeType: entry.mimeType, - }); - } catch (error) { - console.warn("[DraftStore] Failed to migrate legacy draft attachment", { - uri: entry.uri, - error, - }); - return null; - } - }), - ); - - return migrated.filter((entry): entry is AttachmentMetadata => entry !== null); -} - -async function migrateDraftInput(input: { rawInput: unknown }): Promise { - const rawInput = - input.rawInput && typeof input.rawInput === "object" - ? (input.rawInput as Record) - : {}; - const attachments = Array.isArray(rawInput.attachments) - ? rawInput.attachments - .map((entry) => normalizePersistedComposerAttachment(entry)) - .filter((entry): entry is UserComposerAttachment => entry !== null) - : []; - const legacyImages = Array.isArray(rawInput.images) - ? rawInput.images - .map((entry) => normalizePersistedImage(entry)) - .filter((entry): entry is PersistedDraftImage => entry !== null) - : []; - const migratedImages = await migrateLegacyImages(legacyImages); - - return { - text: typeof rawInput.text === "string" ? rawInput.text : "", - attachments: [...attachments, ...legacyImagesToAttachments(migratedImages)], - }; -} - -function resolvePersistedLifecycle(lifecycle: unknown): DraftLifecycleState { - if (lifecycle === "sent" || lifecycle === "abandoned") { - return lifecycle as DraftLifecycleState; - } - return "active"; -} - -function extractRawInput(record: Record): unknown { - if ("input" in record && record.input && typeof record.input === "object") { - return record.input; - } - return record; -} - -async function buildMigratedDraftRecord(parsed: Record): Promise { - return { - input: await migrateDraftInput({ rawInput: extractRawInput(parsed) }), - lifecycle: resolvePersistedLifecycle(parsed.lifecycle), - updatedAt: typeof parsed.updatedAt === "number" ? parsed.updatedAt : Date.now(), - version: typeof parsed.version === "number" ? parsed.version : 1, - }; -} - -async function migratePersistedState(state: unknown): Promise { - const input = (state ?? {}) as { - drafts?: Record; - createModalDraft?: unknown; - }; - - const nextDrafts: Record = {}; - for (const [draftKey, rawRecord] of Object.entries(input.drafts ?? {})) { - if (!rawRecord || typeof rawRecord !== "object") { - continue; - } - nextDrafts[draftKey] = await buildMigratedDraftRecord(rawRecord as Record); - } - - let createModalDraft: DraftRecord | null = null; - if (input.createModalDraft && typeof input.createModalDraft === "object") { - createModalDraft = await buildMigratedDraftRecord( - input.createModalDraft as Record, - ); - } - - return { - drafts: nextDrafts, - createModalDraft, - }; -} - export const useDraftStore = create()( persist( (set, get) => ({ @@ -508,9 +235,10 @@ export const useDraftStore = create()( return ready; } - const migratedDraft = await migrateDraftInput({ - rawInput: current.input, - }); + const migratedDraft = await migrateDraftInput( + { rawInput: current.input }, + { migrateLegacyImages }, + ); set((state) => { const existing = state.drafts[draftKey]; @@ -639,7 +367,10 @@ export const useDraftStore = create()( version: DRAFT_STORE_VERSION, storage: createJSONStorage(() => AsyncStorage), migrate: (persistedState) => { - return migratePersistedState(persistedState); + return migratePersistedState(persistedState, { + migrateLegacyImages, + nowMs: Date.now(), + }); }, onRehydrateStorage: () => { return () => { @@ -650,11 +381,3 @@ export const useDraftStore = create()( }, ), ); - -export const __draftStoreTestUtils = { - migrateDraftInput, - migratePersistedState, - normalizeAttachmentMetadata, - pruneFinalizedDraftRecords, - applyClearDraftRecord, -}; diff --git a/packages/app/src/stores/draft-store.migration.test.ts b/packages/app/src/stores/draft-store/migration.test.ts similarity index 63% rename from packages/app/src/stores/draft-store.migration.test.ts rename to packages/app/src/stores/draft-store/migration.test.ts index b445ede65..0ddee1fe0 100644 --- a/packages/app/src/stores/draft-store.migration.test.ts +++ b/packages/app/src/stores/draft-store/migration.test.ts @@ -1,7 +1,10 @@ -import "@/test/window-local-storage"; -import { beforeEach, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import type { ComposerAttachment } from "@/attachments/types"; -import { __draftStoreTestUtils, useDraftStore } from "./draft-store"; +import { migratePersistedState, type MigrateLegacyImages } from "./migration"; +import { isAttachmentMetadata } from "./state"; + +const passThroughMigrateLegacyImages: MigrateLegacyImages = async (images) => + images.filter(isAttachmentMetadata); function workspaceReviewAttachment(): Extract { return { @@ -44,34 +47,33 @@ function workspaceReviewAttachment(): Extract { - beforeEach(() => { - useDraftStore.setState({ drafts: {}, createModalDraft: null }); - }); - it("normalizes legacy image metadata into image attachments and strips persisted preview URLs", async () => { - const migrated = await __draftStoreTestUtils.migratePersistedState({ - drafts: { - "agent:server:agent": { - input: { - text: "hello", - images: [ - { - id: "att-1", - mimeType: "image/png", - storageType: "desktop-file", - storageKey: "/tmp/att-1.png", - createdAt: 1700000000000, - previewUri: "asset://should-not-persist", - }, - ], + const migrated = await migratePersistedState( + { + drafts: { + "agent:server:agent": { + input: { + text: "hello", + images: [ + { + id: "att-1", + mimeType: "image/png", + storageType: "desktop-file", + storageKey: "/tmp/att-1.png", + createdAt: 1700000000000, + previewUri: "asset://should-not-persist", + }, + ], + }, + lifecycle: "active", + updatedAt: 1700000000001, + version: 1, }, - lifecycle: "active", - updatedAt: 1700000000001, - version: 1, }, + createModalDraft: null, }, - createModalDraft: null, - }); + { migrateLegacyImages: passThroughMigrateLegacyImages, nowMs: 1700000000002 }, + ); expect(migrated.drafts["agent:server:agent"]?.input).toEqual({ text: "hello", @@ -118,8 +120,9 @@ describe("draft-store migration", () => { createModalDraft: null, }; - const once = await __draftStoreTestUtils.migratePersistedState(original); - const twice = await __draftStoreTestUtils.migratePersistedState(once); + const ports = { migrateLegacyImages: passThroughMigrateLegacyImages, nowMs: 1700000000002 }; + const once = await migratePersistedState(original, ports); + const twice = await migratePersistedState(once, ports); expect(twice).toEqual(once); expect(twice.drafts["agent:server:agent"]?.input).toEqual({ @@ -140,20 +143,23 @@ describe("draft-store migration", () => { }); it("rejects workspace review attachments from migrated draft attachments", async () => { - const migrated = await __draftStoreTestUtils.migratePersistedState({ - drafts: { - "agent:server:agent": { - input: { - text: "hello", - attachments: [workspaceReviewAttachment()], + const migrated = await migratePersistedState( + { + drafts: { + "agent:server:agent": { + input: { + text: "hello", + attachments: [workspaceReviewAttachment()], + }, + lifecycle: "active", + updatedAt: 1700000000001, + version: 2, }, - lifecycle: "active", - updatedAt: 1700000000001, - version: 2, }, + createModalDraft: null, }, - createModalDraft: null, - }); + { migrateLegacyImages: passThroughMigrateLegacyImages, nowMs: 1700000000002 }, + ); expect(migrated.drafts["agent:server:agent"]?.input.attachments).toEqual([]); }); diff --git a/packages/app/src/stores/draft-store/migration.ts b/packages/app/src/stores/draft-store/migration.ts new file mode 100644 index 000000000..015c1ca82 --- /dev/null +++ b/packages/app/src/stores/draft-store/migration.ts @@ -0,0 +1,135 @@ +import type { AttachmentMetadata, UserComposerAttachment } from "@/attachments/types"; +import { + isAttachmentMetadata, + isLegacyDraftImage, + isUserComposerAttachment, + normalizeAttachmentMetadata, + normalizeComposerAttachment, + type CanonicalDraftInput, + type DraftLifecycleState, + type DraftRecord, + type DraftStoreState, + type PersistedDraftImage, +} from "./state"; + +export type MigrateLegacyImages = ( + images: readonly PersistedDraftImage[], +) => Promise; + +function normalizePersistedImage(value: unknown): PersistedDraftImage | null { + if (isAttachmentMetadata(value)) { + return normalizeAttachmentMetadata(value); + } + if (isLegacyDraftImage(value)) { + return { + uri: value.uri, + ...(value.mimeType ? { mimeType: value.mimeType } : {}), + }; + } + return null; +} + +function normalizePersistedComposerAttachment(value: unknown): UserComposerAttachment | null { + if (!isUserComposerAttachment(value)) { + return null; + } + return normalizeComposerAttachment(value); +} + +function legacyImagesToAttachments( + images: readonly AttachmentMetadata[], +): UserComposerAttachment[] { + return images.map((metadata) => ({ + kind: "image", + metadata, + })); +} + +export async function migrateDraftInput( + input: { rawInput: unknown }, + ports: { migrateLegacyImages: MigrateLegacyImages }, +): Promise { + const rawInput = + input.rawInput && typeof input.rawInput === "object" + ? (input.rawInput as Record) + : {}; + const attachments = Array.isArray(rawInput.attachments) + ? rawInput.attachments + .map((entry) => normalizePersistedComposerAttachment(entry)) + .filter((entry): entry is UserComposerAttachment => entry !== null) + : []; + const legacyImages = Array.isArray(rawInput.images) + ? rawInput.images + .map((entry) => normalizePersistedImage(entry)) + .filter((entry): entry is PersistedDraftImage => entry !== null) + : []; + const migratedImages = await ports.migrateLegacyImages(legacyImages); + + return { + text: typeof rawInput.text === "string" ? rawInput.text : "", + attachments: [...attachments, ...legacyImagesToAttachments(migratedImages)], + }; +} + +function resolvePersistedLifecycle(lifecycle: unknown): DraftLifecycleState { + if (lifecycle === "sent" || lifecycle === "abandoned") { + return lifecycle as DraftLifecycleState; + } + return "active"; +} + +function extractRawInput(record: Record): unknown { + if ("input" in record && record.input && typeof record.input === "object") { + return record.input; + } + return record; +} + +async function buildMigratedDraftRecord( + parsed: Record, + ports: { migrateLegacyImages: MigrateLegacyImages }, + nowMs: number, +): Promise { + return { + input: await migrateDraftInput({ rawInput: extractRawInput(parsed) }, ports), + lifecycle: resolvePersistedLifecycle(parsed.lifecycle), + updatedAt: typeof parsed.updatedAt === "number" ? parsed.updatedAt : nowMs, + version: typeof parsed.version === "number" ? parsed.version : 1, + }; +} + +export async function migratePersistedState( + state: unknown, + ports: { migrateLegacyImages: MigrateLegacyImages; nowMs: number }, +): Promise { + const input = (state ?? {}) as { + drafts?: Record; + createModalDraft?: unknown; + }; + + const nextDrafts: Record = {}; + for (const [draftKey, rawRecord] of Object.entries(input.drafts ?? {})) { + if (!rawRecord || typeof rawRecord !== "object") { + continue; + } + nextDrafts[draftKey] = await buildMigratedDraftRecord( + rawRecord as Record, + ports, + ports.nowMs, + ); + } + + let createModalDraft: DraftRecord | null = null; + if (input.createModalDraft && typeof input.createModalDraft === "object") { + createModalDraft = await buildMigratedDraftRecord( + input.createModalDraft as Record, + ports, + ports.nowMs, + ); + } + + return { + drafts: nextDrafts, + createModalDraft, + }; +} diff --git a/packages/app/src/stores/draft-store.lifecycle.test.ts b/packages/app/src/stores/draft-store/state.test.ts similarity index 87% rename from packages/app/src/stores/draft-store.lifecycle.test.ts rename to packages/app/src/stores/draft-store/state.test.ts index f79fdf13e..996292d13 100644 --- a/packages/app/src/stores/draft-store.lifecycle.test.ts +++ b/packages/app/src/stores/draft-store/state.test.ts @@ -1,6 +1,5 @@ -import "@/test/window-local-storage"; import { describe, expect, it } from "vitest"; -import { __draftStoreTestUtils } from "./draft-store"; +import { applyClearDraftRecord, pruneFinalizedDraftRecords } from "./state"; describe("draft-store lifecycle", () => { it("prunes finalized tombstones after TTL", () => { @@ -26,7 +25,7 @@ describe("draft-store lifecycle", () => { }, }; - const pruned = __draftStoreTestUtils.pruneFinalizedDraftRecords({ + const pruned = pruneFinalizedDraftRecords({ drafts, nowMs: nowMs + 6 * 60 * 1000, }); @@ -37,7 +36,7 @@ describe("draft-store lifecycle", () => { }); it("normalizes clear-with-lifecycle into a tombstone without attachments", () => { - const cleared = __draftStoreTestUtils.applyClearDraftRecord({ + const cleared = applyClearDraftRecord({ record: { input: { text: "hello", diff --git a/packages/app/src/stores/draft-store/state.ts b/packages/app/src/stores/draft-store/state.ts new file mode 100644 index 000000000..647fc59eb --- /dev/null +++ b/packages/app/src/stores/draft-store/state.ts @@ -0,0 +1,192 @@ +import type { AttachmentMetadata, UserComposerAttachment } from "@/attachments/types"; +import { GitHubSearchItemSchema } from "@getpaseo/protocol/messages"; + +export const DRAFT_STORE_VERSION = 4; +export const FINALIZED_DRAFT_TTL_MS = 5 * 60 * 1000; + +export interface LegacyDraftImage { + uri: string; + mimeType?: string; +} + +export type PersistedDraftImage = AttachmentMetadata | LegacyDraftImage; + +export interface DraftInput { + text: string; + attachments: UserComposerAttachment[]; +} + +export type DraftLifecycleState = "active" | "abandoned" | "sent"; + +export type CanonicalDraftInput = DraftInput; + +export interface DraftRecord { + input: CanonicalDraftInput; + lifecycle: DraftLifecycleState; + updatedAt: number; + version: number; +} + +export interface DraftStoreState { + drafts: Record; + createModalDraft: DraftRecord | null; +} + +export function isAttachmentMetadata(value: unknown): value is AttachmentMetadata { + if (!value || typeof value !== "object") { + return false; + } + const record = value as Record; + return ( + typeof record.id === "string" && + typeof record.mimeType === "string" && + typeof record.storageType === "string" && + typeof record.storageKey === "string" && + typeof record.createdAt === "number" + ); +} + +export function isLegacyDraftImage(value: unknown): value is LegacyDraftImage { + if (!value || typeof value !== "object") { + return false; + } + const record = value as Record; + return typeof record.uri === "string"; +} + +export function normalizeAttachmentMetadata(image: AttachmentMetadata): AttachmentMetadata { + return { + id: image.id, + mimeType: image.mimeType, + storageType: image.storageType, + storageKey: image.storageKey, + createdAt: image.createdAt, + ...(typeof image.fileName === "string" || image.fileName === null + ? { fileName: image.fileName } + : {}), + ...(typeof image.byteSize === "number" || image.byteSize === null + ? { byteSize: image.byteSize } + : {}), + }; +} + +export function isUserComposerAttachment(value: unknown): value is UserComposerAttachment { + if (!value || typeof value !== "object") { + return false; + } + const record = value as Record; + if (record.kind === "image") { + const metadata = record.metadata; + return isAttachmentMetadata(metadata); + } + if (record.kind !== "github_issue" && record.kind !== "github_pr") { + return false; + } + return GitHubSearchItemSchema.safeParse(record.item).success; +} + +export function normalizeComposerAttachment( + attachment: UserComposerAttachment, +): UserComposerAttachment { + if (attachment.kind === "image") { + return { + kind: "image", + metadata: normalizeAttachmentMetadata(attachment.metadata), + }; + } + return attachment; +} + +export function isCanonicalDraftInput(value: unknown): value is CanonicalDraftInput { + if (!value || typeof value !== "object") { + return false; + } + const input = value as Record; + // COMPAT(draft-cwd): accept legacy persisted drafts that include cwd. Stop accepting after 2026-11-09. + return ( + typeof input.text === "string" && + Array.isArray(input.attachments) && + input.attachments.every(isUserComposerAttachment) + ); +} + +export function toDraftInputIfReady( + record: DraftRecord | null | undefined, +): DraftInput | undefined { + if (!record) { + return undefined; + } + if (record.lifecycle !== "active") { + return undefined; + } + if (!isCanonicalDraftInput(record.input)) { + return undefined; + } + return { + text: record.input.text, + attachments: record.input.attachments.map(normalizeComposerAttachment), + }; +} + +export function collectReferencedAttachmentIdsFromState(state: DraftStoreState): Set { + const referencedIds = new Set(); + + for (const draftRecord of Object.values(state.drafts)) { + if (draftRecord.lifecycle !== "active") { + continue; + } + if (!isCanonicalDraftInput(draftRecord.input)) { + continue; + } + for (const attachment of draftRecord.input.attachments) { + if (attachment.kind === "image") { + referencedIds.add(attachment.metadata.id); + } + } + } + + const modalRecord = state.createModalDraft; + if (modalRecord?.lifecycle === "active" && isCanonicalDraftInput(modalRecord.input)) { + for (const attachment of modalRecord.input.attachments) { + if (attachment.kind === "image") { + referencedIds.add(attachment.metadata.id); + } + } + } + + return referencedIds; +} + +export function pruneFinalizedDraftRecords(input: { + drafts: Record; + nowMs: number; +}): Record { + let changed = false; + const next: Record = {}; + for (const [draftKey, record] of Object.entries(input.drafts)) { + if (record.lifecycle !== "active" && input.nowMs - record.updatedAt >= FINALIZED_DRAFT_TTL_MS) { + changed = true; + continue; + } + next[draftKey] = record; + } + return changed ? next : input.drafts; +} + +export function applyClearDraftRecord(input: { + record: DraftRecord; + lifecycle?: Exclude; + nowMs: number; +}): DraftRecord | null { + if (!input.lifecycle) { + return null; + } + + return { + ...input.record, + input: { text: "", attachments: [] }, + lifecycle: input.lifecycle, + updatedAt: input.nowMs, + version: input.record.version + 1, + }; +} diff --git a/packages/app/src/stores/navigation-active-workspace-store.test.ts b/packages/app/src/stores/navigation-active-workspace-store.test.ts deleted file mode 100644 index 3fbb93f7b..000000000 --- a/packages/app/src/stores/navigation-active-workspace-store.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import { renderHook } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const routerMock = vi.hoisted(() => ({ - dismissTo: vi.fn(), -})); -const pathnameState = vi.hoisted(() => ({ - value: "/", -})); -const localParamsState = vi.hoisted(() => ({ - value: {} as { serverId?: string | string[]; workspaceId?: string | string[] }, -})); -const asyncStorageMock = vi.hoisted(() => ({ - getItem: vi.fn(async () => null as string | null), - setItem: vi.fn(async () => undefined), -})); - -vi.mock("expo-router", () => ({ - router: routerMock, - useLocalSearchParams: () => localParamsState.value, - usePathname: () => pathnameState.value, -})); - -vi.mock("@react-native-async-storage/async-storage", () => ({ - default: asyncStorageMock, -})); - -import { - navigateToLastWorkspace, - navigateToWorkspace, - useActiveWorkspaceSelection, -} from "@/stores/navigation-active-workspace-store"; - -describe("workspace navigation", () => { - beforeEach(() => { - routerMock.dismissTo.mockReset(); - asyncStorageMock.setItem.mockClear(); - pathnameState.value = "/"; - localParamsState.value = {}; - }); - - it("reports when no last workspace is known", () => { - expect(navigateToLastWorkspace()).toBe(false); - }); - - it("navigates to a workspace route", () => { - navigateToWorkspace("server-1", "workspace-a"); - - expect(routerMock.dismissTo).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a"); - expect(asyncStorageMock.setItem).toHaveBeenCalledWith( - "paseo:last-workspace-route-selection", - JSON.stringify({ serverId: "server-1", workspaceId: "workspace-a" }), - ); - }); - - it("reads the active workspace from the current route", () => { - pathnameState.value = "/h/server-1/workspace/workspace-a"; - - const { result } = renderHook(() => useActiveWorkspaceSelection()); - - expect(result.current).toEqual({ - serverId: "server-1", - workspaceId: "workspace-a", - }); - }); - - it("falls back to workspace route params during cold route mount", () => { - localParamsState.value = { - serverId: "server-1", - workspaceId: "b64_L3RtcC9wYXNlby1taXNzaW5nLXdvcmtzcGFjZQ", - }; - - const { result } = renderHook(() => useActiveWorkspaceSelection()); - - expect(result.current).toEqual({ - serverId: "server-1", - workspaceId: "/tmp/paseo-missing-workspace", - }); - }); - - it("navigates to the last workspace observed by the route reader", () => { - pathnameState.value = "/h/server-1/workspace/workspace-a"; - renderHook(() => useActiveWorkspaceSelection()); - - expect(navigateToLastWorkspace()).toBe(true); - expect(routerMock.dismissTo).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a"); - }); -}); diff --git a/packages/app/src/stores/navigation-active-workspace-store.ts b/packages/app/src/stores/navigation-active-workspace-store/index.ts similarity index 53% rename from packages/app/src/stores/navigation-active-workspace-store.ts rename to packages/app/src/stores/navigation-active-workspace-store/index.ts index 698e1a148..ba07f70a7 100644 --- a/packages/app/src/stores/navigation-active-workspace-store.ts +++ b/packages/app/src/stores/navigation-active-workspace-store/index.ts @@ -6,18 +6,14 @@ import { type ActiveWorkspaceSelection, type LastWorkspaceSelectionStorage, } from "@/stores/last-workspace-selection"; +import { + navigateToLastWorkspace as navigateToLastWorkspacePure, + navigateToWorkspace as navigateToWorkspacePure, + parseActiveWorkspaceSelection, + type NavigateToWorkspaceDeps, +} from "./navigation"; import { useSessionStore } from "@/stores/session-store"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; -import { pickAttentionAgent } from "@/utils/agent-attention"; -import { - buildHostWorkspaceRoute, - decodeWorkspaceIdFromPathSegment, - parseHostWorkspaceRouteFromPathname, -} from "@/utils/host-routes"; -import { - resolveWorkspaceIdByExecutionDirectory, - resolveWorkspaceMapKeyByIdentity, -} from "@/utils/workspace-execution"; export type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection"; @@ -36,6 +32,19 @@ const lastWorkspaceSelectionStore = createLastWorkspaceSelectionStore( lastWorkspaceSelectionStorage, ); +function navigateDeps(): NavigateToWorkspaceDeps { + return { + getSessionWorkspaces: (serverId) => useSessionStore.getState().sessions[serverId]?.workspaces, + getSessionAgents: (serverId) => + useSessionStore.getState().sessions[serverId]?.agents.values() ?? [], + openWorkspaceAgentTab: (workspaceKey, agentId) => { + useWorkspaceLayoutStore.getState().openTabFocused(workspaceKey, { kind: "agent", agentId }); + }, + rememberLastWorkspace: (selection) => lastWorkspaceSelectionStore.remember(selection), + navigateToRoute: (route) => router.dismissTo(route as Href), + }; +} + export function hydrateLastWorkspaceSelection(): Promise { return lastWorkspaceSelectionStore.hydrate(); } @@ -48,69 +57,19 @@ export function getIsLastWorkspaceSelectionHydrated(): boolean { return lastWorkspaceSelectionStore.isHydrated(); } -function getParamValue(value: string | string[] | undefined): string { - if (typeof value === "string") { - return value.trim(); - } - if (Array.isArray(value)) { - const firstValue = value[0]; - return typeof firstValue === "string" ? firstValue.trim() : ""; - } - return ""; -} - -function parseWorkspaceSelectionFromRouteParams(params: { - serverId?: string | string[]; - workspaceId?: string | string[]; -}): ActiveWorkspaceSelection | null { - const serverId = getParamValue(params.serverId); - const workspaceValue = getParamValue(params.workspaceId); - const workspaceId = workspaceValue ? decodeWorkspaceIdFromPathSegment(workspaceValue) : null; - if (!serverId || !workspaceId) { - return null; - } - return { serverId, workspaceId }; -} - export function navigateToWorkspace( serverId: string, workspaceId: string, _options: NavigateToWorkspaceOptions = {}, ) { - const session = useSessionStore.getState().sessions[serverId]; - const resolvedWorkspaceId = resolveWorkspaceMapKeyByIdentity({ - workspaces: session?.workspaces, - workspaceId, - }); - const workspaceAgents = resolvedWorkspaceId - ? Array.from(session?.agents.values() ?? []).filter( - (agent) => - resolveWorkspaceIdByExecutionDirectory({ - workspaces: session?.workspaces?.values(), - workspaceDirectory: agent.cwd, - }) === resolvedWorkspaceId, - ) - : []; - const attentionAgentId = pickAttentionAgent(workspaceAgents); - if (attentionAgentId && resolvedWorkspaceId) { - useWorkspaceLayoutStore.getState().openTabFocused(`${serverId}:${resolvedWorkspaceId}`, { - kind: "agent", - agentId: attentionAgentId, - }); - } - - lastWorkspaceSelectionStore.remember({ serverId, workspaceId }); - const route = buildHostWorkspaceRoute(serverId, workspaceId) as Href; - router.dismissTo(route); + navigateToWorkspacePure(serverId, workspaceId, navigateDeps()); } export function navigateToLastWorkspace(): boolean { - const selection = lastWorkspaceSelectionStore.getSelection(); - if (!selection) { - return false; - } - navigateToWorkspace(selection.serverId, selection.workspaceId); - return true; + return navigateToLastWorkspacePure({ + ...navigateDeps(), + getLastWorkspaceSelection: () => lastWorkspaceSelectionStore.getSelection(), + }); } export function useActiveWorkspaceSelection(): ActiveWorkspaceSelection | null { @@ -118,9 +77,7 @@ export function useActiveWorkspaceSelection(): ActiveWorkspaceSelection | null { serverId?: string | string[]; workspaceId?: string | string[]; }>(); - const selection = - parseHostWorkspaceRouteFromPathname(usePathname()) ?? - parseWorkspaceSelectionFromRouteParams(params); + const selection = parseActiveWorkspaceSelection({ pathname: usePathname(), params }); const serverId = selection?.serverId ?? null; const workspaceId = selection?.workspaceId ?? null; useEffect(() => { diff --git a/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts b/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts new file mode 100644 index 000000000..a84379957 --- /dev/null +++ b/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; +import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection"; +import { + navigateToLastWorkspace, + navigateToWorkspace, + parseActiveWorkspaceSelection, + type NavigateToLastWorkspaceDeps, + type NavigateToWorkspaceDeps, +} from "./navigation"; +import type { Agent, WorkspaceDescriptor } from "@/stores/session-store"; + +interface RecordedAgentTab { + workspaceKey: string; + agentId: string; +} + +function createFakeDeps(overrides: Partial = {}) { + const navigations: string[] = []; + const remembered: ActiveWorkspaceSelection[] = []; + const openedAgentTabs: RecordedAgentTab[] = []; + const deps: NavigateToWorkspaceDeps = { + getSessionWorkspaces: () => null, + getSessionAgents: () => [] as Agent[], + openWorkspaceAgentTab: (workspaceKey, agentId) => + openedAgentTabs.push({ workspaceKey, agentId }), + rememberLastWorkspace: (selection) => remembered.push(selection), + navigateToRoute: (route) => navigations.push(route), + ...overrides, + }; + return { deps, navigations, remembered, openedAgentTabs }; +} + +function createLastSelectionDeps( + initial: ActiveWorkspaceSelection | null, + overrides: Partial = {}, +): { + deps: NavigateToLastWorkspaceDeps; + navigations: string[]; + remembered: ActiveWorkspaceSelection[]; +} { + let lastSelection = initial; + const base = createFakeDeps({ + rememberLastWorkspace: (selection) => { + lastSelection = selection; + base.remembered.push(selection); + }, + ...overrides, + }); + return { + deps: { ...base.deps, getLastWorkspaceSelection: () => lastSelection }, + navigations: base.navigations, + remembered: base.remembered, + }; +} + +describe("workspace navigation", () => { + it("reports when no last workspace is known", () => { + const { deps } = createLastSelectionDeps(null); + + expect(navigateToLastWorkspace(deps)).toBe(false); + }); + + it("navigates to a workspace route and remembers the selection", () => { + const { deps, navigations, remembered } = createFakeDeps(); + + navigateToWorkspace("server-1", "workspace-a", deps); + + expect(navigations).toEqual(["/h/server-1/workspace/workspace-a"]); + expect(remembered).toEqual([{ serverId: "server-1", workspaceId: "workspace-a" }]); + }); + + it("focuses the attention agent's tab when a workspace has one", () => { + const workspace = { + id: "workspace-a", + workspaceDirectory: "/repo/workspace-a", + } as WorkspaceDescriptor; + const agent = { + id: "agent-1", + cwd: "/repo/workspace-a", + requiresAttention: true, + attentionReason: "permission", + } as unknown as Agent; + const { deps, openedAgentTabs } = createFakeDeps({ + getSessionWorkspaces: () => new Map([[workspace.id, workspace]]), + getSessionAgents: () => [agent], + }); + + navigateToWorkspace("server-1", "workspace-a", deps); + + expect(openedAgentTabs).toEqual([{ workspaceKey: "server-1:workspace-a", agentId: "agent-1" }]); + }); + + it("reads the active workspace from the current route", () => { + const selection = parseActiveWorkspaceSelection({ + pathname: "/h/server-1/workspace/workspace-a", + params: {}, + }); + + expect(selection).toEqual({ serverId: "server-1", workspaceId: "workspace-a" }); + }); + + it("falls back to workspace route params during cold route mount", () => { + const selection = parseActiveWorkspaceSelection({ + pathname: "/", + params: { + serverId: "server-1", + workspaceId: "b64_L3RtcC9wYXNlby1taXNzaW5nLXdvcmtzcGFjZQ", + }, + }); + + expect(selection).toEqual({ + serverId: "server-1", + workspaceId: "/tmp/paseo-missing-workspace", + }); + }); + + it("navigates to the last workspace once a route observation has been remembered", () => { + const { deps, navigations } = createLastSelectionDeps(null); + + const observed = parseActiveWorkspaceSelection({ + pathname: "/h/server-1/workspace/workspace-a", + params: {}, + }); + expect(observed).not.toBeNull(); + if (observed) { + deps.rememberLastWorkspace(observed); + } + + expect(navigateToLastWorkspace(deps)).toBe(true); + expect(navigations).toEqual(["/h/server-1/workspace/workspace-a"]); + }); +}); diff --git a/packages/app/src/stores/navigation-active-workspace-store/navigation.ts b/packages/app/src/stores/navigation-active-workspace-store/navigation.ts new file mode 100644 index 000000000..e1dc11bf7 --- /dev/null +++ b/packages/app/src/stores/navigation-active-workspace-store/navigation.ts @@ -0,0 +1,102 @@ +import type { Agent, WorkspaceDescriptor } from "@/stores/session-store"; +import { pickAttentionAgent } from "@/utils/agent-attention"; +import { + buildHostWorkspaceRoute, + decodeWorkspaceIdFromPathSegment, + parseHostWorkspaceRouteFromPathname, +} from "@/utils/host-routes"; +import { + resolveWorkspaceIdByExecutionDirectory, + resolveWorkspaceMapKeyByIdentity, +} from "@/utils/workspace-execution"; +import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection"; + +export interface RouteSelectionInput { + pathname: string; + params: { + serverId?: string | string[]; + workspaceId?: string | string[]; + }; +} + +export interface NavigateToWorkspaceDeps { + getSessionWorkspaces: (serverId: string) => Map | null | undefined; + getSessionAgents: (serverId: string) => Iterable; + openWorkspaceAgentTab: (workspaceKey: string, agentId: string) => void; + rememberLastWorkspace: (selection: ActiveWorkspaceSelection) => void; + navigateToRoute: (route: string) => void; +} + +export interface NavigateToLastWorkspaceDeps extends NavigateToWorkspaceDeps { + getLastWorkspaceSelection: () => ActiveWorkspaceSelection | null; +} + +function getParamValue(value: string | string[] | undefined): string { + if (typeof value === "string") { + return value.trim(); + } + if (Array.isArray(value)) { + const firstValue = value[0]; + return typeof firstValue === "string" ? firstValue.trim() : ""; + } + return ""; +} + +function parseWorkspaceSelectionFromRouteParams(params: { + serverId?: string | string[]; + workspaceId?: string | string[]; +}): ActiveWorkspaceSelection | null { + const serverId = getParamValue(params.serverId); + const workspaceValue = getParamValue(params.workspaceId); + const workspaceId = workspaceValue ? decodeWorkspaceIdFromPathSegment(workspaceValue) : null; + if (!serverId || !workspaceId) { + return null; + } + return { serverId, workspaceId }; +} + +export function parseActiveWorkspaceSelection( + input: RouteSelectionInput, +): ActiveWorkspaceSelection | null { + return ( + parseHostWorkspaceRouteFromPathname(input.pathname) ?? + parseWorkspaceSelectionFromRouteParams(input.params) + ); +} + +export function navigateToWorkspace( + serverId: string, + workspaceId: string, + deps: NavigateToWorkspaceDeps, +): void { + const workspaces = deps.getSessionWorkspaces(serverId); + const resolvedWorkspaceId = resolveWorkspaceMapKeyByIdentity({ + workspaces, + workspaceId, + }); + const workspaceAgents = resolvedWorkspaceId + ? Array.from(deps.getSessionAgents(serverId)).filter( + (agent) => + resolveWorkspaceIdByExecutionDirectory({ + workspaces: workspaces?.values(), + workspaceDirectory: agent.cwd, + }) === resolvedWorkspaceId, + ) + : []; + const attentionAgentId = pickAttentionAgent(workspaceAgents); + if (attentionAgentId && resolvedWorkspaceId) { + deps.openWorkspaceAgentTab(`${serverId}:${resolvedWorkspaceId}`, attentionAgentId); + } + + deps.rememberLastWorkspace({ serverId, workspaceId }); + deps.navigateToRoute(buildHostWorkspaceRoute(serverId, workspaceId)); +} + +export function navigateToLastWorkspace(deps: NavigateToLastWorkspaceDeps): boolean { + const selection = deps.getLastWorkspaceSelection(); + if (!selection) { + return false; + } + navigateToWorkspace(selection.serverId, selection.workspaceId, deps); + return true; +} diff --git a/packages/app/src/stores/panel-store.ts b/packages/app/src/stores/panel-store/index.ts similarity index 59% rename from packages/app/src/stores/panel-store.ts rename to packages/app/src/stores/panel-store/index.ts index c64f157f8..8be293ef3 100644 --- a/packages/app/src/stores/panel-store.ts +++ b/packages/app/src/stores/panel-store/index.ts @@ -4,66 +4,61 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { buildExplorerCheckoutKey, coerceExplorerTabForCheckout, - isExplorerTab, resolveExplorerTabForCheckout, type ExplorerTab, -} from "./explorer-tab-memory"; -import { type ExplorerCheckoutContext } from "./explorer-checkout-context"; +} from "../explorer-tab-memory"; +import { type ExplorerCheckoutContext } from "../explorer-checkout-context"; +import { + buildOpenFileExplorerPatch, + buildToggleFileExplorerPatch, + clampExplorerFilesSplitRatio, + clampExplorerWidth, + clampSidebarWidth, + DEFAULT_EXPLORER_FILES_SPLIT_RATIO, + DEFAULT_EXPLORER_SIDEBAR_WIDTH, + DEFAULT_SIDEBAR_WIDTH, + MAX_EXPLORER_FILES_SPLIT_RATIO, + MAX_EXPLORER_SIDEBAR_WIDTH, + MAX_SIDEBAR_WIDTH, + MIN_EXPLORER_FILES_SPLIT_RATIO, + MIN_EXPLORER_SIDEBAR_WIDTH, + MIN_SIDEBAR_WIDTH, + migratePanelState, + selectIsAgentListOpen, + selectIsFileExplorerOpen, + selectPanelVisibility, + type DesktopSidebarState, + type ExplorerPanelIntent, + type MobilePanelView, + type PanelLayoutInput, + type PanelVisibilityState, + type SortOption, +} from "./state"; import { isWeb } from "@/constants/platform"; -export type { ExplorerTab } from "./explorer-tab-memory"; -export type { ExplorerCheckoutContext } from "./explorer-checkout-context"; - -/** - * Mobile panel state machine. - * - * On mobile, exactly one panel can be visible at a time: - * - 'agent': Main agent view (no overlay panel) - * - 'agent-list': Agent list sidebar (left overlay) - * - 'file-explorer': File explorer sidebar (right overlay) - * - * This makes impossible states unrepresentable - you cannot have both - * sidebars open at the same time on mobile. - */ -export type MobilePanelView = "agent" | "agent-list" | "file-explorer"; - -/** - * Desktop sidebar state. - * - * On desktop, sidebars are independent toggleable panels that don't overlay - * the main content - they sit alongside it. Both can be open simultaneously. - */ -interface DesktopSidebarState { - agentListOpen: boolean; - fileExplorerOpen: boolean; - focusModeEnabled: boolean; -} - -export type SortOption = "name" | "modified" | "size"; -export const DEFAULT_SIDEBAR_WIDTH = 320; -export const MIN_SIDEBAR_WIDTH = 200; -export const MAX_SIDEBAR_WIDTH = 600; - -export const DEFAULT_EXPLORER_SIDEBAR_WIDTH = 400; -export const MIN_EXPLORER_SIDEBAR_WIDTH = 280; -// Upper bound is intentionally generous; desktop resizing enforces a min-chat-width constraint. -export const MAX_EXPLORER_SIDEBAR_WIDTH = 2000; - -export const DEFAULT_EXPLORER_FILES_SPLIT_RATIO = 0.38; -export const MIN_EXPLORER_FILES_SPLIT_RATIO = 0.2; -export const MAX_EXPLORER_FILES_SPLIT_RATIO = 0.8; - -export interface PanelVisibilityState { - isAgentListOpen: boolean; - isFileExplorerOpen: boolean; -} - -export interface PanelLayoutInput { - isCompact: boolean; -} - -export interface ExplorerPanelIntent extends PanelLayoutInput { - checkout: ExplorerCheckoutContext; -} +export type { ExplorerTab } from "../explorer-tab-memory"; +export type { ExplorerCheckoutContext } from "../explorer-checkout-context"; +export type { + DesktopSidebarState, + ExplorerPanelIntent, + MobilePanelView, + PanelLayoutInput, + PanelVisibilityState, + SortOption, +} from "./state"; +export { + DEFAULT_EXPLORER_FILES_SPLIT_RATIO, + DEFAULT_EXPLORER_SIDEBAR_WIDTH, + DEFAULT_SIDEBAR_WIDTH, + MAX_EXPLORER_FILES_SPLIT_RATIO, + MAX_EXPLORER_SIDEBAR_WIDTH, + MAX_SIDEBAR_WIDTH, + MIN_EXPLORER_FILES_SPLIT_RATIO, + MIN_EXPLORER_SIDEBAR_WIDTH, + MIN_SIDEBAR_WIDTH, + selectIsAgentListOpen, + selectIsFileExplorerOpen, + selectPanelVisibility, +}; export interface PanelState { // Mobile: which panel is currently shown @@ -109,173 +104,6 @@ export interface PanelState { setExplorerFilesSplitRatio: (ratio: number) => void; } -function clampNumber(value: number, min: number, max: number): number { - if (!Number.isFinite(value)) { - return min; - } - return Math.max(min, Math.min(max, value)); -} - -function clampSidebarWidth(width: number): number { - return clampNumber(width, MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH); -} - -function clampWidth(width: number): number { - return clampNumber(width, MIN_EXPLORER_SIDEBAR_WIDTH, MAX_EXPLORER_SIDEBAR_WIDTH); -} - -function clampExplorerFilesSplitRatio(ratio: number): number { - return clampNumber(ratio, MIN_EXPLORER_FILES_SPLIT_RATIO, MAX_EXPLORER_FILES_SPLIT_RATIO); -} - -function resolveExplorerTabFromCheckout(state: PanelState, checkout: ExplorerCheckoutContext) { - return resolveExplorerTabForCheckout({ - serverId: checkout.serverId, - cwd: checkout.cwd, - isGit: checkout.isGit, - explorerTabByCheckout: state.explorerTabByCheckout, - }); -} - -function buildOpenFileExplorerPatch( - state: PanelState, - input: ExplorerPanelIntent, -): Partial { - const resolvedTab = resolveExplorerTabFromCheckout(state, input.checkout); - if (input.isCompact) { - return { - mobileView: "file-explorer", - explorerTab: resolvedTab, - }; - } - return { - desktop: { ...state.desktop, fileExplorerOpen: true }, - explorerTab: resolvedTab, - }; -} - -export function selectPanelVisibility( - state: PanelState, - input: PanelLayoutInput, -): PanelVisibilityState { - if (input.isCompact) { - return { - isAgentListOpen: state.mobileView === "agent-list", - isFileExplorerOpen: state.mobileView === "file-explorer", - }; - } - return { - isAgentListOpen: state.desktop.agentListOpen, - isFileExplorerOpen: state.desktop.fileExplorerOpen, - }; -} - -export function selectIsAgentListOpen(state: PanelState, input: PanelLayoutInput): boolean { - return selectPanelVisibility(state, input).isAgentListOpen; -} - -export function selectIsFileExplorerOpen(state: PanelState, input: PanelLayoutInput): boolean { - return selectPanelVisibility(state, input).isFileExplorerOpen; -} - -type MigratablePanelState = Partial & Record; - -function migratePanelV2Explorer(state: MigratablePanelState): void { - if (isWeb && typeof state.explorerWidth === "number" && state.explorerWidth === 400) { - state.explorerWidth = DEFAULT_EXPLORER_SIDEBAR_WIDTH; - } - if (typeof state.explorerFilesSplitRatio !== "number") { - state.explorerFilesSplitRatio = DEFAULT_EXPLORER_FILES_SPLIT_RATIO; - } else { - state.explorerFilesSplitRatio = clampExplorerFilesSplitRatio(state.explorerFilesSplitRatio); - } -} - -function migratePanelV3Explorer(state: MigratablePanelState): void { - if ( - isWeb && - typeof state.explorerWidth === "number" && - (state.explorerWidth === 400 || state.explorerWidth === 520) - ) { - state.explorerWidth = DEFAULT_EXPLORER_SIDEBAR_WIDTH; - } -} - -function migratePanelExplorerTabByCheckout(state: MigratablePanelState, version: number): void { - if ( - version < 4 || - typeof state.explorerTabByCheckout !== "object" || - !state.explorerTabByCheckout - ) { - state.explorerTabByCheckout = {}; - return; - } - const entries = Object.entries(state.explorerTabByCheckout as Record); - const next: Record = {}; - for (const [key, value] of entries) { - if (!isExplorerTab(value)) { - continue; - } - next[key] = value; - } - state.explorerTabByCheckout = next; -} - -function migratePanelDesktopFocusMode(state: MigratablePanelState): void { - const desktop = state.desktop as Record | undefined; - if (!desktop) { - return; - } - if ("zoomed" in desktop) { - desktop.focusModeEnabled = desktop.zoomed; - delete desktop.zoomed; - } - if ("focused" in desktop) { - desktop.focusModeEnabled = desktop.focused; - delete desktop.focused; - } - if (typeof desktop.focusModeEnabled !== "boolean") { - desktop.focusModeEnabled = false; - } -} - -function migratePanelState(persistedState: unknown, version: number): PanelState { - const state = persistedState as MigratablePanelState; - - if (version < 2) { - migratePanelV2Explorer(state); - } - if (version < 3) { - migratePanelV3Explorer(state); - } - if (!isExplorerTab(state.explorerTab)) { - state.explorerTab = "changes"; - } - migratePanelExplorerTabByCheckout(state, version); - if (version < 8) { - migratePanelDesktopFocusMode(state); - } - if (version < 6 || typeof state.sidebarWidth !== "number") { - state.sidebarWidth = DEFAULT_SIDEBAR_WIDTH; - } - if ( - version < 9 || - typeof state.expandedPathsByWorkspace !== "object" || - !state.expandedPathsByWorkspace - ) { - state.expandedPathsByWorkspace = {}; - } - if ( - version < 10 || - typeof state.diffExpandedPathsByWorkspace !== "object" || - !state.diffExpandedPathsByWorkspace - ) { - state.diffExpandedPathsByWorkspace = {}; - } - - return state as PanelState; -} - const DEFAULT_DESKTOP_OPEN = isWeb; export const usePanelStore = create()( @@ -392,16 +220,7 @@ export const usePanelStore = create()( set((state) => buildOpenFileExplorerPatch(state, input)), toggleFileExplorerForCheckout: (input) => - set((state) => { - const isOpen = selectIsFileExplorerOpen(state, input); - if (!isOpen) { - return buildOpenFileExplorerPatch(state, input); - } - if (input.isCompact) { - return { mobileView: "agent" as const }; - } - return { desktop: { ...state.desktop, fileExplorerOpen: false } }; - }), + set((state) => buildToggleFileExplorerPatch(state, input)), setExplorerTab: (tab) => set({ explorerTab: tab }), setExplorerTabForCheckout: ({ serverId, cwd, isGit, tab }) => @@ -441,7 +260,7 @@ export const usePanelStore = create()( }), })), setSidebarWidth: (width) => set({ sidebarWidth: clampSidebarWidth(width) }), - setExplorerWidth: (width) => set({ explorerWidth: clampWidth(width) }), + setExplorerWidth: (width) => set({ explorerWidth: clampExplorerWidth(width) }), setExplorerSortOption: (option) => set({ explorerSortOption: option }), setExplorerFilesSplitRatio: (ratio) => set({ @@ -454,7 +273,8 @@ export const usePanelStore = create()( name: "panel-state", version: 10, storage: createJSONStorage(() => AsyncStorage), - migrate: (persistedState, version) => migratePanelState(persistedState, version), + migrate: (persistedState, version) => + migratePanelState(persistedState, version, { isWeb }) as unknown as PanelState, partialize: (state) => ({ mobileView: state.mobileView, desktop: state.desktop, diff --git a/packages/app/src/stores/panel-store.test.ts b/packages/app/src/stores/panel-store/state.test.ts similarity index 62% rename from packages/app/src/stores/panel-store.test.ts rename to packages/app/src/stores/panel-store/state.test.ts index 6d940b0b2..0d2764574 100644 --- a/packages/app/src/stores/panel-store.test.ts +++ b/packages/app/src/stores/panel-store/state.test.ts @@ -1,35 +1,20 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("@react-native-async-storage/async-storage", () => { - const storage = new Map(); - return { - default: { - getItem: vi.fn(async (key: string) => storage.get(key) ?? null), - setItem: vi.fn(async (key: string, value: string) => { - storage.set(key, value); - }), - removeItem: vi.fn(async (key: string) => { - storage.delete(key); - }), - }, - }; -}); - +import { describe, expect, it } from "vitest"; import { buildExplorerCheckoutKey, resolveExplorerTabForCheckout, type ExplorerTab, } from "@/stores/explorer-tab-memory"; import { + buildOpenFileExplorerPatch, + buildToggleFileExplorerPatch, selectIsAgentListOpen, selectIsFileExplorerOpen, selectPanelVisibility, - usePanelStore, - type PanelState, -} from "@/stores/panel-store"; + type PanelCoreState, +} from "./state"; -function resetPanelStore() { - usePanelStore.setState({ +function makePanelState(overrides: Partial = {}): PanelCoreState { + return { mobileView: "agent", desktop: { agentListOpen: false, @@ -38,29 +23,10 @@ function resetPanelStore() { }, explorerTab: "changes", explorerTabByCheckout: {}, - }); -} - -function createPanelState(input: { - mobileView: PanelState["mobileView"]; - agentListOpen: boolean; - fileExplorerOpen: boolean; -}): PanelState { - return { - ...usePanelStore.getState(), - mobileView: input.mobileView, - desktop: { - ...usePanelStore.getState().desktop, - agentListOpen: input.agentListOpen, - fileExplorerOpen: input.fileExplorerOpen, - }, + ...overrides, }; } -beforeEach(() => { - resetPanelStore(); -}); - describe("panel-store explorer tab resolution", () => { const serverId = "server-1"; const cwd = "/tmp/repo"; @@ -132,10 +98,9 @@ describe("panel-store explorer tab resolution", () => { describe("panel-store visibility selectors", () => { it("uses mobileView for compact layout visibility", () => { - const state = createPanelState({ + const state = makePanelState({ mobileView: "file-explorer", - agentListOpen: true, - fileExplorerOpen: false, + desktop: { agentListOpen: true, fileExplorerOpen: false, focusModeEnabled: false }, }); expect(selectPanelVisibility(state, { isCompact: true })).toEqual({ @@ -147,10 +112,9 @@ describe("panel-store visibility selectors", () => { }); it("uses desktop flags for expanded layout visibility", () => { - const state = createPanelState({ + const state = makePanelState({ mobileView: "file-explorer", - agentListOpen: true, - fileExplorerOpen: false, + desktop: { agentListOpen: true, fileExplorerOpen: false, focusModeEnabled: false }, }); expect(selectPanelVisibility(state, { isCompact: false })).toEqual({ @@ -166,83 +130,71 @@ describe("panel-store checkout-intent file explorer actions", () => { it("opens the compact explorer and resolves the tab from the explicit checkout", () => { const checkout = { serverId: "server-1", cwd: "/tmp/repo", isGit: true }; const key = buildExplorerCheckoutKey(checkout.serverId, checkout.cwd)!; - usePanelStore.setState({ + const state = makePanelState({ explorerTab: "changes", explorerTabByCheckout: { [key]: "files" }, }); - usePanelStore.getState().openFileExplorerForCheckout({ - isCompact: true, - checkout, - }); + const patch = buildOpenFileExplorerPatch(state, { isCompact: true, checkout }); - expect(usePanelStore.getState().mobileView).toBe("file-explorer"); - expect(usePanelStore.getState().desktop.fileExplorerOpen).toBe(false); - expect(usePanelStore.getState().explorerTab).toBe("files"); + expect(patch.mobileView).toBe("file-explorer"); + expect(patch.desktop).toBeUndefined(); + expect(patch.explorerTab).toBe("files"); }); it("opens the expanded explorer and resolves the tab from the explicit checkout", () => { const checkout = { serverId: "server-1", cwd: "/tmp/repo", isGit: true }; const key = buildExplorerCheckoutKey(checkout.serverId, checkout.cwd)!; - usePanelStore.setState({ + const state = makePanelState({ explorerTab: "changes", explorerTabByCheckout: { [key]: "files" }, }); - usePanelStore.getState().openFileExplorerForCheckout({ - isCompact: false, - checkout, - }); + const patch = buildOpenFileExplorerPatch(state, { isCompact: false, checkout }); - expect(usePanelStore.getState().mobileView).toBe("agent"); - expect(usePanelStore.getState().desktop.fileExplorerOpen).toBe(true); - expect(usePanelStore.getState().explorerTab).toBe("files"); + expect(patch.mobileView).toBeUndefined(); + expect(patch.desktop?.fileExplorerOpen).toBe(true); + expect(patch.explorerTab).toBe("files"); }); it("toggles the explorer closed without changing the active tab", () => { - usePanelStore.setState({ - desktop: { - agentListOpen: false, - fileExplorerOpen: true, - focusModeEnabled: false, - }, + const state = makePanelState({ + desktop: { agentListOpen: false, fileExplorerOpen: true, focusModeEnabled: false }, explorerTab: "files", }); - usePanelStore.getState().toggleFileExplorerForCheckout({ + const patch = buildToggleFileExplorerPatch(state, { isCompact: false, checkout: { serverId: "server-1", cwd: "/tmp/repo", isGit: true }, }); - expect(usePanelStore.getState().desktop.fileExplorerOpen).toBe(false); - expect(usePanelStore.getState().explorerTab).toBe("files"); + expect(patch).toEqual({ + desktop: { agentListOpen: false, fileExplorerOpen: false, focusModeEnabled: false }, + }); }); it("coerces changes to files for a non-git checkout", () => { const checkout = { serverId: "server-1", cwd: "/tmp/repo", isGit: false }; const key = buildExplorerCheckoutKey(checkout.serverId, checkout.cwd)!; - usePanelStore.setState({ + const state = makePanelState({ explorerTab: "changes", explorerTabByCheckout: { [key]: "changes" }, }); - usePanelStore.getState().openFileExplorerForCheckout({ - isCompact: false, - checkout, - }); + const patch = buildOpenFileExplorerPatch(state, { isCompact: false, checkout }); - expect(usePanelStore.getState().explorerTab).toBe("files"); + expect(patch.explorerTab).toBe("files"); }); it("opens with the default files tab for an explicit non-git checkout with no stored tab", () => { - usePanelStore.setState({ explorerTab: "changes", explorerTabByCheckout: {} }); + const state = makePanelState({ explorerTab: "changes", explorerTabByCheckout: {} }); - usePanelStore.getState().openFileExplorerForCheckout({ + const patch = buildOpenFileExplorerPatch(state, { isCompact: false, checkout: { serverId: "server-1", cwd: "/tmp/non-git", isGit: false }, }); - expect(usePanelStore.getState().desktop.fileExplorerOpen).toBe(true); - expect(usePanelStore.getState().explorerTab).toBe("files"); + expect(patch.desktop?.fileExplorerOpen).toBe(true); + expect(patch.explorerTab).toBe("files"); }); }); diff --git a/packages/app/src/stores/panel-store/state.ts b/packages/app/src/stores/panel-store/state.ts new file mode 100644 index 000000000..c8f28b2ea --- /dev/null +++ b/packages/app/src/stores/panel-store/state.ts @@ -0,0 +1,253 @@ +import { + buildExplorerCheckoutKey, + isExplorerTab, + resolveExplorerTabForCheckout, + type ExplorerTab, +} from "../explorer-tab-memory"; +import { type ExplorerCheckoutContext } from "../explorer-checkout-context"; + +export type MobilePanelView = "agent" | "agent-list" | "file-explorer"; + +export interface DesktopSidebarState { + agentListOpen: boolean; + fileExplorerOpen: boolean; + focusModeEnabled: boolean; +} + +export type SortOption = "name" | "modified" | "size"; + +export const DEFAULT_SIDEBAR_WIDTH = 320; +export const MIN_SIDEBAR_WIDTH = 200; +export const MAX_SIDEBAR_WIDTH = 600; + +export const DEFAULT_EXPLORER_SIDEBAR_WIDTH = 400; +export const MIN_EXPLORER_SIDEBAR_WIDTH = 280; +// Upper bound is intentionally generous; desktop resizing enforces a min-chat-width constraint. +export const MAX_EXPLORER_SIDEBAR_WIDTH = 2000; + +export const DEFAULT_EXPLORER_FILES_SPLIT_RATIO = 0.38; +export const MIN_EXPLORER_FILES_SPLIT_RATIO = 0.2; +export const MAX_EXPLORER_FILES_SPLIT_RATIO = 0.8; + +export interface PanelVisibilityState { + isAgentListOpen: boolean; + isFileExplorerOpen: boolean; +} + +export interface PanelLayoutInput { + isCompact: boolean; +} + +export interface ExplorerPanelIntent extends PanelLayoutInput { + checkout: ExplorerCheckoutContext; +} + +export interface PanelCoreState { + mobileView: MobilePanelView; + desktop: DesktopSidebarState; + explorerTab: ExplorerTab; + explorerTabByCheckout: Record; +} + +function clampNumber(value: number, min: number, max: number): number { + if (!Number.isFinite(value)) { + return min; + } + return Math.max(min, Math.min(max, value)); +} + +export function clampSidebarWidth(width: number): number { + return clampNumber(width, MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH); +} + +export function clampExplorerWidth(width: number): number { + return clampNumber(width, MIN_EXPLORER_SIDEBAR_WIDTH, MAX_EXPLORER_SIDEBAR_WIDTH); +} + +export function clampExplorerFilesSplitRatio(ratio: number): number { + return clampNumber(ratio, MIN_EXPLORER_FILES_SPLIT_RATIO, MAX_EXPLORER_FILES_SPLIT_RATIO); +} + +export function selectPanelVisibility( + state: PanelCoreState, + input: PanelLayoutInput, +): PanelVisibilityState { + if (input.isCompact) { + return { + isAgentListOpen: state.mobileView === "agent-list", + isFileExplorerOpen: state.mobileView === "file-explorer", + }; + } + return { + isAgentListOpen: state.desktop.agentListOpen, + isFileExplorerOpen: state.desktop.fileExplorerOpen, + }; +} + +export function selectIsAgentListOpen(state: PanelCoreState, input: PanelLayoutInput): boolean { + return selectPanelVisibility(state, input).isAgentListOpen; +} + +export function selectIsFileExplorerOpen(state: PanelCoreState, input: PanelLayoutInput): boolean { + return selectPanelVisibility(state, input).isFileExplorerOpen; +} + +function resolveExplorerTabFromCheckout( + state: PanelCoreState, + checkout: ExplorerCheckoutContext, +): ExplorerTab { + return resolveExplorerTabForCheckout({ + serverId: checkout.serverId, + cwd: checkout.cwd, + isGit: checkout.isGit, + explorerTabByCheckout: state.explorerTabByCheckout, + }); +} + +export interface OpenFileExplorerPatch { + mobileView?: MobilePanelView; + desktop?: DesktopSidebarState; + explorerTab: ExplorerTab; +} + +export function buildOpenFileExplorerPatch( + state: PanelCoreState, + input: ExplorerPanelIntent, +): OpenFileExplorerPatch { + const resolvedTab = resolveExplorerTabFromCheckout(state, input.checkout); + if (input.isCompact) { + return { + mobileView: "file-explorer", + explorerTab: resolvedTab, + }; + } + return { + desktop: { ...state.desktop, fileExplorerOpen: true }, + explorerTab: resolvedTab, + }; +} + +export type ToggleFileExplorerPatch = + | OpenFileExplorerPatch + | { mobileView: MobilePanelView } + | { desktop: DesktopSidebarState }; + +export function buildToggleFileExplorerPatch( + state: PanelCoreState, + input: ExplorerPanelIntent, +): ToggleFileExplorerPatch { + const isOpen = selectIsFileExplorerOpen(state, input); + if (!isOpen) { + return buildOpenFileExplorerPatch(state, input); + } + if (input.isCompact) { + return { mobileView: "agent" }; + } + return { desktop: { ...state.desktop, fileExplorerOpen: false } }; +} + +type MigratablePanelState = Record; + +function migratePanelV2Explorer(state: MigratablePanelState, isWeb: boolean): void { + if (isWeb && typeof state.explorerWidth === "number" && state.explorerWidth === 400) { + state.explorerWidth = DEFAULT_EXPLORER_SIDEBAR_WIDTH; + } + if (typeof state.explorerFilesSplitRatio !== "number") { + state.explorerFilesSplitRatio = DEFAULT_EXPLORER_FILES_SPLIT_RATIO; + } else { + state.explorerFilesSplitRatio = clampExplorerFilesSplitRatio(state.explorerFilesSplitRatio); + } +} + +function migratePanelV3Explorer(state: MigratablePanelState, isWeb: boolean): void { + if ( + isWeb && + typeof state.explorerWidth === "number" && + (state.explorerWidth === 400 || state.explorerWidth === 520) + ) { + state.explorerWidth = DEFAULT_EXPLORER_SIDEBAR_WIDTH; + } +} + +function migratePanelExplorerTabByCheckout(state: MigratablePanelState, version: number): void { + if ( + version < 4 || + typeof state.explorerTabByCheckout !== "object" || + !state.explorerTabByCheckout + ) { + state.explorerTabByCheckout = {}; + return; + } + const entries = Object.entries(state.explorerTabByCheckout as Record); + const next: Record = {}; + for (const [key, value] of entries) { + if (!isExplorerTab(value)) { + continue; + } + next[key] = value; + } + state.explorerTabByCheckout = next; +} + +function migratePanelDesktopFocusMode(state: MigratablePanelState): void { + const desktop = state.desktop as Record | undefined; + if (!desktop) { + return; + } + if ("zoomed" in desktop) { + desktop.focusModeEnabled = desktop.zoomed; + delete desktop.zoomed; + } + if ("focused" in desktop) { + desktop.focusModeEnabled = desktop.focused; + delete desktop.focused; + } + if (typeof desktop.focusModeEnabled !== "boolean") { + desktop.focusModeEnabled = false; + } +} + +export function migratePanelState( + persistedState: unknown, + version: number, + options: { isWeb: boolean }, +): MigratablePanelState { + const state = (persistedState ?? {}) as MigratablePanelState; + const { isWeb } = options; + + if (version < 2) { + migratePanelV2Explorer(state, isWeb); + } + if (version < 3) { + migratePanelV3Explorer(state, isWeb); + } + if (!isExplorerTab(state.explorerTab)) { + state.explorerTab = "changes"; + } + migratePanelExplorerTabByCheckout(state, version); + if (version < 8) { + migratePanelDesktopFocusMode(state); + } + if (version < 6 || typeof state.sidebarWidth !== "number") { + state.sidebarWidth = DEFAULT_SIDEBAR_WIDTH; + } + if ( + version < 9 || + typeof state.expandedPathsByWorkspace !== "object" || + !state.expandedPathsByWorkspace + ) { + state.expandedPathsByWorkspace = {}; + } + if ( + version < 10 || + typeof state.diffExpandedPathsByWorkspace !== "object" || + !state.diffExpandedPathsByWorkspace + ) { + state.diffExpandedPathsByWorkspace = {}; + } + + return state; +} + +export { buildExplorerCheckoutKey, resolveExplorerTabForCheckout }; +export type { ExplorerTab, ExplorerCheckoutContext }; diff --git a/packages/app/src/stores/session-store-hooks.test.ts b/packages/app/src/stores/session-store-hooks.test.ts deleted file mode 100644 index 10d948c18..000000000 --- a/packages/app/src/stores/session-store-hooks.test.ts +++ /dev/null @@ -1,366 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import { act, renderHook } from "@testing-library/react"; -import { afterEach, describe, expect, it } from "vitest"; - -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { - useHasWorkspaces, - useRecommendedProjectPaths, - useResolveWorkspaceIdByCwd, - useWorkspace, - useWorkspaceExecutionAuthority, - useWorkspaceFields, - useWorkspaceKeys, - useWorkspaceStatusesForBadges, - useWorkspaceStructure, -} from "./session-store-hooks"; -import { useSidebarOrderStore } from "./sidebar-order-store"; -import { useSessionStore, type WorkspaceDescriptor } from "./session-store"; - -const SERVER_ID = "test-server"; - -function createWorkspace( - input: Partial & Pick, -): WorkspaceDescriptor { - return { - id: input.id, - projectId: input.projectId ?? "project-1", - projectDisplayName: input.projectDisplayName ?? "Project 1", - projectRootPath: input.projectRootPath ?? "/repo", - workspaceDirectory: input.workspaceDirectory ?? "/repo", - projectKind: input.projectKind ?? "git", - workspaceKind: input.workspaceKind ?? "local_checkout", - name: input.name ?? "main", - status: input.status ?? "done", - archivingAt: input.archivingAt ?? null, - diffStat: input.diffStat ?? null, - scripts: input.scripts ?? [], - }; -} - -function initializeWorkspaces(workspaces: WorkspaceDescriptor[]): void { - act(() => { - useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient); - useSessionStore - .getState() - .setWorkspaces(SERVER_ID, new Map(workspaces.map((workspace) => [workspace.id, workspace]))); - }); -} - -afterEach(() => { - act(() => { - useSessionStore.getState().clearSession(SERVER_ID); - useSidebarOrderStore.setState({ - projectOrderByServerId: {}, - workspaceOrderByServerAndProject: {}, - }); - }); -}); - -describe("useWorkspace", () => { - it("resolves a descriptor when the route id matches workspace identity but not the map key", () => { - const workspace = createWorkspace({ id: "workspace-a" }); - act(() => { - useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient); - useSessionStore.getState().setWorkspaces(SERVER_ID, new Map([["map-key-a", workspace]])); - }); - - const { result } = renderHook(() => useWorkspace(SERVER_ID, workspace.id)); - - expect(result.current).toBe(workspace); - }); - - it("keeps the descriptor reference for unrelated workspace updates", () => { - const workspaceA = createWorkspace({ id: "workspace-a", name: "A" }); - const workspaceB = createWorkspace({ id: "workspace-b", name: "B" }); - initializeWorkspaces([workspaceA, workspaceB]); - - const { result } = renderHook(() => useWorkspace(SERVER_ID, workspaceA.id)); - const before = result.current; - - act(() => { - useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceB, status: "running" }]); - }); - expect(result.current).toBe(before); - - act(() => { - useSessionStore - .getState() - .mergeWorkspaces(SERVER_ID, [{ ...workspaceA, status: "attention" }]); - }); - expect(result.current).not.toBe(before); - }); - - it("keeps the descriptor reference when the observed workspace is rewritten with content-equal data", () => { - const workspace = createWorkspace({ id: "workspace-a", scripts: [] }); - initializeWorkspaces([workspace]); - - const { result } = renderHook(() => useWorkspace(SERVER_ID, workspace.id)); - const before = result.current; - - act(() => { - useSessionStore - .getState() - .setWorkspaces(SERVER_ID, new Map([[workspace.id, { ...workspace, scripts: [] }]])); - }); - expect(result.current).toBe(before); - }); -}); - -describe("useWorkspaceExecutionAuthority", () => { - it("preserves the old missing-workspace message with the requested id", () => { - initializeWorkspaces([]); - - const { result } = renderHook(() => useWorkspaceExecutionAuthority(SERVER_ID, "missing-id")); - - expect(result.current).toMatchObject({ - ok: false, - message: "Workspace not found: missing-id", - }); - }); - - it("keeps deep-equal authority references under unrelated workspace updates", () => { - const workspaceA = createWorkspace({ id: "workspace-a", name: "A" }); - const workspaceB = createWorkspace({ id: "workspace-b", name: "B" }); - initializeWorkspaces([workspaceA, workspaceB]); - - const { result } = renderHook(() => useWorkspaceExecutionAuthority(SERVER_ID, workspaceA.id)); - const before = result.current; - - act(() => { - useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceB, status: "running" }]); - }); - - expect(result.current).toBe(before); - }); -}); - -describe("useWorkspaceFields", () => { - it("keeps deep-equal projection references until projected fields change", () => { - const workspace = createWorkspace({ id: "workspace-a", name: "A", status: "done" }); - initializeWorkspaces([workspace]); - - const selectIdentity = (current: { id: string; name: string }) => ({ - identity: { - id: current.id, - name: current.name, - }, - }); - const { result } = renderHook(() => - useWorkspaceFields(SERVER_ID, workspace.id, selectIdentity), - ); - const before = result.current; - - act(() => { - useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspace, status: "running" }]); - }); - expect(result.current).toBe(before); - - act(() => { - useSessionStore.getState().mergeWorkspaces(SERVER_ID, [ - { - ...workspace, - name: "A renamed", - status: "running", - }, - ]); - }); - expect(result.current).not.toBe(before); - }); -}); - -describe("useWorkspaceStructure", () => { - it("changes for membership updates but not status-only updates", () => { - const workspaceA = createWorkspace({ id: "workspace-a", name: "A" }); - const workspaceB = createWorkspace({ id: "workspace-b", name: "B" }); - initializeWorkspaces([workspaceA]); - - const { result } = renderHook(() => useWorkspaceStructure(SERVER_ID)); - const before = result.current; - - act(() => { - useSessionStore.getState().mergeWorkspaces(SERVER_ID, [workspaceB]); - }); - const afterAdd = result.current; - expect(afterAdd).not.toBe(before); - expect(afterAdd.projects[0]?.workspaceKeys).toEqual(["workspace-a", "workspace-b"]); - - act(() => { - useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceA, status: "running" }]); - }); - expect(result.current).toBe(afterAdd); - }); - - it("changes when a structure-relevant project identity field changes", () => { - const workspace = createWorkspace({ - id: "workspace-a", - projectDisplayName: "Project 1", - }); - initializeWorkspaces([workspace]); - - const { result } = renderHook(() => useWorkspaceStructure(SERVER_ID)); - const before = result.current; - - act(() => { - useSessionStore - .getState() - .mergeWorkspaces(SERVER_ID, [{ ...workspace, projectDisplayName: "Project Renamed" }]); - }); - expect(result.current).not.toBe(before); - }); - - it("changes when persisted sidebar project order changes", () => { - const workspaceA = createWorkspace({ - id: "workspace-a", - projectId: "project-a", - projectDisplayName: "Project A", - }); - const workspaceB = createWorkspace({ - id: "workspace-b", - projectId: "project-b", - projectDisplayName: "Project B", - }); - initializeWorkspaces([workspaceA, workspaceB]); - - const { result } = renderHook(() => useWorkspaceStructure(SERVER_ID)); - const before = result.current; - - act(() => { - useSidebarOrderStore.getState().setProjectOrder(SERVER_ID, ["project-b", "project-a"]); - }); - expect(result.current).not.toBe(before); - }); -}); - -describe("useWorkspaceKeys", () => { - it("changes for reorder updates but not content-only updates", () => { - const workspaceA = createWorkspace({ id: "workspace-a", name: "A" }); - const workspaceB = createWorkspace({ id: "workspace-b", name: "B" }); - initializeWorkspaces([workspaceA, workspaceB]); - - const { result } = renderHook(() => useWorkspaceKeys(SERVER_ID)); - const before = result.current; - expect(before).toEqual(["workspace-a", "workspace-b"]); - - act(() => { - useSessionStore.getState().setWorkspaces( - SERVER_ID, - new Map([ - [workspaceB.id, workspaceB], - [workspaceA.id, workspaceA], - ]), - ); - }); - const afterReorder = result.current; - expect(afterReorder).not.toBe(before); - expect(afterReorder).toEqual(["workspace-b", "workspace-a"]); - - act(() => { - useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceA, status: "running" }]); - }); - expect(result.current).toBe(afterReorder); - }); -}); - -describe("useRecommendedProjectPaths", () => { - it("updates when an existing workspace project root changes", () => { - const workspace = createWorkspace({ id: "workspace-a", projectRootPath: "/repo/a" }); - initializeWorkspaces([workspace]); - - const { result } = renderHook(() => useRecommendedProjectPaths(SERVER_ID)); - - act(() => { - useSessionStore - .getState() - .mergeWorkspaces(SERVER_ID, [{ ...workspace, projectRootPath: "/repo/b" }]); - }); - - expect(result.current).toEqual(["/repo/b"]); - }); - - it("keeps the path list reference under unrelated workspace updates", () => { - const workspace = createWorkspace({ id: "workspace-a", projectRootPath: "/repo/a" }); - initializeWorkspaces([workspace]); - - const { result } = renderHook(() => useRecommendedProjectPaths(SERVER_ID)); - const before = result.current; - - act(() => { - useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspace, status: "running" }]); - }); - - expect(result.current).toBe(before); - }); -}); - -describe("useHasWorkspaces", () => { - it("stays stable when workspace membership changes without flipping the boolean", () => { - const workspaceA = createWorkspace({ id: "workspace-a" }); - const workspaceB = createWorkspace({ id: "workspace-b" }); - initializeWorkspaces([workspaceA]); - - const { result } = renderHook(() => useHasWorkspaces(SERVER_ID)); - const before = result.current; - - act(() => { - useSessionStore.getState().mergeWorkspaces(SERVER_ID, [workspaceB]); - }); - - expect(result.current).toBe(before); - }); -}); - -describe("useResolveWorkspaceIdByCwd", () => { - it("resolves by cwd and stays stable under unrelated updates", () => { - const workspaceA = createWorkspace({ - id: "workspace-a", - workspaceDirectory: "/repo/a", - }); - const workspaceB = createWorkspace({ - id: "workspace-b", - workspaceDirectory: "/repo/b", - }); - initializeWorkspaces([workspaceA, workspaceB]); - - const { result } = renderHook(() => useResolveWorkspaceIdByCwd(SERVER_ID, "/repo/a")); - const before = result.current; - expect(before).toBe("workspace-a"); - - act(() => { - useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceB, status: "running" }]); - }); - expect(result.current).toBe(before); - }); -}); - -describe("useWorkspaceStatusesForBadges", () => { - it("tracks status changes without changing for no-ops or unrelated descriptor updates", () => { - const workspaceA = createWorkspace({ id: "workspace-a", status: "done" }); - const workspaceB = createWorkspace({ id: "workspace-b", status: "attention" }); - initializeWorkspaces([workspaceA, workspaceB]); - - const { result } = renderHook(() => useWorkspaceStatusesForBadges()); - const before = result.current; - expect(before).toEqual(["done", "attention"]); - - act(() => { - useSessionStore - .getState() - .mergeWorkspaces(SERVER_ID, [{ ...workspaceA, scripts: [...workspaceA.scripts] }]); - }); - expect(result.current).toBe(before); - - act(() => { - useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceB, name: "Renamed" }]); - }); - expect(result.current).toBe(before); - - act(() => { - useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceA, status: "failed" }]); - }); - expect(result.current).not.toBe(before); - expect(result.current).toEqual(["failed", "attention"]); - }); -}); diff --git a/packages/app/src/stores/session-store-hooks/index.ts b/packages/app/src/stores/session-store-hooks/index.ts new file mode 100644 index 000000000..cb2a72d20 --- /dev/null +++ b/packages/app/src/stores/session-store-hooks/index.ts @@ -0,0 +1,138 @@ +import { useMemo } from "react"; +import { useStoreWithEqualityFn } from "zustand/traditional"; +import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; +import { + composeWorkspaceStructure, + selectHasWorkspaces, + selectProjectOrder, + selectRecommendedProjectPaths, + selectResolveWorkspaceIdByCwd, + selectWorkspace, + selectWorkspaceExecutionAuthority, + selectWorkspaceFields, + selectWorkspaceKeys, + selectWorkspaceOrderByScopeForServer, + selectWorkspaceStatusesForBadges, + selectWorkspaceStructureProjects, + workspaceEqualityFns, + type WorkspaceStructure, +} from "./selectors"; +import { useSessionStore, type WorkspaceDescriptor } from "../session-store"; +import type { WorkspaceExecutionAuthorityResult } from "@/utils/workspace-execution"; +import type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state"; + +// These are the ONLY supported ways to read workspaces from the session store. +// Do not write raw `useSessionStore` selectors that return the workspaces Map, a session object, +// or the sessions dict — it breaks re-render isolation. + +export type { + DesktopBadgeWorkspaceStatus, + WorkspaceStructure, + WorkspaceStructureProject, +} from "./selectors"; + +export function useWorkspace( + serverId: string | null, + workspaceId: string | null, +): WorkspaceDescriptor | null { + return useStoreWithEqualityFn( + useSessionStore, + (state) => selectWorkspace(state, serverId, workspaceId), + workspaceEqualityFns.identity, + ); +} + +export function useWorkspaceFields( + serverId: string | null, + workspaceId: string | null, + project: (w: WorkspaceDescriptor) => T, +): T | null { + return useStoreWithEqualityFn( + useSessionStore, + (state) => selectWorkspaceFields(state, serverId, workspaceId, project), + workspaceEqualityFns.deep, + ); +} + +export function useWorkspaceExecutionAuthority( + serverId: string | null, + workspaceId: string | null, +): WorkspaceExecutionAuthorityResult | null { + return useStoreWithEqualityFn( + useSessionStore, + (state) => selectWorkspaceExecutionAuthority(state, serverId, workspaceId), + workspaceEqualityFns.deep, + ); +} + +export function useWorkspaceStructure(serverId: string | null): WorkspaceStructure { + const projects = useStoreWithEqualityFn( + useSessionStore, + (state) => selectWorkspaceStructureProjects(state, serverId), + workspaceEqualityFns.deep, + ); + const projectOrder = useStoreWithEqualityFn( + useSidebarOrderStore, + (state) => selectProjectOrder(state, serverId), + workspaceEqualityFns.deep, + ); + const workspaceOrderByScope = useStoreWithEqualityFn( + useSidebarOrderStore, + (state) => selectWorkspaceOrderByScopeForServer(state, serverId), + workspaceEqualityFns.deep, + ); + + return useMemo( + () => + composeWorkspaceStructure({ + serverId, + projects, + projectOrder, + workspaceOrderByScope, + }), + [projectOrder, projects, serverId, workspaceOrderByScope], + ); +} + +export function useWorkspaceKeys(serverId: string | null): string[] { + return useStoreWithEqualityFn( + useSessionStore, + (state) => selectWorkspaceKeys(state, serverId), + workspaceEqualityFns.deep, + ); +} + +export function useRecommendedProjectPaths(serverId: string | null): string[] { + return useStoreWithEqualityFn( + useSessionStore, + (state) => selectRecommendedProjectPaths(state, serverId), + workspaceEqualityFns.deep, + ); +} + +export function useHasWorkspaces(serverId: string | null): boolean { + return useStoreWithEqualityFn( + useSessionStore, + (state) => selectHasWorkspaces(state, serverId), + workspaceEqualityFns.identity, + ); +} + +export function useResolveWorkspaceIdByCwd( + serverId: string | null, + cwd: string | null | undefined, +): string | null { + return useStoreWithEqualityFn( + useSessionStore, + (state) => selectResolveWorkspaceIdByCwd(state, serverId, cwd), + workspaceEqualityFns.identity, + ); +} + +export function useWorkspaceStatusesForBadges(): DesktopBadgeWorkspaceStatus[] { + return useStoreWithEqualityFn( + useSessionStore, + (state) => selectWorkspaceStatusesForBadges(state), + workspaceEqualityFns.deep, + ); +} diff --git a/packages/app/src/stores/session-store-hooks/selectors.test.ts b/packages/app/src/stores/session-store-hooks/selectors.test.ts new file mode 100644 index 000000000..9f4012482 --- /dev/null +++ b/packages/app/src/stores/session-store-hooks/selectors.test.ts @@ -0,0 +1,423 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { + composeWorkspaceStructure, + selectHasWorkspaces, + selectProjectOrder, + selectRecommendedProjectPaths, + selectResolveWorkspaceIdByCwd, + selectWorkspace, + selectWorkspaceExecutionAuthority, + selectWorkspaceFields, + selectWorkspaceKeys, + selectWorkspaceOrderByScopeForServer, + selectWorkspaceStatusesForBadges, + selectWorkspaceStructureProjects, + workspaceEqualityFns, + type SidebarOrderSnapshot, +} from "./selectors"; +import { useSessionStore, type WorkspaceDescriptor } from "../session-store"; + +const SERVER_ID = "test-server"; + +function createWorkspace( + input: Partial & Pick, +): WorkspaceDescriptor { + return { + id: input.id, + projectId: input.projectId ?? "project-1", + projectDisplayName: input.projectDisplayName ?? "Project 1", + projectRootPath: input.projectRootPath ?? "/repo", + workspaceDirectory: input.workspaceDirectory ?? "/repo", + projectKind: input.projectKind ?? "git", + workspaceKind: input.workspaceKind ?? "local_checkout", + name: input.name ?? "main", + status: input.status ?? "done", + archivingAt: input.archivingAt ?? null, + diffStat: input.diffStat ?? null, + scripts: input.scripts ?? [], + }; +} + +function initializeWorkspaces(workspaces: WorkspaceDescriptor[]): void { + useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient); + useSessionStore + .getState() + .setWorkspaces(SERVER_ID, new Map(workspaces.map((workspace) => [workspace.id, workspace]))); +} + +interface Subscribable { + getState(): S; + subscribe(listener: (state: S, prev: S) => void): () => void; +} + +interface TrackedSelector { + readonly current: T; + stop(): void; +} + +// Mirrors what useStoreWithEqualityFn does: hold onto the previous selection and only +// publish a new value when the equality function rejects it. Lets tests assert reference +// stability without React or a DOM. +function trackSelector( + store: Subscribable, + select: (state: S) => T, + eq: (a: T, b: T) => boolean, +): TrackedSelector { + let current = select(store.getState()); + const stop = store.subscribe((state) => { + const candidate = select(state); + if (!eq(candidate, current)) { + current = candidate; + } + }); + return { + get current() { + return current; + }, + stop, + }; +} + +function emptySidebarOrder(): SidebarOrderSnapshot { + return { + projectOrderByServerId: {}, + workspaceOrderByServerAndProject: {}, + }; +} + +afterEach(() => { + useSessionStore.getState().clearSession(SERVER_ID); +}); + +describe("selectWorkspace", () => { + it("resolves a descriptor when the route id matches workspace identity but not the map key", () => { + const workspace = createWorkspace({ id: "workspace-a" }); + useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient); + useSessionStore.getState().setWorkspaces(SERVER_ID, new Map([["map-key-a", workspace]])); + + expect(selectWorkspace(useSessionStore.getState(), SERVER_ID, workspace.id)).toBe(workspace); + }); + + it("keeps the descriptor reference for unrelated workspace updates", () => { + const workspaceA = createWorkspace({ id: "workspace-a", name: "A" }); + const workspaceB = createWorkspace({ id: "workspace-b", name: "B" }); + initializeWorkspaces([workspaceA, workspaceB]); + + const tracked = trackSelector( + useSessionStore, + (state) => selectWorkspace(state, SERVER_ID, workspaceA.id), + workspaceEqualityFns.identity, + ); + const before = tracked.current; + + useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceB, status: "running" }]); + expect(tracked.current).toBe(before); + + useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceA, status: "attention" }]); + expect(tracked.current).not.toBe(before); + + tracked.stop(); + }); + + it("keeps the descriptor reference when the observed workspace is rewritten with content-equal data", () => { + const workspace = createWorkspace({ id: "workspace-a", scripts: [] }); + initializeWorkspaces([workspace]); + + const tracked = trackSelector( + useSessionStore, + (state) => selectWorkspace(state, SERVER_ID, workspace.id), + workspaceEqualityFns.identity, + ); + const before = tracked.current; + + useSessionStore + .getState() + .setWorkspaces(SERVER_ID, new Map([[workspace.id, { ...workspace, scripts: [] }]])); + expect(tracked.current).toBe(before); + + tracked.stop(); + }); +}); + +describe("selectWorkspaceExecutionAuthority", () => { + it("preserves the old missing-workspace message with the requested id", () => { + initializeWorkspaces([]); + + expect( + selectWorkspaceExecutionAuthority(useSessionStore.getState(), SERVER_ID, "missing-id"), + ).toMatchObject({ + ok: false, + message: "Workspace not found: missing-id", + }); + }); + + it("keeps deep-equal authority references under unrelated workspace updates", () => { + const workspaceA = createWorkspace({ id: "workspace-a", name: "A" }); + const workspaceB = createWorkspace({ id: "workspace-b", name: "B" }); + initializeWorkspaces([workspaceA, workspaceB]); + + const tracked = trackSelector( + useSessionStore, + (state) => selectWorkspaceExecutionAuthority(state, SERVER_ID, workspaceA.id), + workspaceEqualityFns.deep, + ); + const before = tracked.current; + + useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceB, status: "running" }]); + expect(tracked.current).toBe(before); + + tracked.stop(); + }); +}); + +describe("selectWorkspaceFields", () => { + it("keeps deep-equal projection references until projected fields change", () => { + const workspace = createWorkspace({ id: "workspace-a", name: "A", status: "done" }); + initializeWorkspaces([workspace]); + + const selectIdentity = (current: { id: string; name: string }) => ({ + identity: { id: current.id, name: current.name }, + }); + const tracked = trackSelector( + useSessionStore, + (state) => selectWorkspaceFields(state, SERVER_ID, workspace.id, selectIdentity), + workspaceEqualityFns.deep, + ); + const before = tracked.current; + + useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspace, status: "running" }]); + expect(tracked.current).toBe(before); + + useSessionStore + .getState() + .mergeWorkspaces(SERVER_ID, [{ ...workspace, name: "A renamed", status: "running" }]); + expect(tracked.current).not.toBe(before); + + tracked.stop(); + }); +}); + +describe("workspace structure composition", () => { + function snapshotStructure( + serverId: string, + sidebar: SidebarOrderSnapshot, + ): ReturnType { + return composeWorkspaceStructure({ + serverId, + projects: selectWorkspaceStructureProjects(useSessionStore.getState(), serverId), + projectOrder: selectProjectOrder(sidebar, serverId), + workspaceOrderByScope: selectWorkspaceOrderByScopeForServer(sidebar, serverId), + }); + } + + it("changes for membership updates but not status-only updates", () => { + const workspaceA = createWorkspace({ id: "workspace-a", name: "A" }); + const workspaceB = createWorkspace({ id: "workspace-b", name: "B" }); + initializeWorkspaces([workspaceA]); + + const tracked = trackSelector( + useSessionStore, + (state) => selectWorkspaceStructureProjects(state, SERVER_ID), + workspaceEqualityFns.deep, + ); + const before = tracked.current; + + useSessionStore.getState().mergeWorkspaces(SERVER_ID, [workspaceB]); + const afterAdd = tracked.current; + expect(afterAdd).not.toBe(before); + expect(afterAdd[0]?.workspaceKeys).toEqual(["workspace-a", "workspace-b"]); + + useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceA, status: "running" }]); + expect(tracked.current).toBe(afterAdd); + + tracked.stop(); + }); + + it("changes when a structure-relevant project identity field changes", () => { + const workspace = createWorkspace({ + id: "workspace-a", + projectDisplayName: "Project 1", + }); + initializeWorkspaces([workspace]); + + const tracked = trackSelector( + useSessionStore, + (state) => selectWorkspaceStructureProjects(state, SERVER_ID), + workspaceEqualityFns.deep, + ); + const before = tracked.current; + + useSessionStore + .getState() + .mergeWorkspaces(SERVER_ID, [{ ...workspace, projectDisplayName: "Project Renamed" }]); + expect(tracked.current).not.toBe(before); + + tracked.stop(); + }); + + it("changes the composed structure when persisted sidebar project order changes", () => { + const workspaceA = createWorkspace({ + id: "workspace-a", + projectId: "project-a", + projectDisplayName: "Project A", + }); + const workspaceB = createWorkspace({ + id: "workspace-b", + projectId: "project-b", + projectDisplayName: "Project B", + }); + initializeWorkspaces([workspaceA, workspaceB]); + + const before = snapshotStructure(SERVER_ID, emptySidebarOrder()); + const after = snapshotStructure(SERVER_ID, { + ...emptySidebarOrder(), + projectOrderByServerId: { [SERVER_ID]: ["project-b", "project-a"] }, + }); + + expect(after.projects.map((project) => project.projectKey)).toEqual(["project-b", "project-a"]); + expect(after).not.toEqual(before); + }); +}); + +describe("selectWorkspaceKeys", () => { + it("changes for reorder updates but not content-only updates", () => { + const workspaceA = createWorkspace({ id: "workspace-a", name: "A" }); + const workspaceB = createWorkspace({ id: "workspace-b", name: "B" }); + initializeWorkspaces([workspaceA, workspaceB]); + + const tracked = trackSelector( + useSessionStore, + (state) => selectWorkspaceKeys(state, SERVER_ID), + workspaceEqualityFns.deep, + ); + const before = tracked.current; + expect(before).toEqual(["workspace-a", "workspace-b"]); + + useSessionStore.getState().setWorkspaces( + SERVER_ID, + new Map([ + [workspaceB.id, workspaceB], + [workspaceA.id, workspaceA], + ]), + ); + const afterReorder = tracked.current; + expect(afterReorder).not.toBe(before); + expect(afterReorder).toEqual(["workspace-b", "workspace-a"]); + + useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceA, status: "running" }]); + expect(tracked.current).toBe(afterReorder); + + tracked.stop(); + }); +}); + +describe("selectRecommendedProjectPaths", () => { + it("updates when an existing workspace project root changes", () => { + const workspace = createWorkspace({ id: "workspace-a", projectRootPath: "/repo/a" }); + initializeWorkspaces([workspace]); + + useSessionStore + .getState() + .mergeWorkspaces(SERVER_ID, [{ ...workspace, projectRootPath: "/repo/b" }]); + + expect(selectRecommendedProjectPaths(useSessionStore.getState(), SERVER_ID)).toEqual([ + "/repo/b", + ]); + }); + + it("keeps the path list reference under unrelated workspace updates", () => { + const workspace = createWorkspace({ id: "workspace-a", projectRootPath: "/repo/a" }); + initializeWorkspaces([workspace]); + + const tracked = trackSelector( + useSessionStore, + (state) => selectRecommendedProjectPaths(state, SERVER_ID), + workspaceEqualityFns.deep, + ); + const before = tracked.current; + + useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspace, status: "running" }]); + expect(tracked.current).toBe(before); + + tracked.stop(); + }); +}); + +describe("selectHasWorkspaces", () => { + it("stays stable when workspace membership changes without flipping the boolean", () => { + const workspaceA = createWorkspace({ id: "workspace-a" }); + const workspaceB = createWorkspace({ id: "workspace-b" }); + initializeWorkspaces([workspaceA]); + + const tracked = trackSelector( + useSessionStore, + (state) => selectHasWorkspaces(state, SERVER_ID), + workspaceEqualityFns.identity, + ); + const before = tracked.current; + + useSessionStore.getState().mergeWorkspaces(SERVER_ID, [workspaceB]); + expect(tracked.current).toBe(before); + + tracked.stop(); + }); +}); + +describe("selectResolveWorkspaceIdByCwd", () => { + it("resolves by cwd and stays stable under unrelated updates", () => { + const workspaceA = createWorkspace({ + id: "workspace-a", + workspaceDirectory: "/repo/a", + }); + const workspaceB = createWorkspace({ + id: "workspace-b", + workspaceDirectory: "/repo/b", + }); + initializeWorkspaces([workspaceA, workspaceB]); + + const tracked = trackSelector( + useSessionStore, + (state) => selectResolveWorkspaceIdByCwd(state, SERVER_ID, "/repo/a"), + workspaceEqualityFns.identity, + ); + const before = tracked.current; + expect(before).toBe("workspace-a"); + + useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceB, status: "running" }]); + expect(tracked.current).toBe(before); + + tracked.stop(); + }); +}); + +describe("selectWorkspaceStatusesForBadges", () => { + it("tracks status changes without changing for no-ops or unrelated descriptor updates", () => { + const workspaceA = createWorkspace({ id: "workspace-a", status: "done" }); + const workspaceB = createWorkspace({ id: "workspace-b", status: "attention" }); + initializeWorkspaces([workspaceA, workspaceB]); + + const tracked = trackSelector( + useSessionStore, + (state) => selectWorkspaceStatusesForBadges(state), + workspaceEqualityFns.deep, + ); + const before = tracked.current; + expect(before).toEqual(["done", "attention"]); + + useSessionStore + .getState() + .mergeWorkspaces(SERVER_ID, [{ ...workspaceA, scripts: [...workspaceA.scripts] }]); + expect(tracked.current).toBe(before); + + useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceB, name: "Renamed" }]); + expect(tracked.current).toBe(before); + + useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceA, status: "failed" }]); + expect(tracked.current).not.toBe(before); + expect(tracked.current).toEqual(["failed", "attention"]); + + tracked.stop(); + }); +}); diff --git a/packages/app/src/stores/session-store-hooks.ts b/packages/app/src/stores/session-store-hooks/selectors.ts similarity index 50% rename from packages/app/src/stores/session-store-hooks.ts rename to packages/app/src/stores/session-store-hooks/selectors.ts index f6dd921aa..338e0aa3e 100644 --- a/packages/app/src/stores/session-store-hooks.ts +++ b/packages/app/src/stores/session-store-hooks/selectors.ts @@ -1,7 +1,4 @@ import equal from "fast-deep-equal"; -import { useMemo } from "react"; -import { useStoreWithEqualityFn } from "zustand/traditional"; -import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; import { projectDisplayNameFromProjectId } from "@/utils/project-display-name"; import type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state"; import { @@ -10,11 +7,7 @@ import { resolveWorkspaceMapKeyByIdentity, type WorkspaceExecutionAuthorityResult, } from "@/utils/workspace-execution"; -import { useSessionStore, type WorkspaceDescriptor } from "./session-store"; - -// These are the ONLY supported ways to read workspaces from the session store. -// Do not write raw `useSessionStore` selectors that return the workspaces Map, a session object, -// or the sessions dict — it breaks re-render isolation. +import type { WorkspaceDescriptor } from "../session-store"; export type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state"; @@ -30,11 +23,23 @@ export interface WorkspaceStructure { projects: WorkspaceStructureProject[]; } -type SessionStoreSnapshot = ReturnType; +export interface SessionsSnapshot { + sessions: Record }>; +} + +export interface SidebarOrderSnapshot { + projectOrderByServerId: Record; + workspaceOrderByServerAndProject: Record; +} const EMPTY_WORKSPACE_KEYS: string[] = []; const EMPTY_WORKSPACE_STRUCTURE: WorkspaceStructure = { projects: [] }; +export const workspaceEqualityFns = { + identity: Object.is as (a: unknown, b: unknown) => boolean, + deep: equal as (a: unknown, b: unknown) => boolean, +}; + function getWorkspaceOrderScopeKey(serverId: string, projectKey: string): string { return `${serverId.trim()}::${projectKey.trim()}`; } @@ -113,8 +118,8 @@ function applyStoredOrdering(input: { return ordered; } -function selectWorkspace( - state: SessionStoreSnapshot, +export function selectWorkspace( + state: SessionsSnapshot, serverId: string | null, workspaceId: string | null, ): WorkspaceDescriptor | null { @@ -129,53 +134,32 @@ function selectWorkspace( return workspaceKey ? (workspaces?.get(workspaceKey) ?? null) : null; } -export function useWorkspace( - serverId: string | null, - workspaceId: string | null, -): WorkspaceDescriptor | null { - return useStoreWithEqualityFn( - useSessionStore, - (state) => selectWorkspace(state, serverId, workspaceId), - Object.is, - ); -} - -export function useWorkspaceFields( +export function selectWorkspaceFields( + state: SessionsSnapshot, serverId: string | null, workspaceId: string | null, project: (w: WorkspaceDescriptor) => T, ): T | null { - return useStoreWithEqualityFn( - useSessionStore, - (state) => { - const workspace = selectWorkspace(state, serverId, workspaceId); - return workspace ? project(workspace) : null; - }, - equal, - ); + const workspace = selectWorkspace(state, serverId, workspaceId); + return workspace ? project(workspace) : null; } -export function useWorkspaceExecutionAuthority( +export function selectWorkspaceExecutionAuthority( + state: SessionsSnapshot, serverId: string | null, workspaceId: string | null, ): WorkspaceExecutionAuthorityResult | null { - return useStoreWithEqualityFn( - useSessionStore, - (state) => { - if (serverId === null || workspaceId === null) { - return null; - } - return getWorkspaceExecutionAuthority({ - workspaces: state.sessions[serverId]?.workspaces, - workspaceId, - }); - }, - equal, - ); + if (serverId === null || workspaceId === null) { + return null; + } + return getWorkspaceExecutionAuthority({ + workspaces: state.sessions[serverId]?.workspaces, + workspaceId, + }); } -function selectWorkspaceStructureProjects( - state: SessionStoreSnapshot, +export function selectWorkspaceStructureProjects( + state: SessionsSnapshot, serverId: string | null, ): WorkspaceStructureProject[] { if (!serverId) { @@ -231,8 +215,14 @@ function selectWorkspaceStructureProjects( return projects; } -function selectWorkspaceOrderByScopeForServer( - workspaceOrderByScope: Record, +export function selectProjectOrder(state: SidebarOrderSnapshot, serverId: string | null): string[] { + return serverId + ? (state.projectOrderByServerId[serverId] ?? EMPTY_WORKSPACE_KEYS) + : EMPTY_WORKSPACE_KEYS; +} + +export function selectWorkspaceOrderByScopeForServer( + state: SidebarOrderSnapshot, serverId: string | null, ): Record { if (!serverId) { @@ -240,7 +230,7 @@ function selectWorkspaceOrderByScopeForServer( } const prefix = `${serverId.trim()}::`; const relevantOrderByScope: Record = {}; - for (const [scopeKey, order] of Object.entries(workspaceOrderByScope)) { + for (const [scopeKey, order] of Object.entries(state.workspaceOrderByServerAndProject)) { if (scopeKey.startsWith(prefix)) { relevantOrderByScope[scopeKey] = order; } @@ -248,136 +238,96 @@ function selectWorkspaceOrderByScopeForServer( return relevantOrderByScope; } -export function useWorkspaceStructure(serverId: string | null): WorkspaceStructure { - const projects = useStoreWithEqualityFn( - useSessionStore, - (state) => selectWorkspaceStructureProjects(state, serverId), - equal, - ); - const projectOrder = useStoreWithEqualityFn( - useSidebarOrderStore, - (state) => - serverId - ? (state.projectOrderByServerId[serverId] ?? EMPTY_WORKSPACE_KEYS) - : EMPTY_WORKSPACE_KEYS, - equal, - ); - const workspaceOrderByScope = useStoreWithEqualityFn( - useSidebarOrderStore, - (state) => - selectWorkspaceOrderByScopeForServer(state.workspaceOrderByServerAndProject, serverId), - equal, - ); +export function composeWorkspaceStructure(input: { + serverId: string | null; + projects: WorkspaceStructureProject[]; + projectOrder: readonly string[]; + workspaceOrderByScope: Record; +}): WorkspaceStructure { + if (!input.serverId || input.projects.length === 0) { + return EMPTY_WORKSPACE_STRUCTURE; + } - return useMemo(() => { - if (!serverId || projects.length === 0) { - return EMPTY_WORKSPACE_STRUCTURE; - } + const orderedProjects = applyStoredOrdering({ + items: input.projects.map((project) => { + const workspaceOrder = + input.workspaceOrderByScope[ + getWorkspaceOrderScopeKey(input.serverId as string, project.projectKey) + ] ?? EMPTY_WORKSPACE_KEYS; + const workspaceItems = project.workspaceKeys.map((workspaceId) => ({ + workspaceId, + workspaceKey: `${input.serverId as string}:${workspaceId}`, + })); + return { + ...project, + workspaceKeys: applyStoredOrdering({ + items: workspaceItems, + storedOrder: workspaceOrder, + getKey: (workspace) => workspace.workspaceKey, + }).map((workspace) => workspace.workspaceId), + }; + }), + storedOrder: input.projectOrder, + getKey: (project) => project.projectKey, + }); - const orderedProjects = applyStoredOrdering({ - items: projects.map((project) => { - const workspaceOrder = - workspaceOrderByScope[getWorkspaceOrderScopeKey(serverId, project.projectKey)] ?? - EMPTY_WORKSPACE_KEYS; - const workspaceItems = project.workspaceKeys.map((workspaceId) => ({ - workspaceId, - workspaceKey: `${serverId}:${workspaceId}`, - })); - return { - ...project, - workspaceKeys: applyStoredOrdering({ - items: workspaceItems, - storedOrder: workspaceOrder, - getKey: (workspace) => workspace.workspaceKey, - }).map((workspace) => workspace.workspaceId), - }; - }), - storedOrder: projectOrder, - getKey: (project) => project.projectKey, - }); - - return { projects: orderedProjects }; - }, [projectOrder, projects, serverId, workspaceOrderByScope]); + return { projects: orderedProjects }; } -export function useWorkspaceKeys(serverId: string | null): string[] { - return useStoreWithEqualityFn( - useSessionStore, - (state) => { - if (!serverId) { - return EMPTY_WORKSPACE_KEYS; - } - const workspaces = state.sessions[serverId]?.workspaces; - return workspaces ? Array.from(workspaces.keys()) : EMPTY_WORKSPACE_KEYS; - }, - equal, - ); +export function selectWorkspaceKeys(state: SessionsSnapshot, serverId: string | null): string[] { + if (!serverId) { + return EMPTY_WORKSPACE_KEYS; + } + const workspaces = state.sessions[serverId]?.workspaces; + return workspaces ? Array.from(workspaces.keys()) : EMPTY_WORKSPACE_KEYS; } -export function useRecommendedProjectPaths(serverId: string | null): string[] { - return useStoreWithEqualityFn( - useSessionStore, - (state) => { - if (!serverId) { - return EMPTY_WORKSPACE_KEYS; - } - const workspaces = state.sessions[serverId]?.workspaces; - if (!workspaces) { - return EMPTY_WORKSPACE_KEYS; - } - return Array.from(workspaces.values()) - .map((workspace) => workspace.projectRootPath) - .filter((path) => path.length > 0); - }, - equal, - ); +export function selectRecommendedProjectPaths( + state: SessionsSnapshot, + serverId: string | null, +): string[] { + if (!serverId) { + return EMPTY_WORKSPACE_KEYS; + } + const workspaces = state.sessions[serverId]?.workspaces; + if (!workspaces) { + return EMPTY_WORKSPACE_KEYS; + } + return Array.from(workspaces.values()) + .map((workspace) => workspace.projectRootPath) + .filter((path) => path.length > 0); } -export function useHasWorkspaces(serverId: string | null): boolean { - return useStoreWithEqualityFn( - useSessionStore, - (state) => { - if (!serverId) { - return false; - } - return (state.sessions[serverId]?.workspaces?.size ?? 0) > 0; - }, - Object.is, - ); +export function selectHasWorkspaces(state: SessionsSnapshot, serverId: string | null): boolean { + if (!serverId) { + return false; + } + return (state.sessions[serverId]?.workspaces?.size ?? 0) > 0; } -export function useResolveWorkspaceIdByCwd( +export function selectResolveWorkspaceIdByCwd( + state: SessionsSnapshot, serverId: string | null, cwd: string | null | undefined, ): string | null { - return useStoreWithEqualityFn( - useSessionStore, - (state) => { - if (!serverId || !cwd) { - return null; - } - const workspaces = state.sessions[serverId]?.workspaces; - return resolveWorkspaceIdByExecutionDirectory({ - workspaces: workspaces?.values(), - workspaceDirectory: cwd, - }); - }, - Object.is, - ); + if (!serverId || !cwd) { + return null; + } + const workspaces = state.sessions[serverId]?.workspaces; + return resolveWorkspaceIdByExecutionDirectory({ + workspaces: workspaces?.values(), + workspaceDirectory: cwd, + }); } -export function useWorkspaceStatusesForBadges(): DesktopBadgeWorkspaceStatus[] { - return useStoreWithEqualityFn( - useSessionStore, - (state) => { - const statuses: DesktopBadgeWorkspaceStatus[] = []; - for (const session of Object.values(state.sessions)) { - for (const workspace of session.workspaces.values()) { - statuses.push(workspace.status); - } - } - return statuses; - }, - equal, - ); +export function selectWorkspaceStatusesForBadges( + state: SessionsSnapshot, +): DesktopBadgeWorkspaceStatus[] { + const statuses: DesktopBadgeWorkspaceStatus[] = []; + for (const session of Object.values(state.sessions)) { + for (const workspace of session.workspaces.values()) { + statuses.push(workspace.status); + } + } + return statuses; } diff --git a/packages/app/src/stores/sidebar-collapsed-sections-store.test.ts b/packages/app/src/stores/sidebar-collapsed-sections-store.test.ts deleted file mode 100644 index 7fca91bde..000000000 --- a/packages/app/src/stores/sidebar-collapsed-sections-store.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("@react-native-async-storage/async-storage", () => ({ - default: { - getItem: vi.fn(async () => null), - setItem: vi.fn(), - removeItem: vi.fn(), - }, -})); - -import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store"; - -describe("sidebar-collapsed-sections-store", () => { - beforeEach(() => { - useSidebarCollapsedSectionsStore.setState({ - collapsedProjectKeys: new Set(), - }); - }); - - it("tracks collapsed project keys as a Set", () => { - const store = useSidebarCollapsedSectionsStore.getState(); - - store.setProjectCollapsed("project-a", true); - store.toggleProjectCollapsed("project-b"); - store.toggleProjectCollapsed("project-a"); - - expect(Array.from(useSidebarCollapsedSectionsStore.getState().collapsedProjectKeys)).toEqual([ - "project-b", - ]); - }); - - it("serializes collapsed project keys for preference storage", () => { - useSidebarCollapsedSectionsStore.setState({ - collapsedProjectKeys: new Set(["project-a", "project-b"]), - }); - - const partialize = useSidebarCollapsedSectionsStore.persist.getOptions().partialize; - - expect(partialize?.(useSidebarCollapsedSectionsStore.getState())).toEqual({ - collapsedProjectKeys: ["project-a", "project-b"], - }); - }); - - it("restores collapsed project keys from persisted preferences", () => { - const merge = useSidebarCollapsedSectionsStore.persist.getOptions().merge; - const restored = merge?.( - { - collapsedProjectKeys: ["project-a", "project-b", 42], - }, - useSidebarCollapsedSectionsStore.getState(), - ); - - expect(Array.from(restored?.collapsedProjectKeys ?? [])).toEqual(["project-a", "project-b"]); - }); - - it("keeps the existing state object when persisted preferences do not change collapsed keys", () => { - const merge = useSidebarCollapsedSectionsStore.persist.getOptions().merge; - const currentState = useSidebarCollapsedSectionsStore.getState(); - - expect(merge?.({}, currentState)).toBe(currentState); - expect(merge?.({ collapsedProjectKeys: [] }, currentState)).toBe(currentState); - }); -}); diff --git a/packages/app/src/stores/sidebar-collapsed-sections-store.ts b/packages/app/src/stores/sidebar-collapsed-sections-store.ts deleted file mode 100644 index 43013074d..000000000 --- a/packages/app/src/stores/sidebar-collapsed-sections-store.ts +++ /dev/null @@ -1,91 +0,0 @@ -import AsyncStorage from "@react-native-async-storage/async-storage"; -import { create } from "zustand"; -import { createJSONStorage, persist } from "zustand/middleware"; - -interface SidebarCollapsedSectionsState { - collapsedProjectKeys: Set; - toggleProjectCollapsed: (projectKey: string) => void; - setProjectCollapsed: (projectKey: string, collapsed: boolean) => void; -} - -interface PersistedSidebarCollapsedSectionsState { - collapsedProjectKeys?: string[]; -} - -function serializeCollapsedProjectKeys(keys: Set): string[] { - return Array.from(keys); -} - -function deserializeCollapsedProjectKeys(value: unknown): Set { - if (!Array.isArray(value)) { - return new Set(); - } - - return new Set(value.filter((key): key is string => typeof key === "string")); -} - -function areSetsEqual(left: Set, right: Set): boolean { - if (left.size !== right.size) { - return false; - } - - for (const key of left) { - if (!right.has(key)) { - return false; - } - } - - return true; -} - -export const useSidebarCollapsedSectionsStore = create()( - persist( - (set) => ({ - collapsedProjectKeys: new Set(), - toggleProjectCollapsed: (projectKey) => - set((state) => { - const next = new Set(state.collapsedProjectKeys); - if (next.has(projectKey)) { - next.delete(projectKey); - } else { - next.add(projectKey); - } - return { collapsedProjectKeys: next }; - }), - setProjectCollapsed: (projectKey, collapsed) => - set((state) => { - const next = new Set(state.collapsedProjectKeys); - if (collapsed) { - next.add(projectKey); - } else { - next.delete(projectKey); - } - return { collapsedProjectKeys: next }; - }), - }), - { - name: "sidebar-collapsed-sections", - storage: createJSONStorage(() => AsyncStorage), - partialize: (state) => ({ - collapsedProjectKeys: serializeCollapsedProjectKeys(state.collapsedProjectKeys), - }), - merge: (persistedState, currentState) => { - const persisted = persistedState as PersistedSidebarCollapsedSectionsState | undefined; - if (!persisted?.collapsedProjectKeys) { - return currentState; - } - const collapsedProjectKeys = deserializeCollapsedProjectKeys( - persisted.collapsedProjectKeys, - ); - if (areSetsEqual(currentState.collapsedProjectKeys, collapsedProjectKeys)) { - return currentState; - } - - return { - ...currentState, - collapsedProjectKeys, - }; - }, - }, - ), -); diff --git a/packages/app/src/stores/sidebar-collapsed-sections-store/index.ts b/packages/app/src/stores/sidebar-collapsed-sections-store/index.ts new file mode 100644 index 000000000..ce56b7d6a --- /dev/null +++ b/packages/app/src/stores/sidebar-collapsed-sections-store/index.ts @@ -0,0 +1,37 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; +import { + type CollapsedProjectsState, + mergePersistedCollapsedProjects, + serializeCollapsedProjects, + setProjectCollapsed, + toggleProjectCollapsed, +} from "./state"; + +interface SidebarCollapsedSectionsState extends CollapsedProjectsState { + toggleProjectCollapsed: (projectKey: string) => void; + setProjectCollapsed: (projectKey: string, collapsed: boolean) => void; +} + +export const useSidebarCollapsedSectionsStore = create()( + persist( + (set) => ({ + collapsedProjectKeys: new Set(), + toggleProjectCollapsed: (projectKey) => + set((state) => toggleProjectCollapsed(state, projectKey)), + setProjectCollapsed: (projectKey, collapsed) => + set((state) => setProjectCollapsed(state, projectKey, collapsed)), + }), + { + name: "sidebar-collapsed-sections", + storage: createJSONStorage(() => AsyncStorage), + partialize: (state) => serializeCollapsedProjects(state), + merge: (persistedState, currentState) => + mergePersistedCollapsedProjects( + persistedState as { collapsedProjectKeys?: unknown } | undefined, + currentState, + ), + }, + ), +); diff --git a/packages/app/src/stores/sidebar-collapsed-sections-store/state.test.ts b/packages/app/src/stores/sidebar-collapsed-sections-store/state.test.ts new file mode 100644 index 000000000..f9ccb17e7 --- /dev/null +++ b/packages/app/src/stores/sidebar-collapsed-sections-store/state.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { + type CollapsedProjectsState, + mergePersistedCollapsedProjects, + serializeCollapsedProjects, + setProjectCollapsed, + toggleProjectCollapsed, +} from "@/stores/sidebar-collapsed-sections-store/state"; + +function emptyState(): CollapsedProjectsState { + return { collapsedProjectKeys: new Set() }; +} + +describe("sidebar collapsed projects transitions", () => { + it("tracks collapsed project keys as a Set", () => { + let state = emptyState(); + + state = setProjectCollapsed(state, "project-a", true); + state = toggleProjectCollapsed(state, "project-b"); + state = toggleProjectCollapsed(state, "project-a"); + + expect(Array.from(state.collapsedProjectKeys)).toEqual(["project-b"]); + }); + + it("serializes collapsed project keys for preference storage", () => { + const state: CollapsedProjectsState = { + collapsedProjectKeys: new Set(["project-a", "project-b"]), + }; + + expect(serializeCollapsedProjects(state)).toEqual({ + collapsedProjectKeys: ["project-a", "project-b"], + }); + }); + + it("restores collapsed project keys from persisted preferences", () => { + const restored = mergePersistedCollapsedProjects( + { collapsedProjectKeys: ["project-a", "project-b", 42] }, + emptyState(), + ); + + expect(Array.from(restored.collapsedProjectKeys)).toEqual(["project-a", "project-b"]); + }); + + it("keeps the existing state object when persisted preferences do not change collapsed keys", () => { + const currentState = emptyState(); + + expect(mergePersistedCollapsedProjects(undefined, currentState)).toBe(currentState); + expect(mergePersistedCollapsedProjects({}, currentState)).toBe(currentState); + expect(mergePersistedCollapsedProjects({ collapsedProjectKeys: [] }, currentState)).toBe( + currentState, + ); + }); +}); diff --git a/packages/app/src/stores/sidebar-collapsed-sections-store/state.ts b/packages/app/src/stores/sidebar-collapsed-sections-store/state.ts new file mode 100644 index 000000000..1807e068b --- /dev/null +++ b/packages/app/src/stores/sidebar-collapsed-sections-store/state.ts @@ -0,0 +1,73 @@ +export interface CollapsedProjectsState { + collapsedProjectKeys: Set; +} + +export interface PersistedCollapsedProjects { + collapsedProjectKeys?: unknown; +} + +export function toggleProjectCollapsed( + state: CollapsedProjectsState, + projectKey: string, +): CollapsedProjectsState { + const next = new Set(state.collapsedProjectKeys); + if (next.has(projectKey)) { + next.delete(projectKey); + } else { + next.add(projectKey); + } + return { collapsedProjectKeys: next }; +} + +export function setProjectCollapsed( + state: CollapsedProjectsState, + projectKey: string, + collapsed: boolean, +): CollapsedProjectsState { + const next = new Set(state.collapsedProjectKeys); + if (collapsed) { + next.add(projectKey); + } else { + next.delete(projectKey); + } + return { collapsedProjectKeys: next }; +} + +export function serializeCollapsedProjects(state: CollapsedProjectsState): { + collapsedProjectKeys: string[]; +} { + return { collapsedProjectKeys: Array.from(state.collapsedProjectKeys) }; +} + +export function mergePersistedCollapsedProjects( + persisted: PersistedCollapsedProjects | undefined, + current: S, +): S { + if (!persisted?.collapsedProjectKeys) { + return current; + } + const restored = deserializeCollapsedProjectKeys(persisted.collapsedProjectKeys); + if (areSetsEqual(current.collapsedProjectKeys, restored)) { + return current; + } + return { ...current, collapsedProjectKeys: restored }; +} + +function deserializeCollapsedProjectKeys(value: unknown): Set { + if (!Array.isArray(value)) { + return new Set(); + } + return new Set(value.filter((key): key is string => typeof key === "string")); +} + +function areSetsEqual(left: Set, right: Set): boolean { + if (left.size !== right.size) { + return false; + } + for (const key of left) { + if (!right.has(key)) { + return false; + } + } + return true; +} diff --git a/packages/app/src/stores/workspace-subagents-integration.test.ts b/packages/app/src/stores/workspace-subagents-integration.test.ts index 132886783..4219b5203 100644 --- a/packages/app/src/stores/workspace-subagents-integration.test.ts +++ b/packages/app/src/stores/workspace-subagents-integration.test.ts @@ -9,62 +9,6 @@ import { selectSubagentsForParent } from "@/subagents"; import { buildWorkspaceTabPersistenceKey, useWorkspaceLayoutStore } from "./workspace-layout-store"; import { useSessionStore, type Agent } from "./session-store"; -vi.mock("lucide-react-native", () => ({ - Archive: () => null, - Check: () => null, - ChevronDown: () => null, - ChevronRight: () => null, -})); - -vi.mock("react-native-unistyles", () => ({ - StyleSheet: { - create: (factory: unknown) => - typeof factory === "function" - ? factory({ - spacing: { 0: 0, 1: 4, 2: 8, 3: 12, 4: 16 }, - borderWidth: { 1: 1 }, - borderRadius: { sm: 4, md: 6, lg: 8, "2xl": 16, full: 999 }, - fontSize: { xs: 11, sm: 13, base: 15 }, - fontWeight: { normal: "400", medium: "500" }, - iconSize: { sm: 14, md: 18 }, - colors: { - foreground: "#fff", - foregroundMuted: "#aaa", - surface0: "#000", - surface1: "#111", - surface2: "#222", - surface3: "#333", - border: "#444", - borderAccent: "#555", - accent: "#0a84ff", - palette: { - amber: { 500: "#ffbf00", 700: "#aa8000" }, - blue: { 500: "#0a84ff" }, - red: { 500: "#ff453a" }, - green: { 500: "#30d158" }, - }, - }, - }) - : factory, - }, - useUnistyles: () => ({ theme: { colors: {} } }), - withUnistyles: (component: T) => component, -})); - -vi.mock("@/components/provider-icons", () => ({ - getProviderIcon: () => null, -})); - -vi.mock("@/components/ui/tooltip", () => ({ - Tooltip: ({ children }: { children: unknown }) => children, - TooltipTrigger: ({ children }: { children: unknown }) => children, - TooltipContent: () => null, -})); - -vi.mock("@/screens/workspace/workspace-tab-presentation", () => ({ - WorkspaceTabIcon: () => null, -})); - vi.mock("@react-native-async-storage/async-storage", () => { const storage = new Map(); return { diff --git a/packages/app/src/stores/workspace-tabs-store.test.ts b/packages/app/src/stores/workspace-tabs-store.test.ts deleted file mode 100644 index b9e0e7c3a..000000000 --- a/packages/app/src/stores/workspace-tabs-store.test.ts +++ /dev/null @@ -1,353 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("@react-native-async-storage/async-storage", () => { - const storage = new Map(); - return { - default: { - getItem: vi.fn(async (key: string) => storage.get(key) ?? null), - setItem: vi.fn(async (key: string, value: string) => { - storage.set(key, value); - }), - removeItem: vi.fn(async (key: string) => { - storage.delete(key); - }), - }, - }; -}); - -import { - buildWorkspaceTabPersistenceKey, - useWorkspaceTabsStore, -} from "@/stores/workspace-tabs-store"; - -const SERVER_ID = "server-1"; -const WORKSPACE_ID = "/repo/worktree"; - -describe("buildWorkspaceTabPersistenceKey", () => { - it("preserves opaque workspace ids instead of normalizing them like paths", () => { - expect( - buildWorkspaceTabPersistenceKey({ - serverId: SERVER_ID, - workspaceId: " setup\\workspace\\ ", - }), - ).toBe("server-1:setup\\workspace\\"); - }); -}); - -describe("workspace-tabs-store retargetTab", () => { - beforeEach(() => { - useWorkspaceTabsStore.setState({ - uiTabsByWorkspace: {}, - tabOrderByWorkspace: {}, - focusedTabIdByWorkspace: {}, - }); - }); - - it("keeps a promoted draft tab in-place by mutating target without changing tab id", () => { - const draftTabId = "draft_123"; - const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID }); - expect(key).toBeTruthy(); - const workspaceKey = key as string; - - useWorkspaceTabsStore.getState().ensureTab({ - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { kind: "agent", agentId: "left" }, - }); - useWorkspaceTabsStore.getState().openDraftTab({ - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - draftId: draftTabId, - }); - useWorkspaceTabsStore.getState().ensureTab({ - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { kind: "agent", agentId: "right" }, - }); - useWorkspaceTabsStore.getState().focusTab({ - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - tabId: draftTabId, - }); - - const before = useWorkspaceTabsStore.getState(); - const beforeOrder = before.tabOrderByWorkspace[workspaceKey] ?? []; - - const retargeted = useWorkspaceTabsStore.getState().retargetTab({ - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - tabId: draftTabId, - target: { kind: "agent", agentId: "created" }, - }); - - const after = useWorkspaceTabsStore.getState(); - const afterOrder = after.tabOrderByWorkspace[workspaceKey] ?? []; - const tabs = after.uiTabsByWorkspace[workspaceKey] ?? []; - const retargetedTab = tabs.find((tab) => tab.tabId === draftTabId) ?? null; - - expect(retargeted).toBe(draftTabId); - expect(afterOrder).toEqual(beforeOrder); - expect(after.focusedTabIdByWorkspace[workspaceKey]).toBe(draftTabId); - expect(retargetedTab?.target).toEqual({ kind: "agent", agentId: "created" }); - }); - - it("ensureTab adds non-focused membership while openOrFocusTab focuses", () => { - const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID }); - expect(key).toBeTruthy(); - const workspaceKey = key as string; - - const terminalTabId = useWorkspaceTabsStore.getState().ensureTab({ - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { kind: "terminal", terminalId: "term-1" }, - }); - expect(terminalTabId).toBe("terminal_term-1"); - expect(useWorkspaceTabsStore.getState().focusedTabIdByWorkspace[workspaceKey]).toBeUndefined(); - - const focusedTabId = useWorkspaceTabsStore.getState().openOrFocusTab({ - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { kind: "terminal", terminalId: "term-1" }, - }); - expect(focusedTabId).toBe("terminal_term-1"); - expect(useWorkspaceTabsStore.getState().focusedTabIdByWorkspace[workspaceKey]).toBe( - "terminal_term-1", - ); - }); - - it("ensureTab deduplicates by target when a retargeted tab already exists", () => { - const draftTabId = "draft_x"; - const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID }); - expect(key).toBeTruthy(); - const workspaceKey = key as string; - - useWorkspaceTabsStore.getState().openDraftTab({ - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - draftId: draftTabId, - }); - useWorkspaceTabsStore.getState().retargetTab({ - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - tabId: draftTabId, - target: { kind: "agent", agentId: "created-agent" }, - }); - - const ensured = useWorkspaceTabsStore.getState().ensureTab({ - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { kind: "agent", agentId: "created-agent" }, - }); - - const state = useWorkspaceTabsStore.getState(); - const tabs = state.uiTabsByWorkspace[workspaceKey] ?? []; - const order = state.tabOrderByWorkspace[workspaceKey] ?? []; - const matchingTabs = tabs.filter( - (tab) => tab.target.kind === "agent" && tab.target.agentId === "created-agent", - ); - - expect(ensured).toBe(draftTabId); - expect(matchingTabs).toHaveLength(1); - expect(order).toEqual([draftTabId]); - }); - - it("openDraftTab creates a draft tab and deduplicates by draftId", () => { - const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID }); - expect(key).toBeTruthy(); - const workspaceKey = key as string; - - const firstTabId = useWorkspaceTabsStore.getState().openDraftTab({ - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - draftId: "draft-1", - }); - const secondTabId = useWorkspaceTabsStore.getState().openDraftTab({ - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - draftId: "draft-2", - }); - - const state = useWorkspaceTabsStore.getState(); - expect(firstTabId).toBe("draft-1"); - expect(secondTabId).toBe("draft-2"); - expect(state.tabOrderByWorkspace[workspaceKey]).toEqual([firstTabId, secondTabId]); - expect(state.uiTabsByWorkspace[workspaceKey]).toEqual([ - { - tabId: "draft-1", - target: { kind: "draft", draftId: "draft-1" }, - createdAt: expect.any(Number), - }, - { - tabId: "draft-2", - target: { kind: "draft", draftId: "draft-2" }, - createdAt: expect.any(Number), - }, - ]); - }); - - it("keeps draft setup on a retargeted tab", () => { - const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID }); - expect(key).toBeTruthy(); - const workspaceKey = key as string; - - const tabId = useWorkspaceTabsStore.getState().ensureTab({ - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { kind: "agent", agentId: "agent-1" }, - }); - if (!tabId) { - throw new Error("Expected tab id"); - } - expect(tabId).toBe("agent_agent-1"); - - useWorkspaceTabsStore.getState().retargetTab({ - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - tabId, - target: { - kind: "draft", - draftId: "draft-replacement", - setup: { - provider: "mock", - cwd: "/repo/worktree", - modeId: "load-test", - model: "ten-second-stream", - thinkingOptionId: null, - featureValues: { effort: "high" }, - }, - }, - }); - - expect(useWorkspaceTabsStore.getState().uiTabsByWorkspace[workspaceKey]?.[0]?.target).toEqual({ - kind: "draft", - draftId: "draft-replacement", - setup: { - provider: "mock", - cwd: "/repo/worktree", - modeId: "load-test", - model: "ten-second-stream", - thinkingOptionId: null, - featureValues: { effort: "high" }, - }, - }); - }); - - it("updates an existing draft tab when the setup changes", () => { - const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID }); - expect(key).toBeTruthy(); - const workspaceKey = key as string; - - const first = useWorkspaceTabsStore.getState().ensureTab({ - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { kind: "draft", draftId: "draft-1" }, - }); - const second = useWorkspaceTabsStore.getState().ensureTab({ - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { - kind: "draft", - draftId: "draft-1", - setup: { - provider: "mock", - cwd: "/repo/worktree", - modeId: "load-test", - model: "ten-second-stream", - thinkingOptionId: null, - featureValues: {}, - }, - }, - }); - - expect(second).toBe(first); - expect(useWorkspaceTabsStore.getState().uiTabsByWorkspace[workspaceKey]).toHaveLength(1); - expect(useWorkspaceTabsStore.getState().uiTabsByWorkspace[workspaceKey]?.[0]?.target).toEqual({ - kind: "draft", - draftId: "draft-1", - setup: { - provider: "mock", - cwd: "/repo/worktree", - modeId: "load-test", - model: "ten-second-stream", - thinkingOptionId: null, - featureValues: {}, - }, - }); - }); - - it("retargeting a background draft keeps the currently focused tab focused", () => { - const draftTabId = "draft_background"; - const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID }); - expect(key).toBeTruthy(); - const workspaceKey = key as string; - - useWorkspaceTabsStore.getState().openDraftTab({ - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - draftId: draftTabId, - }); - const focusedFileTabId = useWorkspaceTabsStore.getState().openOrFocusTab({ - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { kind: "file", path: "/repo/worktree/src/index.ts" }, - }); - - useWorkspaceTabsStore.getState().retargetTab({ - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - tabId: draftTabId, - target: { kind: "agent", agentId: "created-agent" }, - }); - - expect(useWorkspaceTabsStore.getState().focusedTabIdByWorkspace[workspaceKey]).toBe( - focusedFileTabId, - ); - }); - - it("openOrFocusTab re-focuses an existing file tab after the workspace focus changed", () => { - const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID }); - expect(key).toBeTruthy(); - const workspaceKey = key as string; - - const fileTabId = useWorkspaceTabsStore.getState().openOrFocusTab({ - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { kind: "file", path: "/repo/worktree/src/index.ts" }, - }); - const terminalTabId = useWorkspaceTabsStore.getState().openOrFocusTab({ - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { kind: "terminal", terminalId: "term-1" }, - }); - - expect(fileTabId).toBe("file_/repo/worktree/src/index.ts"); - expect(terminalTabId).toBe("terminal_term-1"); - expect(useWorkspaceTabsStore.getState().focusedTabIdByWorkspace[workspaceKey]).toBe( - terminalTabId, - ); - - const reopenedFileTabId = useWorkspaceTabsStore.getState().openOrFocusTab({ - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { kind: "file", path: "/repo/worktree/src/index.ts" }, - }); - - expect(reopenedFileTabId).toBe(fileTabId); - expect(useWorkspaceTabsStore.getState().focusedTabIdByWorkspace[workspaceKey]).toBe(fileTabId); - }); - - it("builds a deterministic setup tab keyed by workspace id", () => { - const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID }); - expect(key).toBeTruthy(); - const workspaceKey = key as string; - - const tabId = useWorkspaceTabsStore.getState().openOrFocusTab({ - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { kind: "setup", workspaceId: WORKSPACE_ID }, - }); - - expect(tabId).toBe(`setup_${WORKSPACE_ID}`); - expect(useWorkspaceTabsStore.getState().focusedTabIdByWorkspace[workspaceKey]).toBe(tabId); - }); -}); diff --git a/packages/app/src/stores/workspace-tabs-store.ts b/packages/app/src/stores/workspace-tabs-store.ts deleted file mode 100644 index ca0b12ca9..000000000 --- a/packages/app/src/stores/workspace-tabs-store.ts +++ /dev/null @@ -1,696 +0,0 @@ -import AsyncStorage from "@react-native-async-storage/async-storage"; -import type { AgentProvider } from "@getpaseo/protocol/agent-types"; -import { create } from "zustand"; -import { createJSONStorage, persist } from "zustand/middleware"; -import { - buildDeterministicWorkspaceTabId, - normalizeWorkspaceDraftTabSetup, - normalizeWorkspaceTabTarget, - workspaceTabTargetsEqual, -} from "@/workspace-tabs/identity"; -import type { WorkspaceFileTabTarget } from "@/workspace/file-open"; - -export interface WorkspaceDraftTabSetup { - provider: AgentProvider; - cwd: string; - modeId: string | null; - model: string | null; - thinkingOptionId: string | null; - featureValues: Record; -} - -export type WorkspaceTabTarget = - | { kind: "draft"; draftId: string; setup?: WorkspaceDraftTabSetup } - | { kind: "agent"; agentId: string } - | { kind: "terminal"; terminalId: string } - | { kind: "browser"; browserId: string } - | WorkspaceFileTabTarget - | { kind: "setup"; workspaceId: string }; - -export interface WorkspaceTab { - tabId: string; - target: WorkspaceTabTarget; - createdAt: number; -} - -function trimNonEmpty(value: string | null | undefined): string | null { - if (typeof value !== "string") { - return null; - } - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -export function buildWorkspaceTabPersistenceKey(input: { - serverId: string; - workspaceId: string; -}): string | null { - const serverId = trimNonEmpty(input.serverId); - const workspaceId = trimNonEmpty(input.workspaceId); - if (!serverId || !workspaceId) { - return null; - } - return `${serverId}:${workspaceId}`; -} - -function isPlainRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - -function toObjectRecord(value: unknown): Record | undefined { - return isPlainRecord(value) ? value : undefined; -} - -function normalizeTabOrder(list: unknown): string[] { - if (!Array.isArray(list)) { - return []; - } - const next: string[] = []; - const used = new Set(); - for (const value of list) { - const tabId = trimNonEmpty(typeof value === "string" ? value : null); - if (!tabId || used.has(tabId)) { - continue; - } - used.add(tabId); - next.push(tabId); - } - return next; -} - -function ensureInOrder(input: { current: string[]; tabId: string }): string[] { - if (input.current.includes(input.tabId)) { - return input.current; - } - return [...input.current, input.tabId]; -} - -function retargetTabAtIndex( - tab: WorkspaceTab, - index: number, - targetIndex: number, - normalizedTarget: WorkspaceTabTarget, -): WorkspaceTab { - return index === targetIndex ? { ...tab, target: normalizedTarget } : tab; -} - -function buildNextTabsForEnsure(args: { - currentTabs: WorkspaceTab[]; - existingIndex: number; - effectiveTabId: string; - normalizedTarget: WorkspaceTabTarget; - createdAt: number; -}): WorkspaceTab[] { - const { currentTabs, existingIndex, effectiveTabId, normalizedTarget, createdAt } = args; - if (existingIndex < 0) { - return [...currentTabs, { tabId: effectiveTabId, target: normalizedTarget, createdAt }]; - } - const existing = currentTabs[existingIndex]; - if (existing && workspaceTabTargetsEqual(existing.target, normalizedTarget)) { - return currentTabs; - } - return currentTabs.map((tab, index) => - retargetTabAtIndex(tab, index, existingIndex, normalizedTarget), - ); -} - -interface MigrationRawSources { - rawUiTabsByWorkspace: Record; - rawFocused: Record; - rawOrder: Record; - legacyOrder: Record; -} - -function extractMigrationRawSources(persistedState: unknown): MigrationRawSources { - const top = toObjectRecord(persistedState) ?? {}; - const rawState = toObjectRecord(top.state) ?? top; - - return { - rawUiTabsByWorkspace: - toObjectRecord( - rawState.uiTabsByWorkspace ?? - rawState.openTabsByWorkspace ?? - top.uiTabsByWorkspace ?? - top.openTabsByWorkspace, - ) ?? {}, - rawFocused: - toObjectRecord( - rawState.focusedTabIdByWorkspace ?? - rawState.lastFocusedTabByWorkspace ?? - top.focusedTabIdByWorkspace, - ) ?? {}, - rawOrder: toObjectRecord(rawState.tabOrderByWorkspace ?? top.tabOrderByWorkspace) ?? {}, - legacyOrder: - toObjectRecord( - rawState.tabOrderByWorkspace ?? - rawState.tabOrderLegacyByWorkspace ?? - top.tabOrderLegacyByWorkspace, - ) ?? {}, - }; -} - -function coerceWorkspaceTabTarget(raw: Record): WorkspaceTabTarget | null { - const kind = typeof raw.kind === "string" ? raw.kind : null; - if (kind === "draft" && typeof raw.draftId === "string") { - const setup = normalizeWorkspaceDraftTabSetup(raw.setup); - return normalizeWorkspaceTabTarget({ - kind: "draft", - draftId: raw.draftId, - ...(setup ? { setup } : {}), - }); - } - if (kind === "agent" && typeof raw.agentId === "string") { - return normalizeWorkspaceTabTarget({ kind: "agent", agentId: raw.agentId }); - } - if (kind === "terminal" && typeof raw.terminalId === "string") { - return normalizeWorkspaceTabTarget({ kind: "terminal", terminalId: raw.terminalId }); - } - if (kind === "browser" && typeof raw.browserId === "string") { - return normalizeWorkspaceTabTarget({ kind: "browser", browserId: raw.browserId }); - } - if (kind === "file" && typeof raw.path === "string") { - return normalizeWorkspaceTabTarget({ - kind: "file", - path: raw.path, - lineStart: typeof raw.lineStart === "number" ? raw.lineStart : undefined, - lineEnd: typeof raw.lineEnd === "number" ? raw.lineEnd : undefined, - }); - } - if (kind === "setup" && typeof raw.workspaceId === "string") { - return normalizeWorkspaceTabTarget({ kind: "setup", workspaceId: raw.workspaceId }); - } - return null; -} - -function migrateSingleTab(rawTab: unknown): WorkspaceTab | null { - const record = toObjectRecord(rawTab); - if (!record) { - return null; - } - const rawTarget = toObjectRecord(record.target); - const normalizedTarget = rawTarget ? coerceWorkspaceTabTarget(rawTarget) : null; - if (!normalizedTarget) { - return null; - } - const rawTabId = trimNonEmpty(typeof record.tabId === "string" ? record.tabId : null); - const tabId = rawTabId ?? buildDeterministicWorkspaceTabId(normalizedTarget); - const rawCreatedAt = record.createdAt; - return { - tabId, - target: normalizedTarget, - createdAt: typeof rawCreatedAt === "number" ? rawCreatedAt : Date.now(), - }; -} - -interface MigratedTabsForKey { - nextUiTabs: WorkspaceTab[]; - orderFromTabs: string[]; -} - -function migrateUiTabsForKey(rawEntries: unknown): MigratedTabsForKey { - const entries = Array.isArray(rawEntries) ? rawEntries : []; - const nextUiTabs: WorkspaceTab[] = []; - const orderFromTabs: string[] = []; - const usedOrder = new Set(); - - for (const rawTab of entries) { - const migrated = migrateSingleTab(rawTab); - if (!migrated) { - continue; - } - if (!usedOrder.has(migrated.tabId)) { - usedOrder.add(migrated.tabId); - orderFromTabs.push(migrated.tabId); - } - nextUiTabs.push(migrated); - } - - return { nextUiTabs, orderFromTabs }; -} - -function mergeExplicitTabOrder( - tabOrderByWorkspace: Record, - rawOrder: Record, -): void { - for (const key in rawOrder) { - const normalizedOrder = normalizeTabOrder(rawOrder[key]); - if (normalizedOrder.length === 0) { - continue; - } - const existing = tabOrderByWorkspace[key] ?? []; - tabOrderByWorkspace[key] = normalizeTabOrder([...existing, ...normalizedOrder]); - } -} - -function convertLegacyOrderEntry(entry: unknown): string | null { - const raw = typeof entry === "string" ? entry.trim() : ""; - if (!raw) { - return null; - } - if (raw.startsWith("agent:")) { - const agentId = raw.slice("agent:".length).trim(); - return agentId ? `agent_${agentId}` : null; - } - if (raw.startsWith("terminal:")) { - const terminalId = raw.slice("terminal:".length).trim(); - return terminalId ? `terminal_${terminalId}` : null; - } - return null; -} - -function normalizeLegacyOrderList(list: unknown[]): string[] { - const result: string[] = []; - for (const entry of list) { - const converted = convertLegacyOrderEntry(entry); - if (converted) { - result.push(converted); - } - } - return result; -} - -function mergeLegacyTabOrder( - tabOrderByWorkspace: Record, - legacyOrder: Record, -): void { - for (const key in legacyOrder) { - const list = legacyOrder[key]; - if (!Array.isArray(list) || list.length === 0) { - continue; - } - const normalizedLegacyOrder = normalizeLegacyOrderList(list); - if (normalizedLegacyOrder.length === 0) { - continue; - } - const existing = tabOrderByWorkspace[key] ?? []; - tabOrderByWorkspace[key] = normalizeTabOrder([...existing, ...normalizedLegacyOrder]); - } -} - -function resolveFocusedTabId(rawValue: unknown): string | null { - if (typeof rawValue === "string") { - return trimNonEmpty(rawValue); - } - if (!rawValue || typeof rawValue !== "object") { - return null; - } - const value = rawValue as { - kind?: string; - agentId?: string; - terminalId?: string; - draftId?: string; - }; - if (value.kind === "agent" && typeof value.agentId === "string" && value.agentId.trim()) { - return `agent_${value.agentId.trim()}`; - } - if ( - value.kind === "terminal" && - typeof value.terminalId === "string" && - value.terminalId.trim() - ) { - return `terminal_${value.terminalId.trim()}`; - } - if (value.kind === "draft" && typeof value.draftId === "string" && value.draftId.trim()) { - return value.draftId.trim(); - } - return null; -} - -function migrateFocusedTabIds( - focusedTabIdByWorkspace: Record, - rawFocused: Record, -): void { - for (const key in rawFocused) { - const resolved = resolveFocusedTabId(rawFocused[key]); - if (resolved) { - focusedTabIdByWorkspace[key] = resolved; - } - } -} - -function migrateWorkspaceTabsState(persistedState: unknown): { - uiTabsByWorkspace: Record; - tabOrderByWorkspace: Record; - focusedTabIdByWorkspace: Record; -} { - const { rawUiTabsByWorkspace, rawFocused, rawOrder, legacyOrder } = - extractMigrationRawSources(persistedState); - - const uiTabsByWorkspace: Record = {}; - const tabOrderByWorkspace: Record = {}; - const focusedTabIdByWorkspace: Record = {}; - - for (const key in rawUiTabsByWorkspace) { - const { nextUiTabs, orderFromTabs } = migrateUiTabsForKey(rawUiTabsByWorkspace[key]); - if (nextUiTabs.length > 0) { - uiTabsByWorkspace[key] = nextUiTabs; - } - if (orderFromTabs.length > 0) { - tabOrderByWorkspace[key] = orderFromTabs; - } - } - - mergeExplicitTabOrder(tabOrderByWorkspace, rawOrder); - mergeLegacyTabOrder(tabOrderByWorkspace, legacyOrder); - migrateFocusedTabIds(focusedTabIdByWorkspace, rawFocused); - - return { - uiTabsByWorkspace, - tabOrderByWorkspace, - focusedTabIdByWorkspace, - }; -} - -interface WorkspaceTabsState { - uiTabsByWorkspace: Record; - tabOrderByWorkspace: Record; - focusedTabIdByWorkspace: Record; - openDraftTab: (input: { - serverId: string; - workspaceId: string; - draftId: string; - }) => string | null; - ensureTab: (input: { - serverId: string; - workspaceId: string; - target: WorkspaceTabTarget; - }) => string | null; - openOrFocusTab: (input: { - serverId: string; - workspaceId: string; - target: WorkspaceTabTarget; - }) => string | null; - focusTab: (input: { serverId: string; workspaceId: string; tabId: string }) => void; - closeTab: (input: { serverId: string; workspaceId: string; tabId: string }) => void; - retargetTab: (input: { - serverId: string; - workspaceId: string; - tabId: string; - target: WorkspaceTabTarget; - }) => string | null; - reorderTabs: (input: { serverId: string; workspaceId: string; tabIds: string[] }) => void; - getWorkspaceTabs: (input: { serverId: string; workspaceId: string }) => WorkspaceTab[]; - purgeWorkspace: (input: { serverId: string; workspaceId: string }) => void; -} - -export const useWorkspaceTabsStore = create()( - persist( - (set, get) => ({ - uiTabsByWorkspace: {}, - tabOrderByWorkspace: {}, - focusedTabIdByWorkspace: {}, - openDraftTab: ({ serverId, workspaceId, draftId }) => { - const normalizedDraftId = trimNonEmpty(draftId); - if (!normalizedDraftId) { - return null; - } - return get().openOrFocusTab({ - serverId, - workspaceId, - target: { kind: "draft", draftId: normalizedDraftId }, - }); - }, - ensureTab: ({ serverId, workspaceId, target }) => { - const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); - const normalizedTarget = normalizeWorkspaceTabTarget(target); - if (!key || !normalizedTarget) { - return null; - } - - const deterministicTabId = buildDeterministicWorkspaceTabId(normalizedTarget); - let resolvedTabId = deterministicTabId; - const now = Date.now(); - - set((state) => { - const currentTabs = state.uiTabsByWorkspace[key] ?? []; - const tabWithSameTarget = - currentTabs.find((tab) => workspaceTabTargetsEqual(tab.target, normalizedTarget)) ?? - null; - const effectiveTabId = tabWithSameTarget?.tabId ?? deterministicTabId; - resolvedTabId = effectiveTabId; - - const currentOrder = state.tabOrderByWorkspace[key] ?? []; - const nextOrder = ensureInOrder({ current: currentOrder, tabId: effectiveTabId }); - const existingIndex = currentTabs.findIndex((tab) => tab.tabId === effectiveTabId); - const nextTabs = buildNextTabsForEnsure({ - currentTabs, - existingIndex, - effectiveTabId, - normalizedTarget, - createdAt: now, - }); - - return { - uiTabsByWorkspace: - nextTabs === currentTabs - ? state.uiTabsByWorkspace - : { ...state.uiTabsByWorkspace, [key]: nextTabs }, - tabOrderByWorkspace: - nextOrder === currentOrder - ? state.tabOrderByWorkspace - : { ...state.tabOrderByWorkspace, [key]: nextOrder }, - }; - }); - - return resolvedTabId; - }, - openOrFocusTab: ({ serverId, workspaceId, target }) => { - const tabId = get().ensureTab({ serverId, workspaceId, target }); - if (!tabId) { - return null; - } - get().focusTab({ serverId, workspaceId, tabId }); - return tabId; - }, - focusTab: ({ serverId, workspaceId, tabId }) => { - const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); - const normalizedTabId = trimNonEmpty(tabId); - if (!key || !normalizedTabId) { - return; - } - set((state) => { - if (state.focusedTabIdByWorkspace[key] === normalizedTabId) { - return state; - } - return { - ...state, - focusedTabIdByWorkspace: { - ...state.focusedTabIdByWorkspace, - [key]: normalizedTabId, - }, - }; - }); - }, - closeTab: ({ serverId, workspaceId, tabId }) => { - const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); - const normalizedTabId = trimNonEmpty(tabId); - if (!key || !normalizedTabId) { - return; - } - - set((state) => { - const currentTabs = state.uiTabsByWorkspace[key] ?? []; - const nextTabs = currentTabs.filter((tab) => tab.tabId !== normalizedTabId); - const currentOrder = state.tabOrderByWorkspace[key] ?? []; - const nextOrder = currentOrder.filter((value) => value !== normalizedTabId); - - let nextUiTabsByWorkspace: typeof state.uiTabsByWorkspace; - if (nextTabs.length === 0) { - const { [key]: _removed, ...rest } = state.uiTabsByWorkspace; - nextUiTabsByWorkspace = rest; - } else if (nextTabs.length === currentTabs.length) { - nextUiTabsByWorkspace = state.uiTabsByWorkspace; - } else { - nextUiTabsByWorkspace = { ...state.uiTabsByWorkspace, [key]: nextTabs }; - } - - let nextTabOrderByWorkspace: typeof state.tabOrderByWorkspace; - if (nextOrder.length === 0) { - const { [key]: _removed, ...rest } = state.tabOrderByWorkspace; - nextTabOrderByWorkspace = rest; - } else if (nextOrder.length === currentOrder.length) { - nextTabOrderByWorkspace = state.tabOrderByWorkspace; - } else { - nextTabOrderByWorkspace = { ...state.tabOrderByWorkspace, [key]: nextOrder }; - } - - const currentFocused = state.focusedTabIdByWorkspace[key] ?? null; - const nextFocused = - currentFocused !== normalizedTabId - ? currentFocused - : (nextOrder[nextOrder.length - 1] ?? null); - const nextFocusedByWorkspace = (() => { - if (!nextFocused) { - const { [key]: _removed, ...rest } = state.focusedTabIdByWorkspace; - return rest; - } - return { ...state.focusedTabIdByWorkspace, [key]: nextFocused }; - })(); - - const tabsChanged = nextTabs.length !== currentTabs.length; - const orderChanged = nextOrder.length !== currentOrder.length; - const focusChanged = - (state.focusedTabIdByWorkspace[key] ?? null) !== (nextFocusedByWorkspace[key] ?? null); - - if (!tabsChanged && !orderChanged && !focusChanged) { - return state; - } - - return { - uiTabsByWorkspace: nextUiTabsByWorkspace, - tabOrderByWorkspace: nextTabOrderByWorkspace, - focusedTabIdByWorkspace: nextFocusedByWorkspace, - }; - }); - }, - retargetTab: ({ serverId, workspaceId, tabId, target }) => { - const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); - const normalizedTabId = trimNonEmpty(tabId); - const normalizedTarget = normalizeWorkspaceTabTarget(target); - if (!key || !normalizedTabId || !normalizedTarget) { - return null; - } - - let retargetedTabId: string | null = null; - - set((state) => { - const currentTabs = state.uiTabsByWorkspace[key] ?? []; - const index = currentTabs.findIndex((tab) => tab.tabId === normalizedTabId); - if (index < 0) { - return state; - } - - const currentTarget = currentTabs[index]?.target; - if (currentTarget && workspaceTabTargetsEqual(currentTarget, normalizedTarget)) { - return state; - } - - const nextTabs = currentTabs.map((tab, tabIndex) => - tabIndex === index ? Object.assign({}, tab, { target: normalizedTarget }) : tab, - ); - retargetedTabId = normalizedTabId; - return { - uiTabsByWorkspace: { ...state.uiTabsByWorkspace, [key]: nextTabs }, - tabOrderByWorkspace: state.tabOrderByWorkspace, - focusedTabIdByWorkspace: state.focusedTabIdByWorkspace, - }; - }); - - return retargetedTabId; - }, - reorderTabs: ({ serverId, workspaceId, tabIds }) => { - const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); - if (!key) { - return; - } - - const normalized = normalizeTabOrder(tabIds); - set((state) => { - const current = state.tabOrderByWorkspace[key] ?? []; - if (current.length === normalized.length) { - let same = true; - for (let i = 0; i < current.length; i += 1) { - if (current[i] !== normalized[i]) { - same = false; - break; - } - } - if (same) { - return state; - } - } - - return { - ...state, - tabOrderByWorkspace: { - ...state.tabOrderByWorkspace, - [key]: normalized, - }, - }; - }); - }, - getWorkspaceTabs: ({ serverId, workspaceId }) => { - const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); - if (!key) { - return []; - } - return get().uiTabsByWorkspace[key] ?? []; - }, - purgeWorkspace: ({ serverId, workspaceId }) => { - const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); - if (!key) { - return; - } - set((state) => { - if ( - !(key in state.uiTabsByWorkspace) && - !(key in state.tabOrderByWorkspace) && - !(key in state.focusedTabIdByWorkspace) - ) { - return state; - } - const { [key]: _tabs, ...remainingUiTabsByWorkspace } = state.uiTabsByWorkspace; - const { [key]: _order, ...remainingTabOrderByWorkspace } = state.tabOrderByWorkspace; - const { [key]: _focused, ...remainingFocusedTabIdByWorkspace } = - state.focusedTabIdByWorkspace; - return { - ...state, - uiTabsByWorkspace: remainingUiTabsByWorkspace, - tabOrderByWorkspace: remainingTabOrderByWorkspace, - focusedTabIdByWorkspace: remainingFocusedTabIdByWorkspace, - }; - }); - }, - }), - { - name: "workspace-tabs-state", - version: 5, - storage: createJSONStorage(() => AsyncStorage), - partialize: (state) => { - const nextUiTabsByWorkspace: Record = {}; - for (const key in state.uiTabsByWorkspace) { - const tabs = (state.uiTabsByWorkspace[key] ?? []) - .map((tab) => { - const normalizedTarget = normalizeWorkspaceTabTarget(tab.target); - const normalizedTabId = trimNonEmpty(tab.tabId); - if (!normalizedTarget || !normalizedTabId) { - return null; - } - return { - tabId: normalizedTabId, - target: normalizedTarget, - createdAt: typeof tab.createdAt === "number" ? tab.createdAt : Date.now(), - } satisfies WorkspaceTab; - }) - .filter((tab): tab is WorkspaceTab => tab !== null); - if (tabs.length > 0) { - nextUiTabsByWorkspace[key] = tabs; - } - } - - const nextTabOrderByWorkspace: Record = {}; - for (const key in state.tabOrderByWorkspace) { - const order = normalizeTabOrder(state.tabOrderByWorkspace[key]); - if (order.length > 0) { - nextTabOrderByWorkspace[key] = order; - } - } - - const nextFocusedTabIdByWorkspace: Record = {}; - for (const key in state.focusedTabIdByWorkspace) { - const focusedTabId = trimNonEmpty(state.focusedTabIdByWorkspace[key]); - if (focusedTabId) { - nextFocusedTabIdByWorkspace[key] = focusedTabId; - } - } - - return { - uiTabsByWorkspace: nextUiTabsByWorkspace, - tabOrderByWorkspace: nextTabOrderByWorkspace, - focusedTabIdByWorkspace: nextFocusedTabIdByWorkspace, - }; - }, - migrate: (persistedState) => migrateWorkspaceTabsState(persistedState), - }, - ), -); diff --git a/packages/app/src/stores/workspace-tabs-store/index.ts b/packages/app/src/stores/workspace-tabs-store/index.ts new file mode 100644 index 000000000..013f3fca4 --- /dev/null +++ b/packages/app/src/stores/workspace-tabs-store/index.ts @@ -0,0 +1,124 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; +import { + applyCloseTab, + applyEnsureTab, + applyFocusTab, + applyOpenDraftTab, + applyOpenOrFocusTab, + applyPurgeWorkspace, + applyReorderTabs, + applyRetargetTab, + initialWorkspaceTabsCoreState, + migrateWorkspaceTabsState, + partializeWorkspaceTabsState, + selectWorkspaceTabs, + type WorkspaceTab, + type WorkspaceTabsCoreState, + type WorkspaceTabTarget, +} from "./state"; + +export { buildWorkspaceTabPersistenceKey } from "./state"; +export type { WorkspaceDraftTabSetup, WorkspaceTab, WorkspaceTabTarget } from "./state"; + +interface WorkspaceTabsState extends WorkspaceTabsCoreState { + openDraftTab: (input: { + serverId: string; + workspaceId: string; + draftId: string; + }) => string | null; + ensureTab: (input: { + serverId: string; + workspaceId: string; + target: WorkspaceTabTarget; + }) => string | null; + openOrFocusTab: (input: { + serverId: string; + workspaceId: string; + target: WorkspaceTabTarget; + }) => string | null; + focusTab: (input: { serverId: string; workspaceId: string; tabId: string }) => void; + closeTab: (input: { serverId: string; workspaceId: string; tabId: string }) => void; + retargetTab: (input: { + serverId: string; + workspaceId: string; + tabId: string; + target: WorkspaceTabTarget; + }) => string | null; + reorderTabs: (input: { serverId: string; workspaceId: string; tabIds: string[] }) => void; + getWorkspaceTabs: (input: { serverId: string; workspaceId: string }) => WorkspaceTab[]; + purgeWorkspace: (input: { serverId: string; workspaceId: string }) => void; +} + +export const useWorkspaceTabsStore = create()( + persist( + (set, get) => ({ + ...initialWorkspaceTabsCoreState, + openDraftTab: ({ serverId, workspaceId, draftId }) => { + let resolved: string | null = null; + set((state) => { + const result = applyOpenDraftTab(state, { + serverId, + workspaceId, + draftId, + now: Date.now(), + }); + resolved = result.tabId; + return result.state; + }); + return resolved; + }, + ensureTab: ({ serverId, workspaceId, target }) => { + let resolved: string | null = null; + set((state) => { + const result = applyEnsureTab(state, { + serverId, + workspaceId, + target, + now: Date.now(), + }); + resolved = result.tabId; + return result.state; + }); + return resolved; + }, + openOrFocusTab: ({ serverId, workspaceId, target }) => { + let resolved: string | null = null; + set((state) => { + const result = applyOpenOrFocusTab(state, { + serverId, + workspaceId, + target, + now: Date.now(), + }); + resolved = result.tabId; + return result.state; + }); + return resolved; + }, + focusTab: (input) => set((state) => applyFocusTab(state, input)), + closeTab: (input) => set((state) => applyCloseTab(state, input)), + retargetTab: ({ serverId, workspaceId, tabId, target }) => { + let resolved: string | null = null; + set((state) => { + const result = applyRetargetTab(state, { serverId, workspaceId, tabId, target }); + resolved = result.tabId; + return result.state; + }); + return resolved; + }, + reorderTabs: (input) => set((state) => applyReorderTabs(state, input)), + getWorkspaceTabs: (input) => selectWorkspaceTabs(get(), input), + purgeWorkspace: (input) => set((state) => applyPurgeWorkspace(state, input)), + }), + { + name: "workspace-tabs-state", + version: 5, + storage: createJSONStorage(() => AsyncStorage), + partialize: (state) => partializeWorkspaceTabsState(state, { now: Date.now() }), + migrate: (persistedState) => + migrateWorkspaceTabsState(persistedState, { now: Date.now() }) as WorkspaceTabsState, + }, + ), +); diff --git a/packages/app/src/stores/workspace-tabs-store/state.test.ts b/packages/app/src/stores/workspace-tabs-store/state.test.ts new file mode 100644 index 000000000..68b2ef6f5 --- /dev/null +++ b/packages/app/src/stores/workspace-tabs-store/state.test.ts @@ -0,0 +1,364 @@ +import { describe, expect, it } from "vitest"; +import { + applyCloseTab, + applyEnsureTab, + applyFocusTab, + applyOpenDraftTab, + applyOpenOrFocusTab, + applyRetargetTab, + buildWorkspaceTabPersistenceKey, + initialWorkspaceTabsCoreState, + type WorkspaceTabsCoreState, +} from "./state"; + +const SERVER_ID = "server-1"; +const WORKSPACE_ID = "/repo/worktree"; +const WORKSPACE_KEY = `${SERVER_ID}:${WORKSPACE_ID}`; + +const NOW = 1_700_000_000_000; + +function emptyState(): WorkspaceTabsCoreState { + return { + uiTabsByWorkspace: {}, + tabOrderByWorkspace: {}, + focusedTabIdByWorkspace: {}, + }; +} + +describe("buildWorkspaceTabPersistenceKey", () => { + it("preserves opaque workspace ids instead of normalizing them like paths", () => { + expect( + buildWorkspaceTabPersistenceKey({ + serverId: SERVER_ID, + workspaceId: " setup\\workspace\\ ", + }), + ).toBe("server-1:setup\\workspace\\"); + }); +}); + +describe("workspace-tabs-store reducers", () => { + it("keeps a promoted draft tab in-place by mutating target without changing tab id", () => { + const draftTabId = "draft_123"; + + let state = emptyState(); + state = applyEnsureTab(state, { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + target: { kind: "agent", agentId: "left" }, + now: NOW, + }).state; + state = applyOpenDraftTab(state, { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + draftId: draftTabId, + now: NOW, + }).state; + state = applyEnsureTab(state, { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + target: { kind: "agent", agentId: "right" }, + now: NOW, + }).state; + state = applyFocusTab(state, { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + tabId: draftTabId, + }); + + const beforeOrder = state.tabOrderByWorkspace[WORKSPACE_KEY] ?? []; + + const retargeted = applyRetargetTab(state, { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + tabId: draftTabId, + target: { kind: "agent", agentId: "created" }, + }); + + const afterOrder = retargeted.state.tabOrderByWorkspace[WORKSPACE_KEY] ?? []; + const tabs = retargeted.state.uiTabsByWorkspace[WORKSPACE_KEY] ?? []; + const retargetedTab = tabs.find((tab) => tab.tabId === draftTabId) ?? null; + + expect(retargeted.tabId).toBe(draftTabId); + expect(afterOrder).toEqual(beforeOrder); + expect(retargeted.state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe(draftTabId); + expect(retargetedTab?.target).toEqual({ kind: "agent", agentId: "created" }); + }); + + it("ensureTab adds non-focused membership while openOrFocusTab focuses", () => { + let state = emptyState(); + const ensured = applyEnsureTab(state, { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + target: { kind: "terminal", terminalId: "term-1" }, + now: NOW, + }); + state = ensured.state; + expect(ensured.tabId).toBe("terminal_term-1"); + expect(state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBeUndefined(); + + const focused = applyOpenOrFocusTab(state, { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + target: { kind: "terminal", terminalId: "term-1" }, + now: NOW, + }); + expect(focused.tabId).toBe("terminal_term-1"); + expect(focused.state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe("terminal_term-1"); + }); + + it("ensureTab deduplicates by target when a retargeted tab already exists", () => { + const draftTabId = "draft_x"; + + let state = emptyState(); + state = applyOpenDraftTab(state, { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + draftId: draftTabId, + now: NOW, + }).state; + state = applyRetargetTab(state, { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + tabId: draftTabId, + target: { kind: "agent", agentId: "created-agent" }, + }).state; + + const ensured = applyEnsureTab(state, { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + target: { kind: "agent", agentId: "created-agent" }, + now: NOW, + }); + + const tabs = ensured.state.uiTabsByWorkspace[WORKSPACE_KEY] ?? []; + const order = ensured.state.tabOrderByWorkspace[WORKSPACE_KEY] ?? []; + const matchingTabs = tabs.filter( + (tab) => tab.target.kind === "agent" && tab.target.agentId === "created-agent", + ); + + expect(ensured.tabId).toBe(draftTabId); + expect(matchingTabs).toHaveLength(1); + expect(order).toEqual([draftTabId]); + }); + + it("openDraftTab creates a draft tab and deduplicates by draftId", () => { + let state = emptyState(); + const first = applyOpenDraftTab(state, { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + draftId: "draft-1", + now: NOW, + }); + state = first.state; + const second = applyOpenDraftTab(state, { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + draftId: "draft-2", + now: NOW, + }); + state = second.state; + + expect(first.tabId).toBe("draft-1"); + expect(second.tabId).toBe("draft-2"); + expect(state.tabOrderByWorkspace[WORKSPACE_KEY]).toEqual([first.tabId, second.tabId]); + expect(state.uiTabsByWorkspace[WORKSPACE_KEY]).toEqual([ + { + tabId: "draft-1", + target: { kind: "draft", draftId: "draft-1" }, + createdAt: NOW, + }, + { + tabId: "draft-2", + target: { kind: "draft", draftId: "draft-2" }, + createdAt: NOW, + }, + ]); + }); + + it("keeps draft setup on a retargeted tab", () => { + let state = emptyState(); + const ensured = applyEnsureTab(state, { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + target: { kind: "agent", agentId: "agent-1" }, + now: NOW, + }); + expect(ensured.tabId).toBe("agent_agent-1"); + state = ensured.state; + + const retargeted = applyRetargetTab(state, { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + tabId: ensured.tabId!, + target: { + kind: "draft", + draftId: "draft-replacement", + setup: { + provider: "mock", + cwd: "/repo/worktree", + modeId: "load-test", + model: "ten-second-stream", + thinkingOptionId: null, + featureValues: { effort: "high" }, + }, + }, + }); + + expect(retargeted.state.uiTabsByWorkspace[WORKSPACE_KEY]?.[0]?.target).toEqual({ + kind: "draft", + draftId: "draft-replacement", + setup: { + provider: "mock", + cwd: "/repo/worktree", + modeId: "load-test", + model: "ten-second-stream", + thinkingOptionId: null, + featureValues: { effort: "high" }, + }, + }); + }); + + it("updates an existing draft tab when the setup changes", () => { + let state = emptyState(); + const first = applyEnsureTab(state, { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + target: { kind: "draft", draftId: "draft-1" }, + now: NOW, + }); + state = first.state; + const second = applyEnsureTab(state, { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + target: { + kind: "draft", + draftId: "draft-1", + setup: { + provider: "mock", + cwd: "/repo/worktree", + modeId: "load-test", + model: "ten-second-stream", + thinkingOptionId: null, + featureValues: {}, + }, + }, + now: NOW, + }); + state = second.state; + + expect(second.tabId).toBe(first.tabId); + expect(state.uiTabsByWorkspace[WORKSPACE_KEY]).toHaveLength(1); + expect(state.uiTabsByWorkspace[WORKSPACE_KEY]?.[0]?.target).toEqual({ + kind: "draft", + draftId: "draft-1", + setup: { + provider: "mock", + cwd: "/repo/worktree", + modeId: "load-test", + model: "ten-second-stream", + thinkingOptionId: null, + featureValues: {}, + }, + }); + }); + + it("retargeting a background draft keeps the currently focused tab focused", () => { + const draftTabId = "draft_background"; + + let state = emptyState(); + state = applyOpenDraftTab(state, { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + draftId: draftTabId, + now: NOW, + }).state; + const file = applyOpenOrFocusTab(state, { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + target: { kind: "file", path: "/repo/worktree/src/index.ts" }, + now: NOW, + }); + state = file.state; + + state = applyRetargetTab(state, { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + tabId: draftTabId, + target: { kind: "agent", agentId: "created-agent" }, + }).state; + + expect(state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe(file.tabId); + }); + + it("openOrFocusTab re-focuses an existing file tab after the workspace focus changed", () => { + let state = emptyState(); + const fileResult = applyOpenOrFocusTab(state, { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + target: { kind: "file", path: "/repo/worktree/src/index.ts" }, + now: NOW, + }); + state = fileResult.state; + const terminalResult = applyOpenOrFocusTab(state, { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + target: { kind: "terminal", terminalId: "term-1" }, + now: NOW, + }); + state = terminalResult.state; + + expect(fileResult.tabId).toBe("file_/repo/worktree/src/index.ts"); + expect(terminalResult.tabId).toBe("terminal_term-1"); + expect(state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe(terminalResult.tabId); + + const reopened = applyOpenOrFocusTab(state, { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + target: { kind: "file", path: "/repo/worktree/src/index.ts" }, + now: NOW, + }); + + expect(reopened.tabId).toBe(fileResult.tabId); + expect(reopened.state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe(fileResult.tabId); + }); + + it("builds a deterministic setup tab keyed by workspace id", () => { + const result = applyOpenOrFocusTab(initialWorkspaceTabsCoreState, { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + target: { kind: "setup", workspaceId: WORKSPACE_ID }, + now: NOW, + }); + + expect(result.tabId).toBe(`setup_${WORKSPACE_ID}`); + expect(result.state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe(result.tabId); + }); + + it("closeTab focuses the most-recent remaining tab when the focused tab is removed", () => { + let state = emptyState(); + const first = applyOpenOrFocusTab(state, { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + target: { kind: "agent", agentId: "left" }, + now: NOW, + }); + state = first.state; + const second = applyOpenOrFocusTab(state, { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + target: { kind: "agent", agentId: "right" }, + now: NOW, + }); + state = second.state; + expect(state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe(second.tabId); + + state = applyCloseTab(state, { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + tabId: second.tabId!, + }); + + expect(state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe(first.tabId); + expect(state.uiTabsByWorkspace[WORKSPACE_KEY]).toHaveLength(1); + }); +}); diff --git a/packages/app/src/stores/workspace-tabs-store/state.ts b/packages/app/src/stores/workspace-tabs-store/state.ts new file mode 100644 index 000000000..409e17150 --- /dev/null +++ b/packages/app/src/stores/workspace-tabs-store/state.ts @@ -0,0 +1,757 @@ +import type { AgentProvider } from "@getpaseo/protocol/agent-types"; +import { + buildDeterministicWorkspaceTabId, + normalizeWorkspaceDraftTabSetup, + normalizeWorkspaceTabTarget, + workspaceTabTargetsEqual, +} from "@/workspace-tabs/identity"; +import type { WorkspaceFileTabTarget } from "@/workspace/file-open"; + +export interface WorkspaceDraftTabSetup { + provider: AgentProvider; + cwd: string; + modeId: string | null; + model: string | null; + thinkingOptionId: string | null; + featureValues: Record; +} + +export type WorkspaceTabTarget = + | { kind: "draft"; draftId: string; setup?: WorkspaceDraftTabSetup } + | { kind: "agent"; agentId: string } + | { kind: "terminal"; terminalId: string } + | { kind: "browser"; browserId: string } + | WorkspaceFileTabTarget + | { kind: "setup"; workspaceId: string }; + +export interface WorkspaceTab { + tabId: string; + target: WorkspaceTabTarget; + createdAt: number; +} + +export interface WorkspaceTabsCoreState { + uiTabsByWorkspace: Record; + tabOrderByWorkspace: Record; + focusedTabIdByWorkspace: Record; +} + +export const initialWorkspaceTabsCoreState: WorkspaceTabsCoreState = { + uiTabsByWorkspace: {}, + tabOrderByWorkspace: {}, + focusedTabIdByWorkspace: {}, +}; + +function trimNonEmpty(value: string | null | undefined): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +export function buildWorkspaceTabPersistenceKey(input: { + serverId: string; + workspaceId: string; +}): string | null { + const serverId = trimNonEmpty(input.serverId); + const workspaceId = trimNonEmpty(input.workspaceId); + if (!serverId || !workspaceId) { + return null; + } + return `${serverId}:${workspaceId}`; +} + +function isPlainRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function toObjectRecord(value: unknown): Record | undefined { + return isPlainRecord(value) ? value : undefined; +} + +function normalizeTabOrder(list: unknown): string[] { + if (!Array.isArray(list)) { + return []; + } + const next: string[] = []; + const used = new Set(); + for (const value of list) { + const tabId = trimNonEmpty(typeof value === "string" ? value : null); + if (!tabId || used.has(tabId)) { + continue; + } + used.add(tabId); + next.push(tabId); + } + return next; +} + +function ensureInOrder(input: { current: string[]; tabId: string }): string[] { + if (input.current.includes(input.tabId)) { + return input.current; + } + return [...input.current, input.tabId]; +} + +function retargetTabAtIndex( + tab: WorkspaceTab, + index: number, + targetIndex: number, + normalizedTarget: WorkspaceTabTarget, +): WorkspaceTab { + return index === targetIndex ? { ...tab, target: normalizedTarget } : tab; +} + +function buildNextTabsForEnsure(args: { + currentTabs: WorkspaceTab[]; + existingIndex: number; + effectiveTabId: string; + normalizedTarget: WorkspaceTabTarget; + createdAt: number; +}): WorkspaceTab[] { + const { currentTabs, existingIndex, effectiveTabId, normalizedTarget, createdAt } = args; + if (existingIndex < 0) { + return [...currentTabs, { tabId: effectiveTabId, target: normalizedTarget, createdAt }]; + } + const existing = currentTabs[existingIndex]; + if (existing && workspaceTabTargetsEqual(existing.target, normalizedTarget)) { + return currentTabs; + } + return currentTabs.map((tab, index) => + retargetTabAtIndex(tab, index, existingIndex, normalizedTarget), + ); +} + +export interface EnsureTabInput { + serverId: string; + workspaceId: string; + target: WorkspaceTabTarget; + now: number; +} + +export interface EnsureTabResult { + state: WorkspaceTabsCoreState; + tabId: string | null; +} + +export function applyEnsureTab( + state: WorkspaceTabsCoreState, + input: EnsureTabInput, +): EnsureTabResult { + const key = buildWorkspaceTabPersistenceKey({ + serverId: input.serverId, + workspaceId: input.workspaceId, + }); + const normalizedTarget = normalizeWorkspaceTabTarget(input.target); + if (!key || !normalizedTarget) { + return { state, tabId: null }; + } + + const deterministicTabId = buildDeterministicWorkspaceTabId(normalizedTarget); + const currentTabs = state.uiTabsByWorkspace[key] ?? []; + const tabWithSameTarget = + currentTabs.find((tab) => workspaceTabTargetsEqual(tab.target, normalizedTarget)) ?? null; + const effectiveTabId = tabWithSameTarget?.tabId ?? deterministicTabId; + + const currentOrder = state.tabOrderByWorkspace[key] ?? []; + const nextOrder = ensureInOrder({ current: currentOrder, tabId: effectiveTabId }); + const existingIndex = currentTabs.findIndex((tab) => tab.tabId === effectiveTabId); + const nextTabs = buildNextTabsForEnsure({ + currentTabs, + existingIndex, + effectiveTabId, + normalizedTarget, + createdAt: input.now, + }); + + const uiTabsByWorkspace = + nextTabs === currentTabs + ? state.uiTabsByWorkspace + : { ...state.uiTabsByWorkspace, [key]: nextTabs }; + const tabOrderByWorkspace = + nextOrder === currentOrder + ? state.tabOrderByWorkspace + : { ...state.tabOrderByWorkspace, [key]: nextOrder }; + + if ( + uiTabsByWorkspace === state.uiTabsByWorkspace && + tabOrderByWorkspace === state.tabOrderByWorkspace + ) { + return { state, tabId: effectiveTabId }; + } + + return { + state: { ...state, uiTabsByWorkspace, tabOrderByWorkspace }, + tabId: effectiveTabId, + }; +} + +export interface FocusTabInput { + serverId: string; + workspaceId: string; + tabId: string; +} + +export function applyFocusTab( + state: WorkspaceTabsCoreState, + input: FocusTabInput, +): WorkspaceTabsCoreState { + const key = buildWorkspaceTabPersistenceKey({ + serverId: input.serverId, + workspaceId: input.workspaceId, + }); + const normalizedTabId = trimNonEmpty(input.tabId); + if (!key || !normalizedTabId) { + return state; + } + if (state.focusedTabIdByWorkspace[key] === normalizedTabId) { + return state; + } + return { + ...state, + focusedTabIdByWorkspace: { + ...state.focusedTabIdByWorkspace, + [key]: normalizedTabId, + }, + }; +} + +export function applyOpenOrFocusTab( + state: WorkspaceTabsCoreState, + input: EnsureTabInput, +): EnsureTabResult { + const ensured = applyEnsureTab(state, input); + if (!ensured.tabId) { + return ensured; + } + const focused = applyFocusTab(ensured.state, { + serverId: input.serverId, + workspaceId: input.workspaceId, + tabId: ensured.tabId, + }); + return { state: focused, tabId: ensured.tabId }; +} + +export interface OpenDraftTabInput { + serverId: string; + workspaceId: string; + draftId: string; + now: number; +} + +export function applyOpenDraftTab( + state: WorkspaceTabsCoreState, + input: OpenDraftTabInput, +): EnsureTabResult { + const normalizedDraftId = trimNonEmpty(input.draftId); + if (!normalizedDraftId) { + return { state, tabId: null }; + } + return applyOpenOrFocusTab(state, { + serverId: input.serverId, + workspaceId: input.workspaceId, + target: { kind: "draft", draftId: normalizedDraftId }, + now: input.now, + }); +} + +export interface CloseTabInput { + serverId: string; + workspaceId: string; + tabId: string; +} + +export function applyCloseTab( + state: WorkspaceTabsCoreState, + input: CloseTabInput, +): WorkspaceTabsCoreState { + const key = buildWorkspaceTabPersistenceKey({ + serverId: input.serverId, + workspaceId: input.workspaceId, + }); + const normalizedTabId = trimNonEmpty(input.tabId); + if (!key || !normalizedTabId) { + return state; + } + + const currentTabs = state.uiTabsByWorkspace[key] ?? []; + const nextTabs = currentTabs.filter((tab) => tab.tabId !== normalizedTabId); + const currentOrder = state.tabOrderByWorkspace[key] ?? []; + const nextOrder = currentOrder.filter((value) => value !== normalizedTabId); + + let nextUiTabsByWorkspace: Record; + if (nextTabs.length === 0) { + const { [key]: _removed, ...rest } = state.uiTabsByWorkspace; + nextUiTabsByWorkspace = rest; + } else if (nextTabs.length === currentTabs.length) { + nextUiTabsByWorkspace = state.uiTabsByWorkspace; + } else { + nextUiTabsByWorkspace = { ...state.uiTabsByWorkspace, [key]: nextTabs }; + } + + let nextTabOrderByWorkspace: Record; + if (nextOrder.length === 0) { + const { [key]: _removed, ...rest } = state.tabOrderByWorkspace; + nextTabOrderByWorkspace = rest; + } else if (nextOrder.length === currentOrder.length) { + nextTabOrderByWorkspace = state.tabOrderByWorkspace; + } else { + nextTabOrderByWorkspace = { ...state.tabOrderByWorkspace, [key]: nextOrder }; + } + + const currentFocused = state.focusedTabIdByWorkspace[key] ?? null; + const nextFocused = + currentFocused !== normalizedTabId ? currentFocused : (nextOrder[nextOrder.length - 1] ?? null); + const nextFocusedByWorkspace = (() => { + if (!nextFocused) { + const { [key]: _removed, ...rest } = state.focusedTabIdByWorkspace; + return rest; + } + return { ...state.focusedTabIdByWorkspace, [key]: nextFocused }; + })(); + + const tabsChanged = nextTabs.length !== currentTabs.length; + const orderChanged = nextOrder.length !== currentOrder.length; + const focusChanged = + (state.focusedTabIdByWorkspace[key] ?? null) !== (nextFocusedByWorkspace[key] ?? null); + + if (!tabsChanged && !orderChanged && !focusChanged) { + return state; + } + + return { + uiTabsByWorkspace: nextUiTabsByWorkspace, + tabOrderByWorkspace: nextTabOrderByWorkspace, + focusedTabIdByWorkspace: nextFocusedByWorkspace, + }; +} + +export interface RetargetTabInput { + serverId: string; + workspaceId: string; + tabId: string; + target: WorkspaceTabTarget; +} + +export function applyRetargetTab( + state: WorkspaceTabsCoreState, + input: RetargetTabInput, +): EnsureTabResult { + const key = buildWorkspaceTabPersistenceKey({ + serverId: input.serverId, + workspaceId: input.workspaceId, + }); + const normalizedTabId = trimNonEmpty(input.tabId); + const normalizedTarget = normalizeWorkspaceTabTarget(input.target); + if (!key || !normalizedTabId || !normalizedTarget) { + return { state, tabId: null }; + } + + const currentTabs = state.uiTabsByWorkspace[key] ?? []; + const index = currentTabs.findIndex((tab) => tab.tabId === normalizedTabId); + if (index < 0) { + return { state, tabId: null }; + } + + const currentTarget = currentTabs[index]?.target; + if (currentTarget && workspaceTabTargetsEqual(currentTarget, normalizedTarget)) { + return { state, tabId: null }; + } + + const nextTabs = currentTabs.map((tab, tabIndex) => + tabIndex === index ? Object.assign({}, tab, { target: normalizedTarget }) : tab, + ); + + return { + state: { + ...state, + uiTabsByWorkspace: { ...state.uiTabsByWorkspace, [key]: nextTabs }, + }, + tabId: normalizedTabId, + }; +} + +export interface ReorderTabsInput { + serverId: string; + workspaceId: string; + tabIds: string[]; +} + +export function applyReorderTabs( + state: WorkspaceTabsCoreState, + input: ReorderTabsInput, +): WorkspaceTabsCoreState { + const key = buildWorkspaceTabPersistenceKey({ + serverId: input.serverId, + workspaceId: input.workspaceId, + }); + if (!key) { + return state; + } + + const normalized = normalizeTabOrder(input.tabIds); + const current = state.tabOrderByWorkspace[key] ?? []; + if (current.length === normalized.length) { + let same = true; + for (let i = 0; i < current.length; i += 1) { + if (current[i] !== normalized[i]) { + same = false; + break; + } + } + if (same) { + return state; + } + } + + return { + ...state, + tabOrderByWorkspace: { + ...state.tabOrderByWorkspace, + [key]: normalized, + }, + }; +} + +export interface PurgeWorkspaceInput { + serverId: string; + workspaceId: string; +} + +export function applyPurgeWorkspace( + state: WorkspaceTabsCoreState, + input: PurgeWorkspaceInput, +): WorkspaceTabsCoreState { + const key = buildWorkspaceTabPersistenceKey({ + serverId: input.serverId, + workspaceId: input.workspaceId, + }); + if (!key) { + return state; + } + if ( + !(key in state.uiTabsByWorkspace) && + !(key in state.tabOrderByWorkspace) && + !(key in state.focusedTabIdByWorkspace) + ) { + return state; + } + const { [key]: _tabs, ...remainingUiTabsByWorkspace } = state.uiTabsByWorkspace; + const { [key]: _order, ...remainingTabOrderByWorkspace } = state.tabOrderByWorkspace; + const { [key]: _focused, ...remainingFocusedTabIdByWorkspace } = state.focusedTabIdByWorkspace; + return { + ...state, + uiTabsByWorkspace: remainingUiTabsByWorkspace, + tabOrderByWorkspace: remainingTabOrderByWorkspace, + focusedTabIdByWorkspace: remainingFocusedTabIdByWorkspace, + }; +} + +export function selectWorkspaceTabs( + state: WorkspaceTabsCoreState, + input: { serverId: string; workspaceId: string }, +): WorkspaceTab[] { + const key = buildWorkspaceTabPersistenceKey(input); + if (!key) { + return []; + } + return state.uiTabsByWorkspace[key] ?? []; +} + +interface MigrationRawSources { + rawUiTabsByWorkspace: Record; + rawFocused: Record; + rawOrder: Record; + legacyOrder: Record; +} + +function extractMigrationRawSources(persistedState: unknown): MigrationRawSources { + const top = toObjectRecord(persistedState) ?? {}; + const rawState = toObjectRecord(top.state) ?? top; + + return { + rawUiTabsByWorkspace: + toObjectRecord( + rawState.uiTabsByWorkspace ?? + rawState.openTabsByWorkspace ?? + top.uiTabsByWorkspace ?? + top.openTabsByWorkspace, + ) ?? {}, + rawFocused: + toObjectRecord( + rawState.focusedTabIdByWorkspace ?? + rawState.lastFocusedTabByWorkspace ?? + top.focusedTabIdByWorkspace, + ) ?? {}, + rawOrder: toObjectRecord(rawState.tabOrderByWorkspace ?? top.tabOrderByWorkspace) ?? {}, + legacyOrder: + toObjectRecord( + rawState.tabOrderByWorkspace ?? + rawState.tabOrderLegacyByWorkspace ?? + top.tabOrderLegacyByWorkspace, + ) ?? {}, + }; +} + +function coerceWorkspaceTabTarget(raw: Record): WorkspaceTabTarget | null { + const kind = typeof raw.kind === "string" ? raw.kind : null; + if (kind === "draft" && typeof raw.draftId === "string") { + const setup = normalizeWorkspaceDraftTabSetup(raw.setup); + return normalizeWorkspaceTabTarget({ + kind: "draft", + draftId: raw.draftId, + ...(setup ? { setup } : {}), + }); + } + if (kind === "agent" && typeof raw.agentId === "string") { + return normalizeWorkspaceTabTarget({ kind: "agent", agentId: raw.agentId }); + } + if (kind === "terminal" && typeof raw.terminalId === "string") { + return normalizeWorkspaceTabTarget({ kind: "terminal", terminalId: raw.terminalId }); + } + if (kind === "browser" && typeof raw.browserId === "string") { + return normalizeWorkspaceTabTarget({ kind: "browser", browserId: raw.browserId }); + } + if (kind === "file" && typeof raw.path === "string") { + return normalizeWorkspaceTabTarget({ + kind: "file", + path: raw.path, + lineStart: typeof raw.lineStart === "number" ? raw.lineStart : undefined, + lineEnd: typeof raw.lineEnd === "number" ? raw.lineEnd : undefined, + }); + } + if (kind === "setup" && typeof raw.workspaceId === "string") { + return normalizeWorkspaceTabTarget({ kind: "setup", workspaceId: raw.workspaceId }); + } + return null; +} + +function migrateSingleTab(rawTab: unknown, now: number): WorkspaceTab | null { + const record = toObjectRecord(rawTab); + if (!record) { + return null; + } + const rawTarget = toObjectRecord(record.target); + const normalizedTarget = rawTarget ? coerceWorkspaceTabTarget(rawTarget) : null; + if (!normalizedTarget) { + return null; + } + const rawTabId = trimNonEmpty(typeof record.tabId === "string" ? record.tabId : null); + const tabId = rawTabId ?? buildDeterministicWorkspaceTabId(normalizedTarget); + const rawCreatedAt = record.createdAt; + return { + tabId, + target: normalizedTarget, + createdAt: typeof rawCreatedAt === "number" ? rawCreatedAt : now, + }; +} + +interface MigratedTabsForKey { + nextUiTabs: WorkspaceTab[]; + orderFromTabs: string[]; +} + +function migrateUiTabsForKey(rawEntries: unknown, now: number): MigratedTabsForKey { + const entries = Array.isArray(rawEntries) ? rawEntries : []; + const nextUiTabs: WorkspaceTab[] = []; + const orderFromTabs: string[] = []; + const usedOrder = new Set(); + + for (const rawTab of entries) { + const migrated = migrateSingleTab(rawTab, now); + if (!migrated) { + continue; + } + if (!usedOrder.has(migrated.tabId)) { + usedOrder.add(migrated.tabId); + orderFromTabs.push(migrated.tabId); + } + nextUiTabs.push(migrated); + } + + return { nextUiTabs, orderFromTabs }; +} + +function mergeExplicitTabOrder( + tabOrderByWorkspace: Record, + rawOrder: Record, +): void { + for (const key in rawOrder) { + const normalizedOrder = normalizeTabOrder(rawOrder[key]); + if (normalizedOrder.length === 0) { + continue; + } + const existing = tabOrderByWorkspace[key] ?? []; + tabOrderByWorkspace[key] = normalizeTabOrder([...existing, ...normalizedOrder]); + } +} + +function convertLegacyOrderEntry(entry: unknown): string | null { + const raw = typeof entry === "string" ? entry.trim() : ""; + if (!raw) { + return null; + } + if (raw.startsWith("agent:")) { + const agentId = raw.slice("agent:".length).trim(); + return agentId ? `agent_${agentId}` : null; + } + if (raw.startsWith("terminal:")) { + const terminalId = raw.slice("terminal:".length).trim(); + return terminalId ? `terminal_${terminalId}` : null; + } + return null; +} + +function normalizeLegacyOrderList(list: unknown[]): string[] { + const result: string[] = []; + for (const entry of list) { + const converted = convertLegacyOrderEntry(entry); + if (converted) { + result.push(converted); + } + } + return result; +} + +function mergeLegacyTabOrder( + tabOrderByWorkspace: Record, + legacyOrder: Record, +): void { + for (const key in legacyOrder) { + const list = legacyOrder[key]; + if (!Array.isArray(list) || list.length === 0) { + continue; + } + const normalizedLegacyOrder = normalizeLegacyOrderList(list); + if (normalizedLegacyOrder.length === 0) { + continue; + } + const existing = tabOrderByWorkspace[key] ?? []; + tabOrderByWorkspace[key] = normalizeTabOrder([...existing, ...normalizedLegacyOrder]); + } +} + +function resolveFocusedTabId(rawValue: unknown): string | null { + if (typeof rawValue === "string") { + return trimNonEmpty(rawValue); + } + if (!rawValue || typeof rawValue !== "object") { + return null; + } + const value = rawValue as { + kind?: string; + agentId?: string; + terminalId?: string; + draftId?: string; + }; + if (value.kind === "agent" && typeof value.agentId === "string" && value.agentId.trim()) { + return `agent_${value.agentId.trim()}`; + } + if ( + value.kind === "terminal" && + typeof value.terminalId === "string" && + value.terminalId.trim() + ) { + return `terminal_${value.terminalId.trim()}`; + } + if (value.kind === "draft" && typeof value.draftId === "string" && value.draftId.trim()) { + return value.draftId.trim(); + } + return null; +} + +function migrateFocusedTabIds( + focusedTabIdByWorkspace: Record, + rawFocused: Record, +): void { + for (const key in rawFocused) { + const resolved = resolveFocusedTabId(rawFocused[key]); + if (resolved) { + focusedTabIdByWorkspace[key] = resolved; + } + } +} + +export function migrateWorkspaceTabsState( + persistedState: unknown, + options: { now: number }, +): WorkspaceTabsCoreState { + const { rawUiTabsByWorkspace, rawFocused, rawOrder, legacyOrder } = + extractMigrationRawSources(persistedState); + + const uiTabsByWorkspace: Record = {}; + const tabOrderByWorkspace: Record = {}; + const focusedTabIdByWorkspace: Record = {}; + + for (const key in rawUiTabsByWorkspace) { + const { nextUiTabs, orderFromTabs } = migrateUiTabsForKey( + rawUiTabsByWorkspace[key], + options.now, + ); + if (nextUiTabs.length > 0) { + uiTabsByWorkspace[key] = nextUiTabs; + } + if (orderFromTabs.length > 0) { + tabOrderByWorkspace[key] = orderFromTabs; + } + } + + mergeExplicitTabOrder(tabOrderByWorkspace, rawOrder); + mergeLegacyTabOrder(tabOrderByWorkspace, legacyOrder); + migrateFocusedTabIds(focusedTabIdByWorkspace, rawFocused); + + return { + uiTabsByWorkspace, + tabOrderByWorkspace, + focusedTabIdByWorkspace, + }; +} + +export function partializeWorkspaceTabsState( + state: WorkspaceTabsCoreState, + options: { now: number }, +): WorkspaceTabsCoreState { + const nextUiTabsByWorkspace: Record = {}; + for (const key in state.uiTabsByWorkspace) { + const tabs = (state.uiTabsByWorkspace[key] ?? []) + .map((tab) => { + const normalizedTarget = normalizeWorkspaceTabTarget(tab.target); + const normalizedTabId = trimNonEmpty(tab.tabId); + if (!normalizedTarget || !normalizedTabId) { + return null; + } + return { + tabId: normalizedTabId, + target: normalizedTarget, + createdAt: typeof tab.createdAt === "number" ? tab.createdAt : options.now, + } satisfies WorkspaceTab; + }) + .filter((tab): tab is WorkspaceTab => tab !== null); + if (tabs.length > 0) { + nextUiTabsByWorkspace[key] = tabs; + } + } + + const nextTabOrderByWorkspace: Record = {}; + for (const key in state.tabOrderByWorkspace) { + const order = normalizeTabOrder(state.tabOrderByWorkspace[key]); + if (order.length > 0) { + nextTabOrderByWorkspace[key] = order; + } + } + + const nextFocusedTabIdByWorkspace: Record = {}; + for (const key in state.focusedTabIdByWorkspace) { + const focusedTabId = trimNonEmpty(state.focusedTabIdByWorkspace[key]); + if (focusedTabId) { + nextFocusedTabIdByWorkspace[key] = focusedTabId; + } + } + + return { + uiTabsByWorkspace: nextUiTabsByWorkspace, + tabOrderByWorkspace: nextTabOrderByWorkspace, + focusedTabIdByWorkspace: nextFocusedTabIdByWorkspace, + }; +} diff --git a/packages/app/src/subagents/archive-subagent.test.ts b/packages/app/src/subagents/archive-subagent.test.ts new file mode 100644 index 000000000..68280d4ec --- /dev/null +++ b/packages/app/src/subagents/archive-subagent.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it } from "vitest"; +import type { ConfirmDialogInput } from "@/utils/confirm-dialog"; +import { + requestArchiveSubagent, + resolveArchiveSubagentDialog, + type ArchiveSubagentDeps, + type ResolveArchiveSubagentDialogInput, +} from "./archive-subagent"; + +interface RecordedArchive { + serverId: string; + agentId: string; +} + +interface FakeArchiveSubagentEnv { + deps: ArchiveSubagentDeps; + recordedArchives: RecordedArchive[]; + recordedConfirmInputs: ConfirmDialogInput[]; + setSubagent(id: string, snapshot: ResolveArchiveSubagentDialogInput | undefined): void; +} + +function createFakeEnv( + options: { + confirmResult?: boolean; + initialSubagents?: Array<{ id: string; snapshot: ResolveArchiveSubagentDialogInput }>; + } = {}, +): FakeArchiveSubagentEnv { + const subagents = new Map(); + for (const entry of options.initialSubagents ?? []) { + subagents.set(entry.id, entry.snapshot); + } + const recordedArchives: RecordedArchive[] = []; + const recordedConfirmInputs: ConfirmDialogInput[] = []; + + return { + recordedArchives, + recordedConfirmInputs, + setSubagent(id, snapshot) { + subagents.set(id, snapshot); + }, + deps: { + getSubagent: (id) => subagents.get(id), + confirm: async (dialog) => { + recordedConfirmInputs.push(dialog); + return options.confirmResult ?? false; + }, + archiveAgent: async (input) => { + recordedArchives.push(input); + }, + }, + }; +} + +describe("resolveArchiveSubagentDialog", () => { + it("uses running copy for running subagents", () => { + expect( + resolveArchiveSubagentDialog({ + title: "Review branch", + status: "running", + }), + ).toEqual({ + title: "Archive running subagent?", + message: + "Review branch is still running. Archiving it will stop the subagent and remove it from the track.", + confirmLabel: "Archive", + cancelLabel: "Cancel", + destructive: true, + }); + }); + + it("uses running copy for initializing subagents", () => { + expect( + resolveArchiveSubagentDialog({ + title: "Starting child", + status: "initializing", + }), + ).toEqual({ + title: "Archive running subagent?", + message: + "Starting child is still running. Archiving it will stop the subagent and remove it from the track.", + confirmLabel: "Archive", + cancelLabel: "Cancel", + destructive: true, + }); + }); + + it("uses idle copy for non-running subagents", () => { + expect( + resolveArchiveSubagentDialog({ + title: "Review branch", + status: "idle", + }), + ).toEqual({ + title: "Archive subagent?", + message: "Remove Review branch from the track. The subagent will be archived.", + confirmLabel: "Archive", + cancelLabel: "Cancel", + destructive: true, + }); + }); + + it("falls back to this subagent when the title is not displayable", () => { + expect( + resolveArchiveSubagentDialog({ + title: "New Agent", + status: null, + }), + ).toEqual({ + title: "Archive subagent?", + message: "Remove this subagent from the track. The subagent will be archived.", + confirmLabel: "Archive", + cancelLabel: "Cancel", + destructive: true, + }); + }); +}); + +describe("requestArchiveSubagent", () => { + it("archives the subagent with the server id when the user confirms", async () => { + const env = createFakeEnv({ + confirmResult: true, + initialSubagents: [ + { + id: "child-agent", + snapshot: { title: "Review branch", status: "running" }, + }, + ], + }); + + await requestArchiveSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps); + + expect(env.recordedArchives).toEqual([{ serverId: "server-1", agentId: "child-agent" }]); + }); + + it("does not archive the subagent when the user cancels", async () => { + const env = createFakeEnv({ + confirmResult: false, + initialSubagents: [ + { + id: "child-agent", + snapshot: { title: "Review branch", status: "idle" }, + }, + ], + }); + + await requestArchiveSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps); + + expect(env.recordedArchives).toEqual([]); + }); + + it("asks for confirmation using the resolved dialog for the subagent", async () => { + const env = createFakeEnv({ + confirmResult: false, + initialSubagents: [ + { + id: "child-agent", + snapshot: { title: "Review branch", status: "running" }, + }, + ], + }); + + await requestArchiveSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps); + + expect(env.recordedConfirmInputs).toEqual([ + resolveArchiveSubagentDialog({ title: "Review branch", status: "running" }), + ]); + }); + + it("asks with the missing-subagent dialog when the snapshot is unknown", async () => { + const env = createFakeEnv({ confirmResult: false }); + + await requestArchiveSubagent({ serverId: "server-1", subagentId: "missing" }, env.deps); + + expect(env.recordedConfirmInputs).toEqual([ + resolveArchiveSubagentDialog({ title: undefined, status: undefined }), + ]); + expect(env.recordedArchives).toEqual([]); + }); + + it("swallows archive errors so the caller never sees them", async () => { + const env = createFakeEnv({ + confirmResult: true, + initialSubagents: [ + { + id: "child-agent", + snapshot: { title: "Review branch", status: "running" }, + }, + ], + }); + env.deps.archiveAgent = async () => { + throw new Error("daemon offline"); + }; + + await expect( + requestArchiveSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps), + ).resolves.toBeUndefined(); + }); +}); diff --git a/packages/app/src/subagents/archive-subagent.ts b/packages/app/src/subagents/archive-subagent.ts new file mode 100644 index 000000000..26a431199 --- /dev/null +++ b/packages/app/src/subagents/archive-subagent.ts @@ -0,0 +1,66 @@ +import type { Agent } from "@/stores/session-store"; +import type { ConfirmDialogInput } from "@/utils/confirm-dialog"; + +export interface ResolveArchiveSubagentDialogInput { + title: Agent["title"] | null | undefined; + status: Agent["status"] | null | undefined; +} + +function resolveSubagentLabel(title: Agent["title"] | null | undefined): string | null { + if (typeof title !== "string") { + return null; + } + const normalized = title.trim(); + if (!normalized) { + return null; + } + if (normalized.toLowerCase() === "new agent") { + return null; + } + return normalized; +} + +export function resolveArchiveSubagentDialog( + input: ResolveArchiveSubagentDialogInput, +): ConfirmDialogInput { + const subagentLabel = resolveSubagentLabel(input.title) ?? "this subagent"; + const isRunning = input.status === "running" || input.status === "initializing"; + + return { + title: isRunning ? "Archive running subagent?" : "Archive subagent?", + message: isRunning + ? `${subagentLabel} is still running. Archiving it will stop the subagent and remove it from the track.` + : `Remove ${subagentLabel} from the track. The subagent will be archived.`, + confirmLabel: "Archive", + cancelLabel: "Cancel", + destructive: true, + }; +} + +export interface ArchiveSubagentDeps { + getSubagent: (subagentId: string) => ResolveArchiveSubagentDialogInput | undefined; + confirm: (input: ConfirmDialogInput) => Promise; + archiveAgent: (input: { serverId: string; agentId: string }) => Promise; +} + +export interface RequestArchiveSubagentInput { + serverId: string; + subagentId: string; +} + +export async function requestArchiveSubagent( + input: RequestArchiveSubagentInput, + deps: ArchiveSubagentDeps, +): Promise { + const subagent = deps.getSubagent(input.subagentId); + const confirmed = await deps.confirm( + resolveArchiveSubagentDialog({ + title: subagent?.title, + status: subagent?.status, + }), + ); + if (!confirmed) { + return; + } + void deps.archiveAgent({ serverId: input.serverId, agentId: input.subagentId }).catch(() => {}); +} diff --git a/packages/app/src/subagents/index.ts b/packages/app/src/subagents/index.ts index c368f0b0d..0a0be0a9e 100644 --- a/packages/app/src/subagents/index.ts +++ b/packages/app/src/subagents/index.ts @@ -1,5 +1,3 @@ -export { SubagentsTrack } from "./track"; -export type { SubagentsTrackProps } from "./track"; export type { SubagentRow } from "./select"; export { selectSubagentsForParent, useSubagentsForParent } from "./select"; export { useArchiveSubagent, type UseArchiveSubagentInput } from "./use-archive-subagent"; diff --git a/packages/app/src/subagents/track-presentation.test.ts b/packages/app/src/subagents/track-presentation.test.ts new file mode 100644 index 000000000..82170690e --- /dev/null +++ b/packages/app/src/subagents/track-presentation.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from "vitest"; +import type { SubagentRow } from "./select"; +import { + buildSubagentRowPresentationData, + formatHeaderLabel, + resolveRowLabel, +} from "./track-presentation"; + +function row(overrides: Partial & Pick): SubagentRow { + return { + id: overrides.id, + provider: overrides.provider ?? "codex", + title: overrides.title ?? `Agent ${overrides.id}`, + status: overrides.status ?? "idle", + requiresAttention: overrides.requiresAttention ?? false, + createdAt: overrides.createdAt ?? new Date("2026-04-20T00:00:00.000Z"), + }; +} + +describe("formatHeaderLabel", () => { + it("uses singular 'subagent' for a single row", () => { + expect(formatHeaderLabel([row({ id: "a" })])).toBe("1 subagent"); + }); + + it("uses plural 'subagents' for two rows with no running rows", () => { + expect(formatHeaderLabel([row({ id: "a" }), row({ id: "b" })])).toBe("2 subagents"); + }); + + it("appends the running count when at least one row is running", () => { + expect( + formatHeaderLabel([row({ id: "a", status: "running" }), row({ id: "b" }), row({ id: "c" })]), + ).toBe("3 subagents · 1 running"); + }); + + it("counts every running row in the suffix", () => { + expect( + formatHeaderLabel([ + row({ id: "a", status: "running" }), + row({ id: "b", status: "running" }), + row({ id: "c", requiresAttention: true }), + row({ id: "d" }), + row({ id: "e" }), + ]), + ).toBe("5 subagents · 2 running"); + }); + + it("ignores requiresAttention on non-running rows in the header copy", () => { + expect( + formatHeaderLabel([ + row({ id: "a", status: "error", requiresAttention: false }), + row({ id: "b", status: "idle", requiresAttention: false }), + row({ id: "c", status: "idle", requiresAttention: true }), + ]), + ).toBe("3 subagents"); + }); + + it("still counts running rows even when they require attention", () => { + expect( + formatHeaderLabel([ + row({ id: "a", status: "error", requiresAttention: true }), + row({ id: "b", status: "running", requiresAttention: true }), + row({ id: "c", status: "idle", requiresAttention: true }), + ]), + ).toBe("3 subagents · 1 running"); + }); + + it("uses singular 'subagent' for a single row that requires attention upstream", () => { + expect(formatHeaderLabel([row({ id: "a", requiresAttention: true })])).toBe("1 subagent"); + }); +}); + +describe("resolveRowLabel", () => { + it("returns null when title is not a string", () => { + expect(resolveRowLabel(null as unknown as SubagentRow["title"])).toBe(null); + }); + + it("returns null for whitespace-only titles", () => { + expect(resolveRowLabel(" ")).toBe(null); + }); + + it("returns null for the placeholder 'new agent' regardless of case", () => { + expect(resolveRowLabel("new agent")).toBe(null); + expect(resolveRowLabel("New Agent")).toBe(null); + expect(resolveRowLabel(" NEW AGENT ")).toBe(null); + }); + + it("returns the trimmed title for real names", () => { + expect(resolveRowLabel(" Build the thing ")).toBe("Build the thing"); + }); +}); + +describe("buildSubagentRowPresentationData", () => { + it("namespaces the key with a subagent prefix", () => { + expect(buildSubagentRowPresentationData(row({ id: "child-a" })).key).toBe("subagent_child-a"); + }); + + it("marks the row ready when the title resolves to a real label", () => { + const presentation = buildSubagentRowPresentationData(row({ id: "a", title: "Build it" })); + expect(presentation.titleState).toBe("ready"); + expect(presentation.label).toBe("Build it"); + }); + + it("marks the row loading and blanks the label for the placeholder title", () => { + const presentation = buildSubagentRowPresentationData(row({ id: "a", title: "new agent" })); + expect(presentation.titleState).toBe("loading"); + expect(presentation.label).toBe(""); + }); + + it("maps a running row to the running status bucket so callers render the synced loader", () => { + expect(buildSubagentRowPresentationData(row({ id: "a", status: "running" })).statusBucket).toBe( + "running", + ); + }); + + it("maps an idle row to the done status bucket so callers render the static provider icon", () => { + expect(buildSubagentRowPresentationData(row({ id: "a", status: "idle" })).statusBucket).toBe( + "done", + ); + }); + + it("ignores requiresAttention on the source row when computing the bucket", () => { + expect( + buildSubagentRowPresentationData(row({ id: "a", status: "idle", requiresAttention: true })) + .statusBucket, + ).toBe("done"); + }); +}); diff --git a/packages/app/src/subagents/track-presentation.ts b/packages/app/src/subagents/track-presentation.ts new file mode 100644 index 000000000..d9c626313 --- /dev/null +++ b/packages/app/src/subagents/track-presentation.ts @@ -0,0 +1,56 @@ +import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; +import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state"; +import type { SubagentRow } from "./select"; + +export interface SubagentRowPresentationData { + key: string; + kind: "agent"; + label: string; + subtitle: string; + titleState: "ready" | "loading"; + statusBucket: SidebarStateBucket | null; +} + +export function buildSubagentRowPresentationData(row: SubagentRow): SubagentRowPresentationData { + const label = resolveRowLabel(row.title); + return { + key: `subagent_${row.id}`, + kind: "agent", + label: label ?? "", + subtitle: "", + titleState: label ? "ready" : "loading", + statusBucket: deriveSidebarStateBucket({ + status: row.status, + requiresAttention: false, + }), + }; +} + +export function formatHeaderLabel(rows: readonly SubagentRow[]): string { + let runningCount = 0; + for (const row of rows) { + if (row.status === "running") { + runningCount += 1; + } + } + + const parts = [`${rows.length} ${rows.length === 1 ? "subagent" : "subagents"}`]; + if (runningCount > 0) { + parts.push(`${runningCount} running`); + } + return parts.join(" · "); +} + +export function resolveRowLabel(title: SubagentRow["title"]): string | null { + if (typeof title !== "string") { + return null; + } + const normalized = title.trim(); + if (!normalized) { + return null; + } + if (normalized.toLowerCase() === "new agent") { + return null; + } + return normalized; +} diff --git a/packages/app/src/subagents/track.test.tsx b/packages/app/src/subagents/track.test.tsx deleted file mode 100644 index a9ed48957..000000000 --- a/packages/app/src/subagents/track.test.tsx +++ /dev/null @@ -1,285 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import React from "react"; -import { act } from "@testing-library/react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { SubagentsTrack } from "./track"; -import type { SubagentRow } from "./select"; - -const { theme } = vi.hoisted(() => ({ - theme: { - colorScheme: "dark", - spacing: { 0: 0, 1: 4, 2: 8, 3: 12, 4: 16 }, - borderWidth: { 1: 1 }, - borderRadius: { sm: 4, md: 6, lg: 8, "2xl": 16, full: 999 }, - fontSize: { xs: 11, sm: 13, base: 15 }, - fontWeight: { normal: "400", medium: "500" }, - iconSize: { sm: 14, md: 18 }, - colors: { - foreground: "#fff", - foregroundMuted: "#aaa", - surface0: "#000", - surface1: "#111", - surface2: "#222", - surface3: "#333", - border: "#444", - borderAccent: "#555", - accent: "#0a84ff", - palette: { - amber: { 500: "#ffbf00", 700: "#aa8000" }, - blue: { 500: "#0a84ff" }, - red: { 500: "#ff453a" }, - green: { 500: "#30d158" }, - }, - }, - }, -})); - -vi.hoisted(() => { - (globalThis as unknown as { __DEV__: boolean }).__DEV__ = false; -}); - -vi.mock("react-native-unistyles", () => ({ - StyleSheet: { - create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory), - }, - useUnistyles: () => ({ theme, rt: { breakpoint: "lg" } }), - withUnistyles: (component: T) => component, -})); - -vi.mock("@/panels/register-panels", () => ({ - ensurePanelsRegistered: () => {}, -})); - -vi.mock("@/components/ui/tooltip", () => ({ - Tooltip: ({ children }: { children: React.ReactNode }) => children, - TooltipTrigger: ({ children }: { children: React.ReactNode }) => children, - TooltipContent: () => null, -})); - -vi.mock("react-native-reanimated", () => ({ - default: { - View: "div", - }, - runOnJS: (fn: (...args: unknown[]) => unknown) => fn, - useSharedValue: (value: unknown) => ({ value }), - useAnimatedStyle: (factory: () => unknown) => factory(), - withTiming: (value: unknown) => value, - withRepeat: (value: unknown) => value, - cancelAnimation: () => {}, - Easing: { - inOut: () => () => 0, - linear: () => 0, - ease: () => 0, - bezier: () => () => 0, - }, - ReduceMotion: { System: "system", Never: "never", Always: "always" }, -})); - -vi.mock("@/components/provider-icons", () => ({ - getProviderIcon: (provider: unknown) => - function ProviderIconStub(props: { size: number; color: string }) { - return React.createElement("span", { - "data-testid": "subagents-provider-icon", - "data-provider": String(provider ?? "none"), - "data-size": String(props.size), - }); - }, -})); - -vi.mock("@/components/synced-loader", () => ({ - SyncedLoader: (props: { size: number; color: string }) => - React.createElement("span", { - "data-testid": "subagents-synced-loader", - "data-size": String(props.size), - }), -})); - -vi.mock("lucide-react-native", () => { - const createIcon = (name: string) => - function IconStub(props: Record) { - return React.createElement("span", { ...props, "data-icon": name }); - }; - return { - Archive: createIcon("Archive"), - Check: createIcon("Check"), - ChevronDown: createIcon("ChevronDown"), - ChevronRight: createIcon("ChevronRight"), - }; -}); - -function row(overrides: Partial & Pick): SubagentRow { - return { - id: overrides.id, - provider: overrides.provider ?? "codex", - title: overrides.title ?? `Agent ${overrides.id}`, - status: overrides.status ?? "idle", - requiresAttention: overrides.requiresAttention ?? false, - createdAt: overrides.createdAt ?? new Date("2026-04-20T00:00:00.000Z"), - }; -} - -function click(element: Element): void { - act(() => { - element.dispatchEvent(new window.MouseEvent("click", { bubbles: true })); - }); -} - -function queryByTestId(testID: string): HTMLElement | null { - return document.querySelector(`[data-testid="${testID}"]`); -} - -function queryRowIds(): string[] { - return Array.from( - document.querySelectorAll('[data-testid^="subagents-track-row-"]'), - ).map((node) => node.getAttribute("data-testid")?.replace("subagents-track-row-", "") ?? ""); -} - -type SubagentAction = (id: string) => void; - -describe("SubagentsTrack", () => { - let container: HTMLElement | null = null; - let root: Root | null = null; - - beforeEach(() => { - vi.stubGlobal("React", React); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - vi.stubGlobal( - "ResizeObserver", - class ResizeObserver { - observe() {} - unobserve() {} - disconnect() {} - }, - ); - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - }); - - afterEach(() => { - if (root) { - act(() => { - root?.unmount(); - }); - } - root = null; - container?.remove(); - container = null; - vi.unstubAllGlobals(); - }); - - function render( - rows: SubagentRow[], - onOpenSubagent: SubagentAction = vi.fn(), - onArchiveSubagent: SubagentAction = vi.fn(), - ): SubagentAction { - act(() => { - root?.render( - , - ); - }); - return onOpenSubagent; - } - - it("renders nothing when rows is empty", () => { - render([]); - expect(queryByTestId("subagents-track")).toBeNull(); - expect(queryByTestId("subagents-track-header")).toBeNull(); - expect(queryRowIds()).toEqual([]); - }); - - it("shows only the collapsed header and no rows initially", () => { - render([row({ id: "child-a" }), row({ id: "child-b" })]); - expect(queryByTestId("subagents-track-header")).not.toBeNull(); - expect(queryRowIds()).toEqual([]); - }); - - it("expands rows in the given order when the header is pressed", () => { - render([row({ id: "child-b" }), row({ id: "child-a" }), row({ id: "child-c" })]); - click(queryByTestId("subagents-track-header")!); - expect(queryRowIds()).toEqual(["child-b", "child-a", "child-c"]); - }); - - it("calls onOpenSubagent with the row id when a row is pressed", () => { - const onOpenSubagent = render([row({ id: "child-a" }), row({ id: "child-b" })]); - click(queryByTestId("subagents-track-header")!); - click(queryByTestId("subagents-track-row-child-b")!); - expect(onOpenSubagent).toHaveBeenCalledTimes(1); - expect(onOpenSubagent).toHaveBeenCalledWith("child-b"); - }); - - describe("header copy", () => { - it("renders '2 subagents' when two rows are not running", () => { - render([row({ id: "child-a" }), row({ id: "child-b" })]); - expect(queryByTestId("subagents-track-header")?.textContent).toBe("2 subagents"); - }); - - it("renders '3 subagents · 1 running' with a single running row", () => { - render([ - row({ id: "child-a", status: "running" }), - row({ id: "child-b" }), - row({ id: "child-c" }), - ]); - expect(queryByTestId("subagents-track-header")?.textContent).toBe("3 subagents · 1 running"); - }); - - it("renders '1 subagent' for a finished row that still requires attention upstream", () => { - render([row({ id: "child-a", requiresAttention: true })]); - expect(queryByTestId("subagents-track-header")?.textContent).toBe("1 subagent"); - }); - - it("renders '5 subagents · 2 running' when finished rows require attention upstream", () => { - render([ - row({ id: "a", status: "running" }), - row({ id: "b", status: "running" }), - row({ id: "c", requiresAttention: true }), - row({ id: "d" }), - row({ id: "e" }), - ]); - expect(queryByTestId("subagents-track-header")?.textContent).toBe("5 subagents · 2 running"); - }); - }); - - it("ignores requiresAttention for non-running header copy", () => { - render([ - row({ id: "a", status: "error", requiresAttention: false }), - row({ id: "b", status: "idle", requiresAttention: false }), - row({ id: "c", status: "idle", requiresAttention: true }), - ]); - expect(queryByTestId("subagents-track-header")?.textContent).toBe("3 subagents"); - }); - - it("still counts running rows even when they require attention", () => { - render([ - row({ id: "a", status: "error", requiresAttention: true }), - row({ id: "b", status: "running", requiresAttention: true }), - row({ id: "c", status: "idle", requiresAttention: true }), - ]); - expect(queryByTestId("subagents-track-header")?.textContent).toBe("3 subagents · 1 running"); - }); - - it("renders each row through the shared workspace tab icon primitives", () => { - render([ - row({ id: "idle-child", status: "idle", provider: "codex" }), - row({ id: "running-child", status: "running", provider: "claude-code" }), - ]); - click(queryByTestId("subagents-track-header")!); - - const idleRow = queryByTestId("subagents-track-row-idle-child"); - expect(idleRow).not.toBeNull(); - expect(idleRow!.querySelectorAll('[data-testid="subagents-provider-icon"]').length).toBe(1); - expect(idleRow!.querySelectorAll('[data-testid="subagents-synced-loader"]').length).toBe(0); - - const runningRow = queryByTestId("subagents-track-row-running-child"); - expect(runningRow).not.toBeNull(); - expect(runningRow!.querySelectorAll('[data-testid="subagents-synced-loader"]').length).toBe(1); - expect(runningRow!.querySelectorAll('[data-testid="subagents-provider-icon"]').length).toBe(0); - }); -}); diff --git a/packages/app/src/subagents/track.tsx b/packages/app/src/subagents/track.tsx index 48ca2804b..47178949d 100644 --- a/packages/app/src/subagents/track.tsx +++ b/packages/app/src/subagents/track.tsx @@ -11,8 +11,8 @@ import { type WorkspaceTabPresentation, } from "@/screens/workspace/workspace-tab-presentation"; import type { Theme } from "@/styles/theme"; -import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state"; import type { SubagentRow } from "./select"; +import { buildSubagentRowPresentationData, formatHeaderLabel } from "./track-presentation"; const ThemedArchive = withUnistyles(Archive); const ThemedChevronDown = withUnistyles(ChevronDown); @@ -31,48 +31,10 @@ export interface SubagentsTrackProps { const SUBAGENTS_LIST_MAX_HEIGHT = 200; -function formatHeaderLabel(rows: SubagentRow[]): string { - let runningCount = 0; - for (const row of rows) { - if (row.status === "running") { - runningCount += 1; - } - } - - const parts = [`${rows.length} ${rows.length === 1 ? "subagent" : "subagents"}`]; - if (runningCount > 0) { - parts.push(`${runningCount} running`); - } - return parts.join(" · "); -} - -function resolveRowLabel(title: SubagentRow["title"]): string | null { - if (typeof title !== "string") { - return null; - } - const normalized = title.trim(); - if (!normalized) { - return null; - } - if (normalized.toLowerCase() === "new agent") { - return null; - } - return normalized; -} - function buildRowPresentation(row: SubagentRow): WorkspaceTabPresentation { - const label = resolveRowLabel(row.title); return { - key: `subagent_${row.id}`, - kind: "agent", - label: label ?? "", - subtitle: "", - titleState: label ? "ready" : "loading", + ...buildSubagentRowPresentationData(row), icon: getProviderIcon(row.provider), - statusBucket: deriveSidebarStateBucket({ - status: row.status, - requiresAttention: false, - }), }; } diff --git a/packages/app/src/subagents/use-archive-subagent.test.ts b/packages/app/src/subagents/use-archive-subagent.test.ts deleted file mode 100644 index 96dfb7896..000000000 --- a/packages/app/src/subagents/use-archive-subagent.test.ts +++ /dev/null @@ -1,217 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import { act, renderHook } from "@testing-library/react"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { useArchiveSubagent } from "@/subagents"; -import { useSessionStore, type Agent } from "@/stores/session-store"; -import { resolveArchiveSubagentDialog } from "./use-archive-subagent"; - -const { archiveAgentMock, confirmDialogMock } = vi.hoisted(() => ({ - archiveAgentMock: vi.fn(), - confirmDialogMock: vi.fn(), -})); - -vi.mock("@/hooks/use-archive-agent", () => ({ - useArchiveAgent: () => ({ - archiveAgent: archiveAgentMock, - }), -})); - -vi.mock("@/utils/confirm-dialog", () => ({ - confirmDialog: confirmDialogMock, -})); - -vi.mock("./track", () => ({ - SubagentsTrack: () => null, -})); - -const SERVER_ID = "server-1"; - -function makeAgent(input: { id: string; title?: Agent["title"]; status?: Agent["status"] }): Agent { - const createdAt = new Date("2026-03-04T00:00:00.000Z"); - return { - serverId: SERVER_ID, - id: input.id, - provider: "codex", - status: input.status ?? "idle", - createdAt, - updatedAt: createdAt, - lastUserMessageAt: null, - lastActivityAt: createdAt, - capabilities: { - supportsStreaming: true, - supportsSessionPersistence: true, - supportsDynamicModes: true, - supportsMcpServers: true, - supportsReasoningStream: true, - supportsToolInvocations: true, - }, - currentModeId: null, - availableModes: [], - pendingPermissions: [], - persistence: null, - runtimeInfo: { - provider: "codex", - sessionId: null, - }, - title: input.title ?? null, - cwd: "/repo/worktree", - model: null, - thinkingOptionId: null, - parentAgentId: "parent-agent", - labels: {}, - requiresAttention: false, - attentionReason: null, - attentionTimestamp: null, - archivedAt: null, - }; -} - -function seedSubagent(subagent: Agent): void { - const client = { archiveAgent: vi.fn() } as unknown as DaemonClient; - useSessionStore.getState().initializeSession(SERVER_ID, client); - useSessionStore.getState().setAgents(SERVER_ID, new Map([[subagent.id, subagent]])); -} - -describe("resolveArchiveSubagentDialog", () => { - it("uses running copy for running subagents", () => { - expect( - resolveArchiveSubagentDialog({ - title: "Review branch", - status: "running", - }), - ).toEqual({ - title: "Archive running subagent?", - message: - "Review branch is still running. Archiving it will stop the subagent and remove it from the track.", - confirmLabel: "Archive", - cancelLabel: "Cancel", - destructive: true, - }); - }); - - it("uses running copy for initializing subagents", () => { - expect( - resolveArchiveSubagentDialog({ - title: "Starting child", - status: "initializing", - }), - ).toEqual({ - title: "Archive running subagent?", - message: - "Starting child is still running. Archiving it will stop the subagent and remove it from the track.", - confirmLabel: "Archive", - cancelLabel: "Cancel", - destructive: true, - }); - }); - - it("uses idle copy for non-running subagents", () => { - expect( - resolveArchiveSubagentDialog({ - title: "Review branch", - status: "idle", - }), - ).toEqual({ - title: "Archive subagent?", - message: "Remove Review branch from the track. The subagent will be archived.", - confirmLabel: "Archive", - cancelLabel: "Cancel", - destructive: true, - }); - }); - - it("falls back to this subagent when the title is not displayable", () => { - expect( - resolveArchiveSubagentDialog({ - title: "New Agent", - status: null, - }), - ).toEqual({ - title: "Archive subagent?", - message: "Remove this subagent from the track. The subagent will be archived.", - confirmLabel: "Archive", - cancelLabel: "Cancel", - destructive: true, - }); - }); -}); - -describe("useArchiveSubagent", () => { - beforeEach(() => { - archiveAgentMock.mockReset(); - archiveAgentMock.mockResolvedValue(undefined); - confirmDialogMock.mockReset(); - useSessionStore.getState().clearSession(SERVER_ID); - }); - - afterEach(() => { - useSessionStore.getState().clearSession(SERVER_ID); - }); - - it("archives the subagent with the server id when the user confirms", async () => { - const subagent = makeAgent({ - id: "child-agent", - title: "Review branch", - status: "running", - }); - seedSubagent(subagent); - confirmDialogMock.mockResolvedValue(true); - - const { result } = renderHook(() => useArchiveSubagent({ serverId: SERVER_ID })); - - await act(async () => { - await (result.current as (subagentId: string) => Promise)(subagent.id); - }); - - expect(archiveAgentMock).toHaveBeenCalledTimes(1); - expect(archiveAgentMock).toHaveBeenCalledWith({ - serverId: SERVER_ID, - agentId: subagent.id, - }); - }); - - it("does not archive the subagent when the user cancels", async () => { - const subagent = makeAgent({ - id: "child-agent", - title: "Review branch", - status: "idle", - }); - seedSubagent(subagent); - confirmDialogMock.mockResolvedValue(false); - - const { result } = renderHook(() => useArchiveSubagent({ serverId: SERVER_ID })); - - await act(async () => { - await (result.current as (subagentId: string) => Promise)(subagent.id); - }); - - expect(archiveAgentMock).not.toHaveBeenCalled(); - }); - - it("passes the resolved dialog input for the subagent to confirmDialog", async () => { - const subagent = makeAgent({ - id: "child-agent", - title: "Review branch", - status: "running", - }); - seedSubagent(subagent); - confirmDialogMock.mockResolvedValue(false); - - const { result } = renderHook(() => useArchiveSubagent({ serverId: SERVER_ID })); - - await act(async () => { - await (result.current as (subagentId: string) => Promise)(subagent.id); - }); - - expect(confirmDialogMock).toHaveBeenCalledTimes(1); - expect(confirmDialogMock).toHaveBeenCalledWith( - resolveArchiveSubagentDialog({ - title: subagent.title, - status: subagent.status, - }), - ); - }); -}); diff --git a/packages/app/src/subagents/use-archive-subagent.ts b/packages/app/src/subagents/use-archive-subagent.ts index da6188f2f..6f344d3f8 100644 --- a/packages/app/src/subagents/use-archive-subagent.ts +++ b/packages/app/src/subagents/use-archive-subagent.ts @@ -1,65 +1,35 @@ import { useCallback } from "react"; import { useArchiveAgent } from "@/hooks/use-archive-agent"; -import { useSessionStore, type Agent } from "@/stores/session-store"; -import { confirmDialog, type ConfirmDialogInput } from "@/utils/confirm-dialog"; +import { useSessionStore } from "@/stores/session-store"; +import { confirmDialog } from "@/utils/confirm-dialog"; +import { requestArchiveSubagent, type ResolveArchiveSubagentDialogInput } from "./archive-subagent"; + +export { resolveArchiveSubagentDialog, requestArchiveSubagent } from "./archive-subagent"; +export type { + ArchiveSubagentDeps, + RequestArchiveSubagentInput, + ResolveArchiveSubagentDialogInput, +} from "./archive-subagent"; export interface UseArchiveSubagentInput { serverId: string; } -interface ResolveArchiveSubagentDialogInput { - title: Agent["title"] | null | undefined; - status: Agent["status"] | null | undefined; -} - -function resolveSubagentLabel(title: Agent["title"] | null | undefined): string | null { - if (typeof title !== "string") { - return null; - } - const normalized = title.trim(); - if (!normalized) { - return null; - } - if (normalized.toLowerCase() === "new agent") { - return null; - } - return normalized; -} - -export function resolveArchiveSubagentDialog( - input: ResolveArchiveSubagentDialogInput, -): ConfirmDialogInput { - const subagentLabel = resolveSubagentLabel(input.title) ?? "this subagent"; - const isRunning = input.status === "running" || input.status === "initializing"; - - return { - title: isRunning ? "Archive running subagent?" : "Archive subagent?", - message: isRunning - ? `${subagentLabel} is still running. Archiving it will stop the subagent and remove it from the track.` - : `Remove ${subagentLabel} from the track. The subagent will be archived.`, - confirmLabel: "Archive", - cancelLabel: "Cancel", - destructive: true, - }; -} - export function useArchiveSubagent(input: UseArchiveSubagentInput): (subagentId: string) => void { const { archiveAgent } = useArchiveAgent(); const { serverId } = input; return useCallback( - async (subagentId: string) => { - const subagent = useSessionStore.getState().sessions[serverId]?.agents?.get(subagentId); - const confirmed = await confirmDialog( - resolveArchiveSubagentDialog({ - title: subagent?.title, - status: subagent?.status, - }), + (subagentId: string) => { + void requestArchiveSubagent( + { serverId, subagentId }, + { + getSubagent: (id): ResolveArchiveSubagentDialogInput | undefined => + useSessionStore.getState().sessions[serverId]?.agents?.get(id), + confirm: confirmDialog, + archiveAgent, + }, ); - if (!confirmed) { - return; - } - void archiveAgent({ serverId, agentId: subagentId }).catch(() => {}); }, [archiveAgent, serverId], ); diff --git a/packages/app/src/terminal/drop/terminal-file-drop.test.ts b/packages/app/src/terminal/drop/terminal-file-drop.test.ts index d5a51327b..01147eb7b 100644 --- a/packages/app/src/terminal/drop/terminal-file-drop.test.ts +++ b/packages/app/src/terminal/drop/terminal-file-drop.test.ts @@ -1,7 +1,5 @@ -/** - * @vitest-environment jsdom - */ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import type { DesktopHostBridge } from "@/desktop/host"; import { extractTerminalDropPaths, isTerminalDragLeaveOutside, @@ -10,13 +8,6 @@ import { prepareDroppedPathsForTerminal, } from "./terminal-file-drop"; -function setDesktopBridge(bridge: unknown): void { - Object.defineProperty(window, "paseoDesktop", { - configurable: true, - value: bridge, - }); -} - function dataTransfer(input: { types?: string[]; files?: File[] }): DataTransfer { return { types: input.types ?? [], @@ -24,10 +15,25 @@ function dataTransfer(input: { types?: string[]; files?: File[] }): DataTransfer } as unknown as DataTransfer; } -afterEach(() => { - vi.restoreAllMocks(); - delete (window as unknown as { paseoDesktop?: unknown }).paseoDesktop; -}); +function fakeFile(input: { name: string; legacyPath?: string }): File { + const file = { name: input.name } as unknown as File; + if (input.legacyPath !== undefined) { + Object.defineProperty(file, "path", { + configurable: true, + value: input.legacyPath, + }); + } + return file; +} + +function makeNode(children: readonly EventTarget[] = []): EventTarget { + return { + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + contains: (other: EventTarget | null) => other !== null && children.includes(other), + } as unknown as EventTarget; +} describe("terminal file drop", () => { it("detects file drags", () => { @@ -37,77 +43,75 @@ describe("terminal file drop", () => { }); it("keeps drag highlight active when moving between terminal children", () => { - const root = document.createElement("div"); - const child = document.createElement("div"); - const outside = document.createElement("div"); - root.appendChild(child); + const child = makeNode(); + const outside = makeNode(); + const root = makeNode([child]); expect(isTerminalDragLeaveOutside({ currentTarget: root, relatedTarget: child })).toBe(false); expect(isTerminalDragLeaveOutside({ currentTarget: root, relatedTarget: outside })).toBe(true); expect(isTerminalDragLeaveOutside({ currentTarget: root, relatedTarget: null })).toBe(true); + expect(isTerminalDragLeaveOutside({ currentTarget: null, relatedTarget: child })).toBe(true); }); it("extracts paths through Electron webUtils", () => { - const file = new File(["image"], "photo.png", { type: "image/png" }); + const file = fakeFile({ name: "photo.png" }); const getPathForFile = vi.fn(() => "/Users/me/Desktop/photo.png"); - setDesktopBridge({ - webUtils: { getPathForFile }, - }); + const bridge: DesktopHostBridge = { webUtils: { getPathForFile } }; - expect(extractTerminalDropPaths(dataTransfer({ types: ["Files"], files: [file] }))).toEqual([ - "/Users/me/Desktop/photo.png", - ]); + expect( + extractTerminalDropPaths(dataTransfer({ types: ["Files"], files: [file] }), bridge), + ).toEqual(["/Users/me/Desktop/photo.png"]); expect(getPathForFile).toHaveBeenCalledWith(file); }); it("falls back to legacy Electron file paths", () => { - const file = new File(["image"], "photo.png", { type: "image/png" }); - Object.defineProperty(file, "path", { - configurable: true, - value: "/tmp/legacy-photo.png", - }); - setDesktopBridge({ + const file = fakeFile({ name: "photo.png", legacyPath: "/tmp/legacy-photo.png" }); + const bridge: DesktopHostBridge = { webUtils: { getPathForFile: vi.fn(() => { throw new Error("not available"); }), }, - }); + }; - expect(extractTerminalDropPaths(dataTransfer({ types: ["Files"], files: [file] }))).toEqual([ - "/tmp/legacy-photo.png", - ]); + expect( + extractTerminalDropPaths(dataTransfer({ types: ["Files"], files: [file] }), bridge), + ).toEqual(["/tmp/legacy-photo.png"]); }); it("drops browser files that have no filesystem path", () => { - const file = new File(["image"], "photo.png", { type: "image/png" }); + const file = fakeFile({ name: "photo.png" }); - expect(extractTerminalDropPaths(dataTransfer({ types: ["Files"], files: [file] }))).toEqual([]); + expect( + extractTerminalDropPaths(dataTransfer({ types: ["Files"], files: [file] }), null), + ).toEqual([]); }); it("prepares POSIX paths with conservative escaping", () => { - setDesktopBridge({ platform: "darwin" }); + const bridge: DesktopHostBridge = { platform: "darwin" }; - expect(prepareDroppedPathForTerminal("/tmp/my image.png")).toBe("'/tmp/my image.png'"); - expect(prepareDroppedPathForTerminal("/tmp/a$(touch bad).png")).toBe("'/tmp/a(touch bad).png'"); - expect(prepareDroppedPathForTerminal("/tmp/it's.png")).toBe("'/tmp/it\\'s.png'"); + expect(prepareDroppedPathForTerminal("/tmp/my image.png", bridge)).toBe("'/tmp/my image.png'"); + expect(prepareDroppedPathForTerminal("/tmp/a$(touch bad).png", bridge)).toBe( + "'/tmp/a(touch bad).png'", + ); + expect(prepareDroppedPathForTerminal("/tmp/it's.png", bridge)).toBe("'/tmp/it\\'s.png'"); }); it("prepares Windows paths with space quoting", () => { - setDesktopBridge({ platform: "win32" }); + const bridge: DesktopHostBridge = { platform: "win32" }; - expect(prepareDroppedPathForTerminal("C:\\Users\\me\\photo.png")).toBe( + expect(prepareDroppedPathForTerminal("C:\\Users\\me\\photo.png", bridge)).toBe( "C:\\Users\\me\\photo.png", ); - expect(prepareDroppedPathForTerminal("C:\\Users\\me\\photo one.png")).toBe( + expect(prepareDroppedPathForTerminal("C:\\Users\\me\\photo one.png", bridge)).toBe( '"C:\\Users\\me\\photo one.png"', ); }); it("joins multiple dropped paths for one terminal input", () => { - setDesktopBridge({ platform: "darwin" }); + const bridge: DesktopHostBridge = { platform: "darwin" }; - expect(prepareDroppedPathsForTerminal(["/tmp/a.png", "/tmp/b c.png"])).toBe( + expect(prepareDroppedPathsForTerminal(["/tmp/a.png", "/tmp/b c.png"], bridge)).toBe( "'/tmp/a.png' '/tmp/b c.png'", ); }); diff --git a/packages/app/src/terminal/drop/terminal-file-drop.ts b/packages/app/src/terminal/drop/terminal-file-drop.ts index df0a97700..44dab5b14 100644 --- a/packages/app/src/terminal/drop/terminal-file-drop.ts +++ b/packages/app/src/terminal/drop/terminal-file-drop.ts @@ -1,27 +1,13 @@ -interface DesktopFilePathBridge { - platform?: string; - webUtils?: { - getPathForFile?: (file: File) => string; - }; -} +import type { DesktopHostBridge } from "@/desktop/host"; const DANGEROUS_NON_WINDOWS_PATH_CHARS = /[`$|&>~#!^*;<]/g; -function getDesktopBridge(): DesktopFilePathBridge | null { - if (typeof window === "undefined") { - return null; - } - const bridge = (window as unknown as { paseoDesktop?: DesktopFilePathBridge }).paseoDesktop; - return bridge && typeof bridge === "object" ? bridge : null; -} - function getLegacyFilePath(file: File): string | null { const path = Reflect.get(file, "path"); return typeof path === "string" && path.length > 0 ? path : null; } -function getFilePath(file: File): string | null { - const bridge = getDesktopBridge(); +function getFilePath(file: File, bridge: DesktopHostBridge | null): string | null { const getPathForFile = bridge?.webUtils?.getPathForFile; if (typeof getPathForFile === "function") { try { @@ -40,24 +26,39 @@ export function isTerminalFileDrag(dataTransfer: DataTransfer | null): boolean { return Boolean(dataTransfer && Array.from(dataTransfer.types).includes("Files")); } +interface ContainsTarget { + contains: (other: EventTarget | null) => boolean; +} + +function asContainsTarget(value: EventTarget | null): ContainsTarget | null { + if (!value || typeof (value as Partial).contains !== "function") { + return null; + } + return value as unknown as ContainsTarget; +} + export function isTerminalDragLeaveOutside(input: { currentTarget: EventTarget | null; relatedTarget: EventTarget | null; }): boolean { - if (!(input.currentTarget instanceof Node) || !(input.relatedTarget instanceof Node)) { + const currentTarget = asContainsTarget(input.currentTarget); + if (!currentTarget || !input.relatedTarget) { return true; } - return !input.currentTarget.contains(input.relatedTarget); + return !currentTarget.contains(input.relatedTarget); } -export function extractTerminalDropPaths(dataTransfer: DataTransfer | null): string[] { +export function extractTerminalDropPaths( + dataTransfer: DataTransfer | null, + bridge: DesktopHostBridge | null, +): string[] { if (!dataTransfer) { return []; } const paths: string[] = []; for (const file of Array.from(dataTransfer.files)) { - const path = getFilePath(file); + const path = getFilePath(file, bridge); if (path) { paths.push(path); } @@ -89,14 +90,19 @@ function escapeWindowsPath(path: string): string { return `"${path}"`; } -export function prepareDroppedPathForTerminal(path: string): string { - const platform = getDesktopBridge()?.platform; - if (platform === "win32") { +export function prepareDroppedPathForTerminal( + path: string, + bridge: DesktopHostBridge | null, +): string { + if (bridge?.platform === "win32") { return escapeWindowsPath(path); } return escapeNonWindowsPath(path); } -export function prepareDroppedPathsForTerminal(paths: readonly string[]): string { - return paths.map(prepareDroppedPathForTerminal).join(" "); +export function prepareDroppedPathsForTerminal( + paths: readonly string[], + bridge: DesktopHostBridge | null, +): string { + return paths.map((path) => prepareDroppedPathForTerminal(path, bridge)).join(" "); } diff --git a/packages/app/src/utils/client-id.test.ts b/packages/app/src/utils/client-id.test.ts index 3b7be3e22..647ea48b0 100644 --- a/packages/app/src/utils/client-id.test.ts +++ b/packages/app/src/utils/client-id.test.ts @@ -1,45 +1,79 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; +import { createClientIdResolver, type ClientIdStorage } from "./client-id"; -const asyncStorageMock = vi.hoisted(() => ({ - getItem: vi.fn<(_: string) => Promise>(), - setItem: vi.fn<(_: string, __: string) => Promise>(), -})); +interface InMemoryStorage extends ClientIdStorage { + items: Map; + setCallCount: number; +} -vi.mock("@react-native-async-storage/async-storage", () => ({ - default: asyncStorageMock, -})); +function inMemoryStorage(initial: Record = {}): InMemoryStorage { + const items = new Map(Object.entries(initial)); + const storage: InMemoryStorage = { + items, + setCallCount: 0, + async getItem(key) { + return items.get(key) ?? null; + }, + async setItem(key, value) { + storage.setCallCount += 1; + items.set(key, value); + }, + }; + return storage; +} -describe("client-id", () => { - beforeEach(() => { - vi.resetModules(); - asyncStorageMock.getItem.mockReset(); - asyncStorageMock.setItem.mockReset(); +describe("clientIdResolver", () => { + it("returns the stored client id when present and does not regenerate", async () => { + const storage = inMemoryStorage({ "@paseo:client-id-v1": "cid_existing" }); + const resolver = createClientIdResolver({ + storage, + generateUuid: () => { + throw new Error("generateUuid should not run when an id is stored"); + }, + }); + + expect(await resolver.getOrCreate()).toBe("cid_existing"); + expect(storage.setCallCount).toBe(0); }); - it("returns stored client id when present", async () => { - asyncStorageMock.getItem.mockResolvedValue("cid_existing"); - const mod = await import("./client-id"); + it("creates and persists a new client id when storage is empty", async () => { + const storage = inMemoryStorage(); + const resolver = createClientIdResolver({ + storage, + generateUuid: () => "123456781234123412341234567890ab", + }); - const key = await mod.getOrCreateClientId(); - expect(key).toBe("cid_existing"); - expect(asyncStorageMock.getItem).toHaveBeenCalledTimes(1); - expect(asyncStorageMock.setItem).not.toHaveBeenCalled(); + expect(await resolver.getOrCreate()).toBe("cid_123456781234123412341234567890ab"); + expect(storage.items.get("@paseo:client-id-v1")).toBe("cid_123456781234123412341234567890ab"); }); - it("creates and persists a client id when missing", async () => { - asyncStorageMock.getItem.mockResolvedValue(null); - asyncStorageMock.setItem.mockResolvedValue(); - vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue( - "12345678-1234-1234-1234-1234567890ab", - ); + it("dedupes concurrent callers behind a single storage write", async () => { + const storage = inMemoryStorage(); + let uuidCalls = 0; + const resolver = createClientIdResolver({ + storage, + generateUuid: () => { + uuidCalls += 1; + return "abcdef0123456789abcdef0123456789"; + }, + }); - const mod = await import("./client-id"); - const key = await mod.getOrCreateClientId(); + const [first, second] = await Promise.all([resolver.getOrCreate(), resolver.getOrCreate()]); - expect(key).toBe("cid_123456781234123412341234567890ab"); - expect(asyncStorageMock.setItem).toHaveBeenCalledWith( - "@paseo:client-id-v1", - "cid_123456781234123412341234567890ab", - ); + expect(first).toBe("cid_abcdef0123456789abcdef0123456789"); + expect(second).toBe(first); + expect(uuidCalls).toBe(1); + expect(storage.setCallCount).toBe(1); + }); + + it("ignores stored blank strings and treats them as missing", async () => { + const storage = inMemoryStorage({ "@paseo:client-id-v1": " " }); + const resolver = createClientIdResolver({ + storage, + generateUuid: () => "newuuid", + }); + + expect(await resolver.getOrCreate()).toBe("cid_newuuid"); + expect(storage.items.get("@paseo:client-id-v1")).toBe("cid_newuuid"); }); }); diff --git a/packages/app/src/utils/client-id.ts b/packages/app/src/utils/client-id.ts index 19f307a91..52397e91b 100644 --- a/packages/app/src/utils/client-id.ts +++ b/packages/app/src/utils/client-id.ts @@ -2,18 +2,13 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; const CLIENT_ID_STORAGE_KEY = "@paseo:client-id-v1"; -let cachedClientId: string | null = null; -let inFlightClientId: Promise | null = null; +export interface ClientIdStorage { + getItem(key: string): Promise; + setItem(key: string, value: string): Promise; +} -function generateClientId(): string { - const randomUuid = (() => { - const cryptoObj = globalThis.crypto as { randomUUID?: () => string } | undefined; - if (cryptoObj && typeof cryptoObj.randomUUID === "function") { - return cryptoObj.randomUUID().replace(/-/g, ""); - } - return `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`; - })(); - return `cid_${randomUuid}`; +export interface ClientIdResolver { + getOrCreate(): Promise; } function normalizeStoredClientId(value: unknown): string | null { @@ -24,31 +19,60 @@ function normalizeStoredClientId(value: unknown): string | null { return trimmed.length > 0 ? trimmed : null; } -export async function getOrCreateClientId(): Promise { - if (cachedClientId) { - return cachedClientId; - } - if (inFlightClientId) { - return inFlightClientId; - } +export function createClientIdResolver(deps: { + storage: ClientIdStorage; + generateUuid: () => string; + storageKey?: string; +}): ClientIdResolver { + const storageKey = deps.storageKey ?? CLIENT_ID_STORAGE_KEY; + let cached: string | null = null; + let inFlight: Promise | null = null; - inFlightClientId = (async () => { - const storedValue = await AsyncStorage.getItem(CLIENT_ID_STORAGE_KEY); - const existing = normalizeStoredClientId(storedValue); - if (existing) { - cachedClientId = existing; - return existing; - } + return { + async getOrCreate(): Promise { + if (cached) { + return cached; + } + if (inFlight) { + return inFlight; + } - const nextValue = generateClientId(); - await AsyncStorage.setItem(CLIENT_ID_STORAGE_KEY, nextValue); - cachedClientId = nextValue; - return nextValue; - })(); + inFlight = (async () => { + const stored = await deps.storage.getItem(storageKey); + const existing = normalizeStoredClientId(stored); + if (existing) { + cached = existing; + return existing; + } - try { - return await inFlightClientId; - } finally { - inFlightClientId = null; + const next = `cid_${deps.generateUuid()}`; + await deps.storage.setItem(storageKey, next); + cached = next; + return next; + })(); + + try { + return await inFlight; + } finally { + inFlight = null; + } + }, + }; +} + +function generateUuidFromGlobalCrypto(): string { + const cryptoObj = globalThis.crypto as { randomUUID?: () => string } | undefined; + if (cryptoObj && typeof cryptoObj.randomUUID === "function") { + return cryptoObj.randomUUID().replace(/-/g, ""); } + return `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`; +} + +const defaultResolver = createClientIdResolver({ + storage: AsyncStorage, + generateUuid: generateUuidFromGlobalCrypto, +}); + +export async function getOrCreateClientId(): Promise { + return defaultResolver.getOrCreate(); } diff --git a/packages/app/src/utils/navigate-to-agent.test.ts b/packages/app/src/utils/navigate-to-agent.test.ts deleted file mode 100644 index 627186b6c..000000000 --- a/packages/app/src/utils/navigate-to-agent.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const { routerMock } = vi.hoisted(() => ({ - routerMock: { - dismissTo: vi.fn(), - navigate: vi.fn(), - replace: vi.fn(), - }, -})); - -vi.mock("expo-router", () => ({ - router: routerMock, - useLocalSearchParams: () => ({}), - usePathname: () => "/", -})); - -vi.mock("@react-native-async-storage/async-storage", () => { - const storage = new Map(); - return { - default: { - getItem: vi.fn(async (key: string) => storage.get(key) ?? null), - setItem: vi.fn(async (key: string, value: string) => { - storage.set(key, value); - }), - removeItem: vi.fn(async (key: string) => { - storage.delete(key); - }), - }, - }; -}); - -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { useSessionStore, type Agent, type WorkspaceDescriptor } from "@/stores/session-store"; -import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; -import { navigateToAgent } from "@/utils/navigate-to-agent"; - -const SERVER_ID = "server-1"; -const WORKSPACE_ID = "workspace-1"; -const AGENT_ID = "agent-1"; - -function createWorkspace(): WorkspaceDescriptor { - return { - id: WORKSPACE_ID, - projectId: "project-1", - projectDisplayName: "Project", - projectRootPath: "/repo", - workspaceDirectory: "/repo/worktree", - projectKind: "git", - workspaceKind: "local_checkout", - name: "worktree", - status: "done", - archivingAt: null, - diffStat: null, - scripts: [], - }; -} - -function createAgent(input: Partial = {}): Agent { - return { - serverId: SERVER_ID, - id: AGENT_ID, - provider: "codex", - status: "closed", - createdAt: new Date("2026-05-09T00:00:00.000Z"), - updatedAt: new Date("2026-05-09T00:00:00.000Z"), - lastUserMessageAt: null, - lastActivityAt: new Date("2026-05-09T00:00:00.000Z"), - capabilities: { - supportsStreaming: false, - supportsSessionPersistence: false, - supportsDynamicModes: false, - supportsMcpServers: false, - supportsReasoningStream: false, - supportsToolInvocations: false, - }, - currentModeId: null, - availableModes: [], - pendingPermissions: [], - persistence: null, - title: "Archived agent", - cwd: "/repo/worktree", - model: null, - thinkingOptionId: null, - archivedAt: new Date("2026-05-09T00:00:00.000Z"), - parentAgentId: null, - labels: {}, - ...input, - }; -} - -describe("navigateToAgent", () => { - beforeEach(() => { - routerMock.dismissTo.mockReset(); - routerMock.navigate.mockReset(); - routerMock.replace.mockReset(); - useSessionStore.getState().clearSession(SERVER_ID); - useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient); - useSessionStore - .getState() - .setWorkspaces(SERVER_ID, new Map([[WORKSPACE_ID, createWorkspace()]])); - useWorkspaceLayoutStore.setState({ - layoutByWorkspace: {}, - splitSizesByWorkspace: {}, - pinnedAgentIdsByWorkspace: {}, - hiddenAgentIdsByWorkspace: {}, - }); - }); - - it("opens archived agent details through the resolved workspace", () => { - useSessionStore.getState().setAgentDetails(SERVER_ID, new Map([[AGENT_ID, createAgent()]])); - - const route = navigateToAgent({ serverId: SERVER_ID, agentId: AGENT_ID, pin: true }); - - expect(route).toBe("/h/server-1/workspace/workspace-1"); - expect(routerMock.navigate).not.toHaveBeenCalled(); - expect(routerMock.dismissTo).toHaveBeenCalledWith("/h/server-1/workspace/workspace-1"); - const key = `${SERVER_ID}:${WORKSPACE_ID}`; - expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(key)).toEqual([ - expect.objectContaining({ target: { kind: "agent", agentId: AGENT_ID } }), - ]); - expect(useWorkspaceLayoutStore.getState().pinnedAgentIdsByWorkspace[key]).toEqual( - new Set([AGENT_ID]), - ); - }); - - it("falls back to the host agent route when the workspace is unknown", () => { - const route = navigateToAgent({ serverId: SERVER_ID, agentId: "missing-agent" }); - - expect(route).toBe("/h/server-1/agent/missing-agent"); - expect(routerMock.navigate).toHaveBeenCalledWith("/h/server-1/agent/missing-agent"); - expect(routerMock.dismissTo).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/app/src/utils/navigate-to-agent.ts b/packages/app/src/utils/navigate-to-agent.ts deleted file mode 100644 index 9dc73767a..000000000 --- a/packages/app/src/utils/navigate-to-agent.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { router, type Href } from "expo-router"; -import { useSessionStore } from "@/stores/session-store"; -import { buildHostAgentDetailRoute } from "@/utils/host-routes"; -import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution"; -import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation"; - -interface NavigateToAgentInput { - serverId: string; - agentId: string; - currentPathname?: string | null; - pin?: boolean; -} - -export function navigateToAgent(input: NavigateToAgentInput): string { - const session = useSessionStore.getState().sessions[input.serverId]; - const agent = session?.agents.get(input.agentId) ?? session?.agentDetails.get(input.agentId); - const workspaceId = resolveWorkspaceIdByExecutionDirectory({ - workspaces: session?.workspaces.values(), - workspaceDirectory: agent?.cwd, - }); - - if (!workspaceId) { - const route = buildHostAgentDetailRoute(input.serverId, input.agentId); - router.navigate(route as Href); - return route; - } - - return navigateToPreparedWorkspaceTab({ - serverId: input.serverId, - workspaceId, - target: { kind: "agent", agentId: input.agentId }, - currentPathname: input.currentPathname, - pin: input.pin, - }); -} diff --git a/packages/app/src/utils/navigate-to-agent/index.ts b/packages/app/src/utils/navigate-to-agent/index.ts new file mode 100644 index 000000000..44d412ecb --- /dev/null +++ b/packages/app/src/utils/navigate-to-agent/index.ts @@ -0,0 +1,23 @@ +import { router, type Href } from "expo-router"; +import { useSessionStore } from "@/stores/session-store"; +import { resolveNavigateToAgent, type NavigateToAgentInput } from "./resolve"; +import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation"; + +export type { NavigateToAgentInput } from "./resolve"; + +export function navigateToAgent(input: NavigateToAgentInput): string { + return resolveNavigateToAgent(input, { + readAgentNavTarget: ({ serverId, agentId }) => { + const session = useSessionStore.getState().sessions[serverId]; + const agent = session?.agents.get(agentId) ?? session?.agentDetails.get(agentId); + return { + workspaces: session?.workspaces.values(), + agentCwd: agent?.cwd, + }; + }, + navigateToHostAgent: (route) => { + router.navigate(route as Href); + }, + navigateToPreparedWorkspaceTab, + }); +} diff --git a/packages/app/src/utils/navigate-to-agent/resolve.test.ts b/packages/app/src/utils/navigate-to-agent/resolve.test.ts new file mode 100644 index 000000000..de61b33be --- /dev/null +++ b/packages/app/src/utils/navigate-to-agent/resolve.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; +import type { WorkspaceDescriptor } from "@/stores/session-store"; +import { + resolveNavigateToAgent, + type AgentNavTarget, + type NavigateToAgentDeps, +} from "@/utils/navigate-to-agent/resolve"; +import type { NavigateToPreparedWorkspaceTabInput } from "@/utils/prepare-workspace-tab"; + +const SERVER_ID = "server-1"; +const WORKSPACE_ID = "workspace-1"; +const AGENT_ID = "agent-1"; + +function createWorkspace(): WorkspaceDescriptor { + return { + id: WORKSPACE_ID, + projectId: "project-1", + projectDisplayName: "Project", + projectRootPath: "/repo", + workspaceDirectory: "/repo/worktree", + projectKind: "git", + workspaceKind: "local_checkout", + name: "worktree", + status: "done", + archivingAt: null, + diffStat: null, + scripts: [], + }; +} + +interface RecordedHostNav { + route: string; +} + +interface RecordedTabNav extends NavigateToPreparedWorkspaceTabInput {} + +function createFakeNavigators(target: AgentNavTarget): { + deps: NavigateToAgentDeps; + hostNavigations: RecordedHostNav[]; + tabNavigations: RecordedTabNav[]; +} { + const hostNavigations: RecordedHostNav[] = []; + const tabNavigations: RecordedTabNav[] = []; + return { + hostNavigations, + tabNavigations, + deps: { + readAgentNavTarget: () => target, + navigateToHostAgent: (route) => { + hostNavigations.push({ route }); + }, + navigateToPreparedWorkspaceTab: (input) => { + tabNavigations.push(input); + return `/h/${input.serverId}/workspace/${input.workspaceId}`; + }, + }, + }; +} + +describe("resolveNavigateToAgent", () => { + it("opens the resolved workspace tab when the agent's cwd matches a workspace", () => { + const { deps, hostNavigations, tabNavigations } = createFakeNavigators({ + workspaces: [createWorkspace()], + agentCwd: "/repo/worktree", + }); + + const route = resolveNavigateToAgent( + { serverId: SERVER_ID, agentId: AGENT_ID, pin: true }, + deps, + ); + + expect(route).toBe("/h/server-1/workspace/workspace-1"); + expect(hostNavigations).toEqual([]); + expect(tabNavigations).toEqual([ + { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + target: { kind: "agent", agentId: AGENT_ID }, + currentPathname: undefined, + pin: true, + }, + ]); + }); + + it("falls back to the host agent route when the workspace is unknown", () => { + const { deps, hostNavigations, tabNavigations } = createFakeNavigators({ + workspaces: [], + agentCwd: null, + }); + + const route = resolveNavigateToAgent({ serverId: SERVER_ID, agentId: "missing-agent" }, deps); + + expect(route).toBe("/h/server-1/agent/missing-agent"); + expect(hostNavigations).toEqual([{ route: "/h/server-1/agent/missing-agent" }]); + expect(tabNavigations).toEqual([]); + }); +}); diff --git a/packages/app/src/utils/navigate-to-agent/resolve.ts b/packages/app/src/utils/navigate-to-agent/resolve.ts new file mode 100644 index 000000000..a9c286924 --- /dev/null +++ b/packages/app/src/utils/navigate-to-agent/resolve.ts @@ -0,0 +1,50 @@ +import type { WorkspaceDescriptor } from "@/stores/session-store"; +import { buildHostAgentDetailRoute } from "@/utils/host-routes"; +import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution"; +import type { NavigateToPreparedWorkspaceTabInput } from "@/utils/prepare-workspace-tab"; + +export interface NavigateToAgentInput { + serverId: string; + agentId: string; + currentPathname?: string | null; + pin?: boolean; +} + +export interface AgentNavTarget { + workspaces: Iterable | null | undefined; + agentCwd: string | null | undefined; +} + +export interface NavigateToAgentDeps { + readAgentNavTarget: (input: { serverId: string; agentId: string }) => AgentNavTarget; + navigateToHostAgent: (route: string) => void; + navigateToPreparedWorkspaceTab: (input: NavigateToPreparedWorkspaceTabInput) => string; +} + +export function resolveNavigateToAgent( + input: NavigateToAgentInput, + deps: NavigateToAgentDeps, +): string { + const { workspaces, agentCwd } = deps.readAgentNavTarget({ + serverId: input.serverId, + agentId: input.agentId, + }); + const workspaceId = resolveWorkspaceIdByExecutionDirectory({ + workspaces, + workspaceDirectory: agentCwd, + }); + + if (!workspaceId) { + const route = buildHostAgentDetailRoute(input.serverId, input.agentId); + deps.navigateToHostAgent(route); + return route; + } + + return deps.navigateToPreparedWorkspaceTab({ + serverId: input.serverId, + workspaceId, + target: { kind: "agent", agentId: input.agentId }, + currentPathname: input.currentPathname, + pin: input.pin, + }); +} diff --git a/packages/app/src/utils/prepare-workspace-tab.ts b/packages/app/src/utils/prepare-workspace-tab.ts new file mode 100644 index 000000000..ee116ff3b --- /dev/null +++ b/packages/app/src/utils/prepare-workspace-tab.ts @@ -0,0 +1,68 @@ +import { generateDraftId } from "@/stores/draft-keys"; +import { + buildWorkspaceTabPersistenceKey, + type WorkspaceTabTarget, +} from "@/stores/workspace-tabs-store"; +import { buildHostWorkspaceRoute } from "@/utils/host-routes"; + +export interface PrepareWorkspaceTabInput { + serverId: string; + workspaceId: string; + target: WorkspaceTabTarget; + pin?: boolean; +} + +export interface NavigateToPreparedWorkspaceTabInput extends PrepareWorkspaceTabInput { + currentPathname?: string | null; +} + +export interface PrepareWorkspaceTabDeps { + openTabFocused: (workspaceKey: string, target: WorkspaceTabTarget) => string | null; + pinAgent: (workspaceKey: string, agentId: string) => void; +} + +export interface NavigateToPreparedWorkspaceTabDeps extends PrepareWorkspaceTabDeps { + navigateToWorkspace: ( + serverId: string, + workspaceId: string, + options: { currentPathname?: string | null }, + ) => void; +} + +function getPreparedTarget(target: WorkspaceTabTarget): WorkspaceTabTarget { + if (target.kind !== "draft" || target.draftId.trim() !== "new") { + return target; + } + return { kind: "draft", draftId: generateDraftId() }; +} + +export function prepareWorkspaceTab( + input: PrepareWorkspaceTabInput, + deps: PrepareWorkspaceTabDeps, +): string { + const target = getPreparedTarget(input.target); + const key = + buildWorkspaceTabPersistenceKey({ + serverId: input.serverId, + workspaceId: input.workspaceId, + }) ?? ""; + + deps.openTabFocused(key, target); + + if (input.pin && target.kind === "agent") { + deps.pinAgent(key, target.agentId); + } + + return buildHostWorkspaceRoute(input.serverId, input.workspaceId); +} + +export function navigateToPreparedWorkspaceTab( + input: NavigateToPreparedWorkspaceTabInput, + deps: NavigateToPreparedWorkspaceTabDeps, +): string { + const route = prepareWorkspaceTab(input, deps); + deps.navigateToWorkspace(input.serverId, input.workspaceId, { + currentPathname: input.currentPathname, + }); + return route; +} diff --git a/packages/app/src/utils/rich-clipboard-default-environment.ts b/packages/app/src/utils/rich-clipboard-default-environment.ts new file mode 100644 index 000000000..2f32c314d --- /dev/null +++ b/packages/app/src/utils/rich-clipboard-default-environment.ts @@ -0,0 +1,30 @@ +import * as Clipboard from "expo-clipboard"; +import { isWeb } from "@/constants/platform"; +import type { MarkdownClipboardEnvironment, RichClipboardWriter } from "@/utils/rich-clipboard"; + +export function getDefaultMarkdownClipboardEnvironment(): MarkdownClipboardEnvironment { + return { + richWriter: getWebRichClipboardWriter(), + writePlainText: (text) => Clipboard.setStringAsync(text), + }; +} + +function getWebRichClipboardWriter(): RichClipboardWriter | null { + if (!isWeb) { + return null; + } + if (typeof navigator === "undefined" || typeof navigator.clipboard?.write !== "function") { + return null; + } + if (typeof ClipboardItem === "undefined") { + return null; + } + + return { + supportsHtml: () => + typeof ClipboardItem.supports !== "function" || ClipboardItem.supports("text/html"), + write: async (data) => { + await navigator.clipboard.write([new ClipboardItem(data)]); + }, + }; +} diff --git a/packages/app/src/utils/rich-clipboard.test.ts b/packages/app/src/utils/rich-clipboard.test.ts index b204f3786..129d87c23 100644 --- a/packages/app/src/utils/rich-clipboard.test.ts +++ b/packages/app/src/utils/rich-clipboard.test.ts @@ -1,21 +1,43 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { createMarkdownClipboardContent, + type MarkdownClipboardEnvironment, type RichClipboardWriter, writeMarkdownToRichClipboard, } from "./rich-clipboard"; -const { setStringAsyncMock } = vi.hoisted(() => ({ - setStringAsyncMock: vi.fn(async () => true), -})); +interface RecordingClipboard { + environment: MarkdownClipboardEnvironment; + richWrites: Array>; + plainTexts: string[]; +} -vi.mock("expo-clipboard", () => ({ - setStringAsync: setStringAsyncMock, -})); +function createRecordingClipboard( + options: { supportsHtml?: boolean; richWriteFails?: boolean } = {}, +): RecordingClipboard { + const { supportsHtml = true, richWriteFails = false } = options; + const richWrites: Array> = []; + const plainTexts: string[] = []; -beforeEach(() => { - setStringAsyncMock.mockClear(); -}); + const richWriter: RichClipboardWriter = { + supportsHtml: () => supportsHtml, + write: async (data) => { + if (richWriteFails) { + throw new Error("clipboard denied"); + } + richWrites.push(data); + }, + }; + + const environment: MarkdownClipboardEnvironment = { + richWriter, + writePlainText: async (text) => { + plainTexts.push(text); + }, + }; + + return { environment, richWrites, plainTexts }; +} describe("createMarkdownClipboardContent", () => { it("renders markdown structures to clipboard html", () => { @@ -56,42 +78,25 @@ describe("createMarkdownClipboardContent", () => { describe("writeMarkdownToRichClipboard", () => { it("writes plain text and html when a rich clipboard writer is available", async () => { - const markdown = "- item"; - const writes: Array[0]> = []; - const richWriter: RichClipboardWriter = { - supportsHtml: () => true, - write: async (data) => { - writes.push(data); - }, - }; + const clipboard = createRecordingClipboard(); - await writeMarkdownToRichClipboard(markdown, { - richWriter, - writePlainText: setStringAsyncMock, - }); + await writeMarkdownToRichClipboard("- item", clipboard.environment); - const written = writes[0]; + const written = clipboard.richWrites[0]; if (!written) { throw new Error("Expected rich clipboard data to be written"); } - await expect(written["text/plain"].text()).resolves.toBe(markdown); + await expect(written["text/plain"].text()).resolves.toBe("- item"); await expect(written["text/html"].text()).resolves.toContain("
  • item
  • "); - expect(setStringAsyncMock).not.toHaveBeenCalled(); + expect(clipboard.plainTexts).toEqual([]); }); it("falls back to plain text when rich clipboard writing fails", async () => { - const richWriter: RichClipboardWriter = { - supportsHtml: () => true, - write: async () => { - throw new Error("clipboard denied"); - }, - }; + const clipboard = createRecordingClipboard({ richWriteFails: true }); - await writeMarkdownToRichClipboard("**bold**", { - richWriter, - writePlainText: setStringAsyncMock, - }); + await writeMarkdownToRichClipboard("**bold**", clipboard.environment); - expect(setStringAsyncMock).toHaveBeenCalledWith("**bold**"); + expect(clipboard.plainTexts).toEqual(["**bold**"]); + expect(clipboard.richWrites).toEqual([]); }); }); diff --git a/packages/app/src/utils/rich-clipboard.ts b/packages/app/src/utils/rich-clipboard.ts index bd0a826e7..ad4030333 100644 --- a/packages/app/src/utils/rich-clipboard.ts +++ b/packages/app/src/utils/rich-clipboard.ts @@ -1,6 +1,4 @@ -import * as Clipboard from "expo-clipboard"; import MarkdownIt from "markdown-it"; -import { isWeb } from "@/constants/platform"; const markdownRenderer = new MarkdownIt({ html: false, @@ -34,7 +32,7 @@ export function createMarkdownClipboardContent(markdown: string): MarkdownClipbo export async function writeMarkdownToRichClipboard( markdown: string, - environment: MarkdownClipboardEnvironment = getDefaultMarkdownClipboardEnvironment(), + environment: MarkdownClipboardEnvironment, ): Promise { if (environment.richWriter?.supportsHtml()) { const content = createMarkdownClipboardContent(markdown); @@ -52,30 +50,3 @@ export async function writeMarkdownToRichClipboard( await environment.writePlainText(markdown); } - -function getDefaultMarkdownClipboardEnvironment(): MarkdownClipboardEnvironment { - return { - richWriter: getWebRichClipboardWriter(), - writePlainText: (text) => Clipboard.setStringAsync(text), - }; -} - -function getWebRichClipboardWriter(): RichClipboardWriter | null { - if (!isWeb) { - return null; - } - if (typeof navigator === "undefined" || typeof navigator.clipboard?.write !== "function") { - return null; - } - if (typeof ClipboardItem === "undefined") { - return null; - } - - return { - supportsHtml: () => - typeof ClipboardItem.supports !== "function" || ClipboardItem.supports("text/html"), - write: async (data) => { - await navigator.clipboard.write([new ClipboardItem(data)]); - }, - }; -} diff --git a/packages/app/src/utils/sidebar-workspace-archive-redirect.ts b/packages/app/src/utils/sidebar-workspace-archive-redirect.ts index 1b0d8ed97..7c80d6821 100644 --- a/packages/app/src/utils/sidebar-workspace-archive-redirect.ts +++ b/packages/app/src/utils/sidebar-workspace-archive-redirect.ts @@ -1,26 +1,16 @@ import { router } from "expo-router"; -import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import { useSessionStore } from "@/stores/session-store"; -import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation"; +import { + redirectIfArchivingActiveWorkspace as redirectIfArchivingActiveWorkspacePure, + type RedirectIfArchivingActiveWorkspaceInput, +} from "@/utils/workspace-archive-redirect"; -export function redirectIfArchivingActiveWorkspace(input: { - serverId: string; - workspaceId: string; - activeWorkspaceSelection: ActiveWorkspaceSelection | null; -}): boolean { - if ( - input.activeWorkspaceSelection?.serverId !== input.serverId || - input.activeWorkspaceSelection.workspaceId !== input.workspaceId - ) { - return false; - } - - router.replace( - buildWorkspaceArchiveRedirectRoute({ - serverId: input.serverId, - archivedWorkspaceId: input.workspaceId, - workspaces: useSessionStore.getState().sessions[input.serverId]?.workspaces.values() ?? [], - }), - ); - return true; +export function redirectIfArchivingActiveWorkspace( + input: RedirectIfArchivingActiveWorkspaceInput, +): boolean { + return redirectIfArchivingActiveWorkspacePure(input, { + navigateToRoute: (route) => router.replace(route), + readWorkspaces: (serverId) => + useSessionStore.getState().sessions[serverId]?.workspaces.values() ?? [], + }); } diff --git a/packages/app/src/utils/tool-call-icon-name.test.ts b/packages/app/src/utils/tool-call-icon-name.test.ts new file mode 100644 index 000000000..14da899c3 --- /dev/null +++ b/packages/app/src/utils/tool-call-icon-name.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { resolveToolCallIconName } from "./tool-call-icon-name"; + +describe("resolveToolCallIconName", () => { + it("uses the bot icon for task sub-agent details", () => { + const icon = resolveToolCallIconName("Task", { + type: "sub_agent", + subAgentType: "Explore", + description: "Inspect repository", + log: "[Read] README.md", + }); + + expect(icon).toBe("bot"); + }); + + it("uses the bot icon for task calls without canonical detail", () => { + const icon = resolveToolCallIconName("Task", { + type: "unknown", + input: null, + output: null, + }); + + expect(icon).toBe("bot"); + expect(resolveToolCallIconName("Task")).toBe("bot"); + }); + + it("keeps the brain icon override for thinking calls with unknown detail", () => { + const icon = resolveToolCallIconName("thinking", { + type: "unknown", + input: null, + output: null, + }); + + expect(icon).toBe("brain"); + }); + + it("uses the custom icon from plain_text detail", () => { + const icon = resolveToolCallIconName("custom_tool", { + type: "plain_text", + label: "Background task completed", + icon: "sparkles", + text: "notification payload", + }); + + expect(icon).toBe("sparkles"); + }); + + it("does not special-case skill tool names", () => { + const icon = resolveToolCallIconName("skill", { + type: "plain_text", + label: "Skill output", + }); + + expect(icon).toBe("wrench"); + }); +}); diff --git a/packages/app/src/utils/tool-call-icon-name.ts b/packages/app/src/utils/tool-call-icon-name.ts new file mode 100644 index 000000000..28bfedb70 --- /dev/null +++ b/packages/app/src/utils/tool-call-icon-name.ts @@ -0,0 +1,45 @@ +import type { ToolCallDetail, ToolCallIconName } from "@getpaseo/protocol/agent-types"; +import { isPaseoToolName } from "@getpaseo/protocol/tool-name-normalization"; + +export type ToolCallIcon = ToolCallIconName | "paseo"; + +const TOOL_DETAIL_ICON_NAMES: Record = { + shell: "square_terminal", + read: "eye", + edit: "pencil", + write: "pencil", + search: "search", + fetch: "search", + worktree_setup: "square_terminal", + sub_agent: "bot", + plain_text: "wrench", + plan: "brain", + unknown: "wrench", +}; + +export function resolveToolCallIconName(toolName: string, detail?: ToolCallDetail): ToolCallIcon { + const lowerName = toolName.trim().toLowerCase(); + + if (detail?.type === "plain_text" && detail.icon) { + return detail.icon; + } + + // Thoughts are rendered through ToolCall with unknown detail payloads. + if (lowerName === "thinking" && (!detail || detail.type === "unknown")) { + return "brain"; + } + if (lowerName === "speak") { + return "mic_vocal"; + } + if (isPaseoToolName(lowerName)) { + return "paseo"; + } + if (lowerName === "task") { + return "bot"; + } + + if (detail) { + return TOOL_DETAIL_ICON_NAMES[detail.type]; + } + return "wrench"; +} diff --git a/packages/app/src/utils/tool-call-icon.test.ts b/packages/app/src/utils/tool-call-icon.test.ts deleted file mode 100644 index cc6588f84..000000000 --- a/packages/app/src/utils/tool-call-icon.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -const iconMocks = vi.hoisted(() => ({ - Bot: () => null, - Brain: () => null, - Eye: () => null, - MicVocal: () => null, - Pencil: () => null, - Search: () => null, - Sparkles: () => null, - SquareTerminal: () => null, - Wrench: () => null, -})); - -vi.mock("lucide-react-native", () => iconMocks); - -import { resolveToolCallIcon } from "./tool-call-icon"; - -describe("tool-call-icon", () => { - it("uses robot icon for task sub-agent details", () => { - const icon = resolveToolCallIcon("Task", { - type: "sub_agent", - subAgentType: "Explore", - description: "Inspect repository", - log: "[Read] README.md", - }); - - expect(icon).toBe(iconMocks.Bot); - }); - - it("uses robot icon for task calls without canonical detail", () => { - const icon = resolveToolCallIcon("Task", { - type: "unknown", - input: null, - output: null, - }); - - expect(icon).toBe(iconMocks.Bot); - expect(resolveToolCallIcon("Task")).toBe(iconMocks.Bot); - }); - - it("keeps thinking icon override for unknown detail", () => { - const icon = resolveToolCallIcon("thinking", { - type: "unknown", - input: null, - output: null, - }); - - expect(icon).toBe(iconMocks.Brain); - }); - - it("uses custom icon from plain_text detail", () => { - const icon = resolveToolCallIcon("custom_tool", { - type: "plain_text", - label: "Background task completed", - icon: "sparkles", - text: "notification payload", - }); - - expect(icon).toBe(iconMocks.Sparkles); - }); - - it("does not special-case skill tool names", () => { - const icon = resolveToolCallIcon("skill", { - type: "plain_text", - label: "Skill output", - }); - - expect(icon).toBe(iconMocks.Wrench); - }); -}); diff --git a/packages/app/src/utils/tool-call-icon.ts b/packages/app/src/utils/tool-call-icon.ts index 4643419c3..804b68c4c 100644 --- a/packages/app/src/utils/tool-call-icon.ts +++ b/packages/app/src/utils/tool-call-icon.ts @@ -10,27 +10,13 @@ import { SquareTerminal, Wrench, } from "lucide-react-native"; -import type { ToolCallDetail, ToolCallIconName } from "@getpaseo/protocol/agent-types"; -import { isPaseoToolName } from "@getpaseo/protocol/tool-name-normalization"; +import type { ToolCallDetail } from "@getpaseo/protocol/agent-types"; import { PaseoLogo } from "@/components/icons/paseo-logo"; +import { resolveToolCallIconName, type ToolCallIcon } from "./tool-call-icon-name"; export type ToolCallIconComponent = ComponentType<{ size?: number; color?: string }>; -const TOOL_DETAIL_ICONS: Record = { - shell: SquareTerminal, - read: Eye, - edit: Pencil, - write: Pencil, - search: Search, - fetch: Search, - worktree_setup: SquareTerminal, - sub_agent: Bot, - plain_text: Wrench, - plan: Brain, - unknown: Wrench, -}; - -const TOOL_ICON_BY_NAME: Record = { +const ICON_COMPONENTS: Record = { wrench: Wrench, square_terminal: SquareTerminal, eye: Eye, @@ -40,34 +26,16 @@ const TOOL_ICON_BY_NAME: Record = { sparkles: Sparkles, brain: Brain, mic_vocal: MicVocal, + paseo: PaseoLogo, }; +export function componentForToolCallIcon(name: ToolCallIcon): ToolCallIconComponent { + return ICON_COMPONENTS[name]; +} + export function resolveToolCallIcon( toolName: string, detail?: ToolCallDetail, ): ToolCallIconComponent { - const lowerName = toolName.trim().toLowerCase(); - - if (detail?.type === "plain_text" && detail.icon) { - return TOOL_ICON_BY_NAME[detail.icon]; - } - - // Thoughts are rendered through ToolCall with unknown detail payloads. - if (lowerName === "thinking" && (!detail || detail.type === "unknown")) { - return Brain; - } - if (lowerName === "speak") { - return MicVocal; - } - if (isPaseoToolName(lowerName)) { - return PaseoLogo; - } - if (lowerName === "task") { - return Bot; - } - - if (detail) { - return TOOL_DETAIL_ICONS[detail.type]; - } - return Wrench; + return componentForToolCallIcon(resolveToolCallIconName(toolName, detail)); } diff --git a/packages/app/src/utils/workspace-archive-navigation.test.ts b/packages/app/src/utils/workspace-archive-navigation.test.ts index a0d505f80..7787174f4 100644 --- a/packages/app/src/utils/workspace-archive-navigation.test.ts +++ b/packages/app/src/utils/workspace-archive-navigation.test.ts @@ -1,19 +1,10 @@ -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation"; import type { WorkspaceDescriptor } from "@/stores/session-store"; -import { useSessionStore } from "@/stores/session-store"; -import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect"; - -const { replaceMock } = vi.hoisted(() => ({ - replaceMock: vi.fn(), -})); - -vi.mock("expo-router", () => ({ - router: { - replace: replaceMock, - }, -})); +import { + redirectIfArchivingActiveWorkspace, + type RedirectIfArchivingActiveWorkspaceDeps, +} from "@/utils/workspace-archive-redirect"; function workspace( input: Partial & Pick, @@ -89,51 +80,60 @@ describe("buildWorkspaceArchiveRedirectRoute", () => { }); }); -describe("redirectIfArchivingActiveWorkspace", () => { - afterEach(() => { - replaceMock.mockClear(); - useSessionStore.getState().clearSession("server-1"); - }); +function createFakeRouter(workspaces: WorkspaceDescriptor[]): { + deps: RedirectIfArchivingActiveWorkspaceDeps; + routes: string[]; +} { + const routes: string[] = []; + return { + routes, + deps: { + navigateToRoute: (route) => { + routes.push(route); + }, + readWorkspaces: () => workspaces, + }, + }; +} +describe("redirectIfArchivingActiveWorkspace", () => { it("does not replace the route when archiving an inactive workspace", () => { - useSessionStore.getState().initializeSession("server-1", null as unknown as DaemonClient); - useSessionStore.getState().setWorkspaces( - "server-1", - new Map([ - ["main", workspace({ id: "main", workspaceKind: "local_checkout" })], - ["feature", workspace({ id: "feature", name: "feature" })], - ]), - ); + const { deps, routes } = createFakeRouter([ + workspace({ id: "main", workspaceKind: "local_checkout" }), + workspace({ id: "feature", name: "feature" }), + ]); + expect( - redirectIfArchivingActiveWorkspace({ - serverId: "server-1", - workspaceId: "feature", - activeWorkspaceSelection: { serverId: "server-1", workspaceId: "main" }, - }), + redirectIfArchivingActiveWorkspace( + { + serverId: "server-1", + workspaceId: "feature", + activeWorkspaceSelection: { serverId: "server-1", workspaceId: "main" }, + }, + deps, + ), ).toBe(false); - expect(replaceMock).not.toHaveBeenCalled(); + expect(routes).toEqual([]); }); it("replaces the route at action time when archiving the active workspace", () => { - useSessionStore.getState().initializeSession("server-1", null as unknown as DaemonClient); - useSessionStore.getState().setWorkspaces( - "server-1", - new Map([ - ["main", workspace({ id: "main", workspaceKind: "local_checkout" })], - ["feature", workspace({ id: "feature", name: "feature" })], - ]), - ); + const { deps, routes } = createFakeRouter([ + workspace({ id: "main", workspaceKind: "local_checkout" }), + workspace({ id: "feature", name: "feature" }), + ]); + expect( - redirectIfArchivingActiveWorkspace({ - serverId: "server-1", - workspaceId: "feature", - activeWorkspaceSelection: { serverId: "server-1", workspaceId: "feature" }, - }), + redirectIfArchivingActiveWorkspace( + { + serverId: "server-1", + workspaceId: "feature", + activeWorkspaceSelection: { serverId: "server-1", workspaceId: "feature" }, + }, + deps, + ), ).toBe(true); - expect(replaceMock).toHaveBeenCalledWith( - "/h/server-1/new?dir=%2Frepo&name=Project&projectId=project-1", - ); + expect(routes).toEqual(["/h/server-1/new?dir=%2Frepo&name=Project&projectId=project-1"]); }); }); diff --git a/packages/app/src/utils/workspace-archive-redirect.ts b/packages/app/src/utils/workspace-archive-redirect.ts new file mode 100644 index 000000000..0f042bd2d --- /dev/null +++ b/packages/app/src/utils/workspace-archive-redirect.ts @@ -0,0 +1,35 @@ +import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; +import type { WorkspaceDescriptor } from "@/stores/session-store"; +import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation"; + +export interface RedirectIfArchivingActiveWorkspaceInput { + serverId: string; + workspaceId: string; + activeWorkspaceSelection: ActiveWorkspaceSelection | null; +} + +export interface RedirectIfArchivingActiveWorkspaceDeps { + navigateToRoute: (route: string) => void; + readWorkspaces: (serverId: string) => Iterable; +} + +export function redirectIfArchivingActiveWorkspace( + input: RedirectIfArchivingActiveWorkspaceInput, + deps: RedirectIfArchivingActiveWorkspaceDeps, +): boolean { + if ( + input.activeWorkspaceSelection?.serverId !== input.serverId || + input.activeWorkspaceSelection.workspaceId !== input.workspaceId + ) { + return false; + } + + deps.navigateToRoute( + buildWorkspaceArchiveRedirectRoute({ + serverId: input.serverId, + archivedWorkspaceId: input.workspaceId, + workspaces: deps.readWorkspaces(input.serverId), + }), + ); + return true; +} diff --git a/packages/app/src/utils/workspace-navigation.test.ts b/packages/app/src/utils/workspace-navigation.test.ts index 72d4322aa..af08350ab 100644 --- a/packages/app/src/utils/workspace-navigation.test.ts +++ b/packages/app/src/utils/workspace-navigation.test.ts @@ -1,88 +1,96 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const { platformState, routerMock } = vi.hoisted(() => ({ - platformState: { - isWeb: true, - }, - routerMock: { - dismissTo: vi.fn(), - navigate: vi.fn(), - replace: vi.fn(), - }, -})); - -vi.mock("expo-router", () => ({ - router: routerMock, - useLocalSearchParams: () => ({}), - usePathname: () => "/", -})); - -vi.mock("@/constants/platform", () => ({ - get isWeb() { - return platformState.isWeb; - }, -})); - -vi.mock("@react-native-async-storage/async-storage", () => { - const storage = new Map(); - return { - default: { - getItem: vi.fn(async (key: string) => storage.get(key) ?? null), - setItem: vi.fn(async (key: string, value: string) => { - storage.set(key, value); - }), - removeItem: vi.fn(async (key: string) => { - storage.delete(key); - }), - }, - }; -}); - -import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; -import { navigateToPreparedWorkspaceTab, prepareWorkspaceTab } from "@/utils/workspace-navigation"; +import { describe, expect, it } from "vitest"; +import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; +import { navigateToPreparedWorkspaceTab, prepareWorkspaceTab } from "@/utils/prepare-workspace-tab"; const SERVER_ID = "server-1"; const WORKSPACE_ID = "/repo/worktree"; const AGENT_ID = "agent-1"; -describe("prepareWorkspaceTab", () => { - beforeEach(() => { - vi.useRealTimers(); - platformState.isWeb = true; - routerMock.dismissTo.mockReset(); - routerMock.navigate.mockReset(); - routerMock.replace.mockReset(); - useWorkspaceLayoutStore.setState({ - layoutByWorkspace: {}, - splitSizesByWorkspace: {}, - pinnedAgentIdsByWorkspace: {}, - hiddenAgentIdsByWorkspace: {}, - }); - }); +interface RecordedOpenedTab { + key: string; + target: WorkspaceTabTarget; +} +interface RecordedPin { + key: string; + agentId: string; +} + +interface RecordedNavigation { + serverId: string; + workspaceId: string; + currentPathname?: string | null; +} + +function createFakeLayout() { + const openedTabs: RecordedOpenedTab[] = []; + const pinnedAgents: RecordedPin[] = []; + return { + openedTabs, + pinnedAgents, + openTabFocused: (key: string, target: WorkspaceTabTarget) => { + openedTabs.push({ key, target }); + return target.kind === "agent" ? target.agentId : null; + }, + pinAgent: (key: string, agentId: string) => { + pinnedAgents.push({ key, agentId }); + }, + }; +} + +function createFakeNavigator() { + const navigations: RecordedNavigation[] = []; + return { + navigations, + navigateToWorkspace: ( + serverId: string, + workspaceId: string, + options: { currentPathname?: string | null }, + ) => { + navigations.push({ serverId, workspaceId, currentPathname: options.currentPathname }); + }, + }; +} + +describe("prepareWorkspaceTab", () => { it("opens and focuses an agent tab", () => { - const route = prepareWorkspaceTab({ - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { kind: "agent", agentId: AGENT_ID }, - }); + const layout = createFakeLayout(); + + const route = prepareWorkspaceTab( + { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + target: { kind: "agent", agentId: AGENT_ID }, + }, + layout, + ); expect(route).toBe("/h/server-1/workspace/b64_L3JlcG8vd29ya3RyZWU"); - const key = "server-1:/repo/worktree"; - expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(key)).toHaveLength(1); + expect(layout.openedTabs).toEqual([ + { key: "server-1:/repo/worktree", target: { kind: "agent", agentId: AGENT_ID } }, + ]); + expect(layout.pinnedAgents).toEqual([]); }); it("prepares a tab and navigates through the workspace navigation helper", () => { - const route = navigateToPreparedWorkspaceTab({ - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { kind: "agent", agentId: AGENT_ID }, - }); + const layout = createFakeLayout(); + const navigator = createFakeNavigator(); + + const route = navigateToPreparedWorkspaceTab( + { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + target: { kind: "agent", agentId: AGENT_ID }, + }, + { ...layout, navigateToWorkspace: navigator.navigateToWorkspace }, + ); expect(route).toBe("/h/server-1/workspace/b64_L3JlcG8vd29ya3RyZWU"); - expect(routerMock.dismissTo).toHaveBeenCalledWith( - "/h/server-1/workspace/b64_L3JlcG8vd29ya3RyZWU", - ); - expect(routerMock.replace).not.toHaveBeenCalled(); + expect(layout.openedTabs).toEqual([ + { key: "server-1:/repo/worktree", target: { kind: "agent", agentId: AGENT_ID } }, + ]); + expect(navigator.navigations).toEqual([ + { serverId: SERVER_ID, workspaceId: WORKSPACE_ID, currentPathname: undefined }, + ]); }); }); diff --git a/packages/app/src/utils/workspace-navigation.ts b/packages/app/src/utils/workspace-navigation.ts index 401e96e3b..514929c6d 100644 --- a/packages/app/src/utils/workspace-navigation.ts +++ b/packages/app/src/utils/workspace-navigation.ts @@ -1,51 +1,32 @@ -import { navigateToWorkspace } from "@/hooks/use-workspace-navigation"; +import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; -import { generateDraftId } from "@/stores/draft-keys"; import { - buildWorkspaceTabPersistenceKey, - type WorkspaceTabTarget, -} from "@/stores/workspace-tabs-store"; -import { buildHostWorkspaceRoute } from "@/utils/host-routes"; + prepareWorkspaceTab as prepareWorkspaceTabPure, + navigateToPreparedWorkspaceTab as navigateToPreparedWorkspaceTabPure, + type PrepareWorkspaceTabInput, + type NavigateToPreparedWorkspaceTabInput, +} from "./prepare-workspace-tab"; -interface PrepareWorkspaceTabInput { - serverId: string; - workspaceId: string; - target: WorkspaceTabTarget; - pin?: boolean; +export type { + PrepareWorkspaceTabInput, + NavigateToPreparedWorkspaceTabInput, +} from "./prepare-workspace-tab"; + +function layoutStoreDeps() { + const store = useWorkspaceLayoutStore.getState(); + return { + openTabFocused: store.openTabFocused, + pinAgent: store.pinAgent, + }; } -interface NavigateToPreparedWorkspaceTabInput extends PrepareWorkspaceTabInput { - currentPathname?: string | null; -} - -function getPreparedTarget(target: WorkspaceTabTarget): WorkspaceTabTarget { - if (target.kind !== "draft" || target.draftId.trim() !== "new") { - return target; - } - return { kind: "draft", draftId: generateDraftId() }; -} - -export function prepareWorkspaceTab(input: PrepareWorkspaceTabInput) { - const target = getPreparedTarget(input.target); - const key = - buildWorkspaceTabPersistenceKey({ - serverId: input.serverId, - workspaceId: input.workspaceId, - }) ?? ""; - - useWorkspaceLayoutStore.getState().openTabFocused(key, target); - - if (input.pin && target.kind === "agent") { - useWorkspaceLayoutStore.getState().pinAgent(key, target.agentId); - } - - return buildHostWorkspaceRoute(input.serverId, input.workspaceId); +export function prepareWorkspaceTab(input: PrepareWorkspaceTabInput): string { + return prepareWorkspaceTabPure(input, layoutStoreDeps()); } export function navigateToPreparedWorkspaceTab(input: NavigateToPreparedWorkspaceTabInput): string { - const route = prepareWorkspaceTab(input); - navigateToWorkspace(input.serverId, input.workspaceId, { - currentPathname: input.currentPathname, + return navigateToPreparedWorkspaceTabPure(input, { + ...layoutStoreDeps(), + navigateToWorkspace, }); - return route; }