Improve app tests (#1197)

* 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 <Redirect> 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
This commit is contained in:
Mohamed Boudra
2026-05-28 12:34:31 +08:00
committed by GitHub
parent 5cede0a7bb
commit 3ac182cffc
195 changed files with 13063 additions and 11542 deletions
+9
View File
@@ -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.
-149
View File
@@ -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(<Index />);
});
}
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");
});
});
@@ -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<AttachmentFileInfo>;
makeDirectory(uri: string, options: { intermediates: boolean }): Promise<void>;
writeBytes(uri: string, bytes: Uint8Array): Promise<void>;
copy(input: { from: string; to: string }): Promise<void>;
readAsBase64(uri: string): Promise<string>;
delete(uri: string, options: { idempotent: boolean }): Promise<void>;
listDirectory(uri: string): Promise<string[]>;
}
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);
},
};
}
@@ -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);
});
});
@@ -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<void> {
const info = await FileSystem.getInfoAsync(uri);
async function ensureDirectory(fileSystem: AttachmentFileSystem, uri: string): Promise<void> {
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<Uint8Array> {
@@ -55,6 +54,7 @@ async function blobToBytes(blob: Blob): Promise<Uint8Array> {
}
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<AttachmentStorageType, "desktop-file" | "native-file">;
baseDirectoryName: string;
fileSystem: AttachmentFileSystem;
resolvePreviewUrl: (attachment: AttachmentMetadata) => Promise<string>;
releasePreviewUrl?: (input: { attachment: AttachmentMetadata; url: string }) => Promise<void>;
}): 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<AttachmentMetadata> {
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<string> {
const uri = attachmentUri(attachment);
return await FileSystem.readAsStringAsync(uri, {
encoding: FileSystem.EncodingType.Base64,
});
return await fileSystem.readAsBase64(attachmentUri(attachment));
},
async resolvePreviewUrl({ attachment }): Promise<string> {
@@ -184,22 +181,22 @@ export function createLocalFileAttachmentStore(params: {
: {}),
async delete({ attachment }): Promise<void> {
await FileSystem.deleteAsync(attachmentUri(attachment), { idempotent: true });
await fileSystem.delete(attachmentUri(attachment), { idempotent: true });
},
async garbageCollect({ referencedIds }): Promise<void> {
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,
});
}),
@@ -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;
+3 -1
View File
@@ -9,7 +9,9 @@ async function createAttachmentStore(): Promise<AttachmentStore> {
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");
@@ -0,0 +1,95 @@
import type { AttachmentFileInfo, AttachmentFileSystem } from "./attachment-file-system";
export interface TestAttachmentFileSystem extends AttachmentFileSystem {
readonly files: ReadonlyMap<string, Uint8Array>;
readonly directories: ReadonlySet<string>;
setFile(uri: string, bytes: Uint8Array): void;
setDirectory(uri: string): void;
}
export function createTestAttachmentFileSystem(options?: {
cacheDirectory?: string | null;
}): TestAttachmentFileSystem {
const files = new Map<string, Uint8Array>();
const directories = new Set<string>();
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");
}
@@ -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<string>(), {
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"] });
});
});
@@ -0,0 +1,19 @@
export interface DragState<T> {
activeId: string | null;
dragItems: T[] | null;
}
export type DragAction<T> = { type: "start"; id: string; data: T[] } | { type: "clear" };
export function dragStateInitial<T>(): DragState<T> {
return { activeId: null, dragItems: null };
}
export function dragStateReducer<T>(state: DragState<T>, action: DragAction<T>): DragState<T> {
switch (action.type) {
case "start":
return { activeId: action.id, dragItems: action.data };
case "clear":
return { activeId: null, dragItems: null };
}
}
@@ -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";
@@ -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 });
});
});
@@ -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 };
}
@@ -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();
});
});
@@ -0,0 +1,24 @@
import { arrayMove } from "@dnd-kit/sortable";
export interface DragEndInput<T> {
items: T[];
activeId: string;
overId: string | null | undefined;
keyExtractor: (item: T, index: number) => string;
}
export function reorderItemsOnDragEnd<T>({
items,
activeId,
overId,
keyExtractor,
}: DragEndInput<T>): 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);
}
@@ -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<T> {
activeId: string | null;
items: T[];
handlers: DragReorderHandlers;
}
export function useDragReorderState<T>({
data,
keyExtractor,
onDragEnd,
onDragBegin,
disabled = false,
}: {
data: T[];
keyExtractor: (item: T, index: number) => string;
onDragEnd?: (items: T[]) => void;
onDragBegin?: () => void;
disabled?: boolean;
}): DragReorderState<T> {
const [state, dispatch] = useReducer(dragStateReducer<T>, undefined, dragStateInitial<T>);
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,
},
};
}
@@ -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<DndContextProps>) => {
latestDndContextProps = props;
return <div>{children}</div>;
},
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: <T,>(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 (
<div data-active={String(isActive)} data-testid={`item-${item}`}>
{item}
</div>
);
}
function renderList({ useDragHandle = false }: { useDragHandle?: boolean } = {}): void {
act(() => {
root?.render(
<DraggableList
data={DATA}
keyExtractor={keyExtractor}
onDragEnd={vi.fn()}
renderItem={renderItem}
scrollEnabled={false}
useDragHandle={useDragHandle}
/>,
);
});
}
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");
});
});
@@ -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<T> {
id: string;
@@ -137,17 +138,21 @@ export function DraggableList<T>({
onDragBegin,
nestable: _nestable = false,
}: DraggableListProps<T>) {
const [activeId, setActiveId] = useState<string | null>(null);
const [dragItems, setDragItems] = useState<T[] | null>(null);
const items = dragItems ?? data;
const { activeId, items, handlers } = useDragReorderState({
data,
keyExtractor,
onDragEnd,
onDragBegin,
});
const showCustomScrollbar = enableDesktopWebScrollbar && scrollEnabled;
const scrollViewRef = useRef<ScrollView>(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<T>({
}),
);
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<T>({
sensors={sensors}
collisionDetection={closestCenter}
modifiers={DND_MODIFIERS}
onDragStart={handleDragStart}
onDragCancel={clearDragState}
onDragEnd={handleDragEnd}
onDragStart={handlers.onDragStart}
onDragCancel={handlers.onDragCancel}
onDragEnd={handlers.onDragEnd}
>
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
{items.map((item, index) => {
@@ -255,9 +227,9 @@ export function DraggableList<T>({
sensors={sensors}
collisionDetection={closestCenter}
modifiers={DND_MODIFIERS}
onDragStart={handleDragStart}
onDragCancel={clearDragState}
onDragEnd={handleDragEnd}
onDragStart={handlers.onDragStart}
onDragCancel={handlers.onDragCancel}
onDragEnd={handlers.onDragEnd}
>
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
{items.map((item, index) => {
@@ -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> = {},
): 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<Omit<SessionsQueryResult, "data">>,
): 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<string, string>(),
};
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.");
});
});
@@ -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<string> = 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<string, string> {
const map = new Map<string, string>();
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<SessionsQueryResult>,
): FetchRecentProviderSessionEntry[] {
const seen = new Set<string>();
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<SessionsQueryResult>,
): number {
let total = 0;
for (const query of queries) {
total += query.data?.filteredAlreadyImportedCount ?? 0;
}
return total;
}
export function collectErroredProviderLabels(
providersToFetch: AgentProvider[] | null,
queries: ReadonlyArray<SessionsQueryResult>,
providerLabelById: ReadonlyMap<string, string>,
): 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<string, string>;
}
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." };
}
@@ -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<string> = 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<RecentSessionsResponse>;
}
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<string, string> {
const map = new Map<string, string>();
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<string | null>;
@@ -110,66 +83,6 @@ function buildSessionsQueriesConfig(args: {
}));
}
function aggregateSessionEntries(
queries: ReadonlyArray<SessionsQueryResult>,
): FetchRecentProviderSessionEntry[] {
const seen = new Set<string>();
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<SessionsQueryResult>): number {
let total = 0;
for (const query of queries) {
total += query.data?.filteredAlreadyImportedCount ?? 0;
}
return total;
}
function collectErroredProviderLabels(
providersToFetch: AgentProvider[] | null,
queries: ReadonlyArray<SessionsQueryResult>,
providerLabelById: ReadonlyMap<string, string>,
): 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<string, string>;
}
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(
+2 -1
View File
@@ -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) {
@@ -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" });
});
});
@@ -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<BuiltinProviderIconName> = new Set([
"claude",
"codex",
"copilot",
"kiro",
"opencode",
"pi",
]);
const CATALOG_ICON_PROVIDER_IDS: ReadonlySet<string> = 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" };
}
@@ -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);
});
});
+15 -9
View File
@@ -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<ProviderIconProps>;
const PROVIDER_ICONS: Record<string, ProviderIconComponent> = {
const BUILTIN_PROVIDER_ICONS: Record<BuiltinProviderIconName, ProviderIconComponent> = {
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;
}
@@ -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";
@@ -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<DndContextProps>) => {
latestDndContextProps = props;
return <div>{children}</div>;
},
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: <T,>(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 (
<div data-active={String(isActive)} data-testid={`item-${item}`}>
{item}
</div>
);
}
function renderList(): void {
act(() => {
root?.render(
<SortableInlineList
data={DATA}
keyExtractor={keyExtractor}
onDragEnd={vi.fn()}
renderItem={renderItem}
/>,
);
});
}
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");
});
});
@@ -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<T>({
activeId?: string | null;
getItemData?: (item: T, index: number) => Record<string, unknown>;
}): ReactElement {
const [activeId, setActiveId] = useState<string | null>(null);
const [dragItems, setDragItems] = useState<T[] | null>(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<T>({
}),
);
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<T>({
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<T>({
sensors={sensors}
collisionDetection={closestCenter}
modifiers={DND_MODIFIERS}
onDragStart={handleDragStart}
onDragCancel={clearDragState}
onDragEnd={handleDragEnd}
onDragStart={handlers.onDragStart}
onDragCancel={handlers.onDragCancel}
onDragEnd={handlers.onDragEnd}
>
{renderedItems}
</DndContext>
@@ -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 });
@@ -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<string, unknown>, ref: React.ForwardedRef<unknown>) => {
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 (
<IsolatedBottomSheetModal
ref={sheetRef}
index={0}
snapPoints={SNAP_POINTS_50}
onChange={handleSheetChange}
onDismiss={handleSheetDismiss}
>
<div>Sheet content</div>
</IsolatedBottomSheetModal>
);
}
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(<Harness visible={false} onClose={onClose} />);
expect(getByTestId("app-portal").getAttribute("data-host")).toBe("root");
expect(modalProps).toHaveBeenLastCalledWith(
expect.objectContaining({
enableDismissOnClose: true,
stackBehavior: "replace",
}),
);
expect(modalMethods.present).not.toHaveBeenCalled();
rerender(<Harness visible onClose={onClose} />);
expect(modalMethods.present).toHaveBeenCalledTimes(1);
rerender(<Harness visible={false} onClose={onClose} />);
expect(modalMethods.dismiss).toHaveBeenCalledTimes(1);
expect(modalMethods.close).not.toHaveBeenCalled();
rerender(<Harness visible onClose={onClose} />);
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(<Harness visible onClose={onClose} />);
latestModalProps().onChange?.(-1);
expect(onClose).toHaveBeenCalledTimes(1);
rerender(<Harness visible={false} onClose={onClose} />);
latestModalProps().onChange?.(-1);
expect(onClose).toHaveBeenCalledTimes(1);
});
it("reports a dismiss while visible as a close request", () => {
const onClose = vi.fn();
render(<Harness visible onClose={onClose} />);
latestModalProps().onDismiss?.();
expect(onClose).toHaveBeenCalledTimes(1);
});
it("deduplicates close notifications from change and dismiss callbacks", () => {
const onClose = vi.fn();
render(<Harness visible onClose={onClose} />);
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(<Harness visible onClose={onClose} />);
expect(modalMethods.present).not.toHaveBeenCalled();
expect(modalMethods.snapToIndex).not.toHaveBeenCalled();
shouldExposeModalRef.current = true;
rerender(<Harness visible onClose={onClose} />);
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(
<IsolatedBottomSheetModal index={0} snapPoints={SNAP_POINTS_90}>
<IsolatedBottomSheetModal index={0} snapPoints={SNAP_POINTS_60} onChange={noop}>
<div>Nested model picker</div>
</IsolatedBottomSheetModal>
</IsolatedBottomSheetModal>,
);
expect(getAllByTestId("bottom-sheet-provider")).toHaveLength(1);
expect(modalProps).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
stackBehavior: "replace",
}),
);
expect(modalProps).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
stackBehavior: "push",
}),
);
});
});
@@ -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<typeof GorhomBottomSheetModal>;
@@ -68,88 +72,31 @@ export function useIsolatedBottomSheetVisibility({
isEnabled?: boolean;
onClose: () => void;
}) {
const sheetRef = useRef<IsolatedBottomSheetModalRef | null>(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,
@@ -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);
});
});
@@ -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;
},
};
}
@@ -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<DesktopAttachmentFileResult>;
writeBase64(input: {
attachmentId: string;
base64: string;
extension?: string | null;
}): Promise<DesktopAttachmentFileResult>;
writeBytes(input: {
attachmentId: string;
bytes: Uint8Array;
extension?: string | null;
}): Promise<DesktopAttachmentFileResult>;
deleteFile(input: { path: string }): Promise<boolean>;
garbageCollect(input: { referencedIds: readonly string[] }): Promise<number>;
readFileBase64(path: string): Promise<string>;
resolvePreviewUrl(attachment: AttachmentMetadata): Promise<string>;
releasePreviewUrl(input: { url: string }): Promise<void>;
}
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),
};
}
@@ -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"] }]);
});
});
@@ -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<string, string> = {
"image/png": ".png",
@@ -77,12 +66,15 @@ function toDesktopMetadata(input: {
};
}
async function saveDesktopAttachmentFromFileUri(input: {
id: string;
uri: string;
mimeType?: string;
fileName?: string | null;
}): Promise<AttachmentMetadata> {
async function saveDesktopAttachmentFromFileUri(
bridge: DesktopAttachmentBridge,
input: {
id: string;
uri: string;
mimeType?: string;
fileName?: string | null;
},
): Promise<AttachmentMetadata> {
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<AttachmentMetadata> {
async function saveDesktopAttachmentFromBase64(
bridge: DesktopAttachmentBridge,
input: {
id: string;
base64: string;
mimeType: string;
fileName?: string | null;
},
): Promise<AttachmentMetadata> {
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<AttachmentMetadata> {
async function saveDesktopAttachmentFromBytes(
bridge: DesktopAttachmentBridge,
input: {
id: string;
bytes: Uint8Array;
mimeType: string;
fileName?: string | null;
},
): Promise<AttachmentMetadata> {
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<string> {
assertDesktopAttachment(attachment);
return await readDesktopFileBase64(attachment.storageKey);
return await bridge.readFileBase64(attachment.storageKey);
},
async resolvePreviewUrl({ attachment }): Promise<string> {
assertDesktopAttachment(attachment);
return await resolveDesktopPreviewUrl(attachment);
return await bridge.resolvePreviewUrl(attachment);
},
async releasePreviewUrl({ attachment, url }): Promise<void> {
assertDesktopAttachment(attachment);
await releaseDesktopPreviewUrl({ url });
await bridge.releasePreviewUrl({ url });
},
async delete({ attachment }): Promise<void> {
assertDesktopAttachment(attachment);
await deleteDesktopAttachmentFile({ path: attachment.storageKey });
await bridge.deleteFile({ path: attachment.storageKey });
},
async garbageCollect({ referencedIds }): Promise<void> {
await garbageCollectDesktopAttachmentFiles({
await bridge.garbageCollect({
referencedIds: Array.from(referencedIds),
});
},
@@ -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<string, string>) {}
async readFileBase64(storageKey: string): Promise<string> {
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> = {}): 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]);
});
});
@@ -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<string>;
}
const activeObjectUrls = new Set<string>();
export interface ObjectUrlMinter {
tryCreate(input: { mimeType: string; base64: string }): string | null;
revoke(url: string): void;
}
export interface DesktopPreviewUrlResolver {
resolve(attachment: AttachmentMetadata): Promise<string>;
release(input: { url: string }): Promise<void>;
}
export async function readDesktopFileBase64(pathOrUri: string): Promise<string> {
return await invokeDesktopCommand<string>("read_file_base64", {
@@ -15,36 +23,55 @@ export async function readDesktopFileBase64(pathOrUri: string): Promise<string>
});
}
export async function resolveDesktopPreviewUrl(attachment: AttachmentMetadata): Promise<string> {
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<void> {
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<string>();
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"));
}
@@ -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,
};
}
@@ -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<string>>();
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<string>((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<string>((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);
});
});
@@ -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;
@@ -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<string>;
listenToEvents(handler: (event: LocalDaemonTransportEvent) => void): Promise<() => void>;
sendMessage(input: { sessionId: string; text?: string; binaryBase64?: string }): Promise<void>;
closeSession(sessionId: string): Promise<void>;
}
export const defaultLocalDaemonTransportRpc: LocalDaemonTransportRpc = {
openSession: openLocalTransportSession,
listenToEvents: listenToLocalTransportEvents,
sendMessage: sendLocalTransportMessage,
closeSession: closeLocalTransportSession,
};
@@ -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<string>((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);
},
};
}
+28 -53
View File
@@ -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<unknown>>(async () => 12_000),
}));
function fakeInvoker(result: () => Promise<unknown>): DesktopIpcInvoker {
return <T>() => result() as Promise<T>;
}
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 <T>(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);
});
+6 -4
View File
@@ -1,14 +1,16 @@
import { invokeDesktopCommand } from "@/desktop/electron/invoke";
const DESKTOP_SYSTEM_IDLE_COMMAND = "desktop_get_system_idle_time";
export type DesktopIpcInvoker = <T>(command: string) => Promise<T>;
function isValidIdleTimeMs(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value) && value >= 0;
}
export async function getDesktopSystemIdleTimeMs(): Promise<number | null> {
export async function readDesktopSystemIdleTimeMs(
invoke: DesktopIpcInvoker,
): Promise<number | null> {
try {
const idleTimeMs = await invokeDesktopCommand<unknown>(DESKTOP_SYSTEM_IDLE_COMMAND);
const idleTimeMs = await invoke<unknown>(DESKTOP_SYSTEM_IDLE_COMMAND);
if (!isValidIdleTimeMs(idleTimeMs)) {
console.warn("[DesktopIdle] Invalid system idle time", idleTimeMs);
return null;
@@ -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");
});
@@ -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<string>;
}
@@ -35,7 +35,7 @@ interface MediaStreamLike {
getTracks?: () => MediaStreamTrackLike[];
}
interface NavigatorLike {
export interface NavigatorLike {
mediaDevices?: {
getUserMedia?: (constraints: { audio: boolean }) => Promise<MediaStreamLike>;
};
@@ -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<DesktopPermissionSnapshot>;
requestDesktopPermission: (input: {
kind: DesktopPermissionKind;
}) => Promise<DesktopPermissionStatus>;
}
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<DesktopPermissionStatus> {
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<DesktopPermissionStatus> {
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<DesktopPermissionStatus> {
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<DesktopPermissionStatus> {
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<DesktopPermissionStatus> {
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<DesktopPermissionStatus> {
if (input.kind === "notifications") {
return await requestNotificationPermissionStatus();
async function getMicrophonePermissionStatus(): Promise<DesktopPermissionStatus> {
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<DesktopPermissionSnapshot> {
const [notifications, microphone] = await Promise.all([
getNotificationPermissionStatus(),
getMicrophonePermissionStatus(),
]);
async function requestNotificationPermissionStatus(): Promise<DesktopPermissionStatus> {
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<DesktopPermissionStatus> {
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<DesktopPermissionStatus> {
if (input.kind === "notifications") {
return await requestNotificationPermissionStatus();
}
return await requestMicrophonePermissionStatus();
}
async function getDesktopPermissionSnapshot(): Promise<DesktopPermissionSnapshot> {
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;
@@ -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");
});
});
@@ -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<DesktopAppUpdateCheckResult>;
installDesktopAppUpdate(input: {
releaseChannel: DesktopReleaseChannel;
}): Promise<DesktopAppUpdateInstallResult>;
}
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<DesktopAppUpdateCheckResult | null>;
installUpdate(options: {
releaseChannel: DesktopReleaseChannel;
}): Promise<DesktopAppUpdateInstallResult | null>;
}
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<DesktopAppUpdateCheckResult | null> {
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<DesktopAppUpdateInstallResult | null> {
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,
};
}
@@ -0,0 +1,107 @@
import { describe, expect, it } from "vitest";
import {
resolveUpdateCalloutDescriptor,
type ResolveUpdateCalloutInput,
} from "./resolve-update-callout";
function input(overrides: Partial<ResolveUpdateCalloutInput> = {}): 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");
});
});
@@ -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",
};
}
@@ -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<DesktopAppUpdateCheckResult> };
type InstallOutcome =
| { kind: "result"; result: DesktopAppUpdateInstallResult }
| { kind: "error"; error: unknown };
function buildCheckResult(
overrides: Partial<DesktopAppUpdateCheckResult> = {},
): DesktopAppUpdateCheckResult {
return {
hasUpdate: false,
readyToInstall: false,
currentVersion: null,
latestVersion: null,
body: null,
date: null,
...overrides,
};
}
function buildInstallResult(
overrides: Partial<DesktopAppUpdateInstallResult> = {},
): 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<DesktopAppUpdateCheckResult>((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> = {},
): DesktopAppUpdateCheckResult {
return buildCheckResult(overrides);
}
export function buildFakeInstallResult(
overrides: Partial<DesktopAppUpdateInstallResult> = {},
): DesktopAppUpdateInstallResult {
return buildInstallResult(overrides);
}
@@ -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<string, string>(),
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<string, unknown>) => React.createElement("span", props);
const Gift = (props: Record<string, unknown>) =>
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 (
<SidebarCalloutProvider>
<UpdateCalloutSource />
{withSlot ? <SidebarCalloutSlot /> : null}
</SidebarCalloutProvider>
);
}
async function renderHarness(root: Root, withSlot = true): Promise<void> {
await act(async () => {
root.render(<UpdateCalloutHarness withSlot={withSlot} />);
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(<UpdateCalloutHarness />);
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"]),
);
});
});
@@ -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 <UpdateAvailableDescription versionLabel={body.versionLabel ?? undefined} />;
}
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 (
<UpdateAvailableDescription versionLabel={`v${args.latestVersion.replace(/^v/i, "")}`} />
);
}
return <UpdateAvailableDescription />;
}
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 ? (
<Gift size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
) : 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,
@@ -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",
});
});
});
@@ -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<DesktopAppUpdateInstallResult | null>;
}
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<DesktopAppUpdateStatus>("idle");
const [availableUpdate, setAvailableUpdate] = useState<DesktopAppUpdateCheckResult | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [installMessage, setInstallMessage] = useState<string | null>(null);
const [lastCheckedAt, setLastCheckedAt] = useState<number | null>(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,
};
@@ -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> = {}): 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,
);
});
});
@@ -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<CheckoutStatusPayload>;
}
export async function peekOrFetchCheckoutStatus({
queryClient,
client,
serverId,
cwd,
}: {
queryClient: QueryClient;
client: CheckoutStatusClient;
serverId: string;
cwd: string;
}): Promise<CheckoutStatusPayload> {
const queryKey = checkoutStatusQueryKey(serverId, cwd);
const cached = queryClient.getQueryData<CheckoutStatusPayload>(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);
}
@@ -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> = {}): 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<typeof useCheckoutStatusQuery> | 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<void> {
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<void> {
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("<!doctype html><html><body><div id='root'></div></body></html>", {
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,
);
});
});
+11 -35
View File
@@ -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<CheckoutStatusPayload>;
}
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<CheckoutStatusPayload> {
const queryKey = checkoutStatusQueryKey(serverId, cwd);
const cached = queryClient.getQueryData<CheckoutStatusPayload>(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,
@@ -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<ClientActivityTrackerInput> & {
client?: FakeHeartbeatClient;
clock?: ReturnType<typeof createTestClock>;
} = {},
): {
tracker: ClientActivityTracker;
client: FakeHeartbeatClient;
clock: ReturnType<typeof createTestClock>;
} {
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);
});
});
@@ -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,
};
}
@@ -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);
});
});
@@ -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;
},
};
}
@@ -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);
});
});
@@ -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;
}
@@ -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",
},
]);
});
});
@@ -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<string> {
const context = ImageManipulator.manipulate(uri);
@@ -102,28 +31,9 @@ async function exportPickedImageAsPng(uri: string): Promise<string> {
export async function normalizePickedImageAssets(
assets: readonly ExpoImagePickerAssetLike[],
): Promise<PickedImageAttachmentInput[]> {
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<string[]> {
export async function openImagePathsWithDesktopDialog(_dialog?: unknown): Promise<string[]> {
throw new Error("Desktop dialog API is not available on native.");
}
@@ -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.",
);
});
@@ -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<string[]> {
const desktop = getDesktopHost();
export async function openImagePathsWithDesktopDialog(
dialog: DesktopDialogBridge | null | undefined,
): Promise<string[]> {
const options = {
directory: false,
multiple: true,
@@ -88,7 +89,7 @@ export async function openImagePathsWithDesktopDialog(): Promise<string[]> {
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.");
}
+43
View File
@@ -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<DaemonClient, "openProject"> | null;
mergeWorkspaces: (serverId: string, workspaces: Iterable<WorkspaceDescriptor>) => 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<boolean> {
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;
}
@@ -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"]);
});
});
@@ -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<string>;
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<PickedImageAttachmentInput[]> {
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),
};
}),
);
}
@@ -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 });
});
});
@@ -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<T>(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<string, T>();
for (const item of input.items) {
itemByKey.set(input.getKey(item), item);
}
const prunedOrder: string[] = [];
const seen = new Set<string>();
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 };
}
@@ -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<ReturnType<ListCommands>>;
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<typeof useAgentCommandsQuery>[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 }]);
});
});
@@ -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<string, unknown>;
}
function commandsQueryKey(serverId: string, agentId: string, draftConfig?: DraftCommandConfig) {
export type AgentCommandsClient = Pick<DaemonClient, "listCommands">;
export async function fetchAgentCommands(input: {
client: AgentCommandsClient;
agentId: string;
draftConfig?: DraftCommandConfig;
}): Promise<AgentSlashCommand[]> {
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,
@@ -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<ReturnType<FetchAgentHistory>>;
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"));
});
});
@@ -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<ReturnType<DaemonClient["fetchAgentHistory"]>> {
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);
});
});
});
+5 -7
View File
@@ -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<DaemonClient, "fetchAgentHistory">;
export async function fetchAgentHistoryPage(input: {
client: AgentHistoryClient;
serverId: string;
cursor: string | null;
}): Promise<AgentHistoryPage> {
@@ -147,7 +149,3 @@ export function useAgentHistory(options: {
loadMore,
};
}
export const __private__ = {
fetchAgentHistoryPage,
};
@@ -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<typeof vi.fn>;
refreshAgent: ReturnType<typeof vi.fn>;
}
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);
@@ -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<DaemonClient, "fetchAgentTimeline"> | null;
setAgentInitializing: SetAgentInitializing;
}
export function ensureAgentIsInitialized(input: EnsureAgentIsInitializedInput): Promise<void> {
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<DaemonClient, "refreshAgent" | "fetchAgentTimeline"> | null;
setAgentInitializing: SetAgentInitializing;
}
export async function refreshAgent(input: RefreshAgentInput): Promise<void> {
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<typeof useSessionStore.getState>["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<void> => {
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<void> =>
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<void> => refreshAgent({ agentId, client, setAgentInitializing }),
[client, setAgentInitializing],
);
return { ensureAgentIsInitialized, refreshAgent };
return {
ensureAgentIsInitialized: ensureAgentIsInitializedCallback,
refreshAgent: refreshAgentCallback,
};
}
@@ -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> = {}): 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 });
+40 -47
View File
@@ -11,12 +11,7 @@ export interface ArchiveAgentInput {
agentId: string;
}
export interface ArchivedAgentCloseResult {
agentId: string;
archivedAt: string;
}
type ArchiveAgentPendingState = Record<string, true>;
export type ArchiveAgentPendingState = Record<string, true>;
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<typeof getStoredAgentSnapshot>;
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<ArchiveAgentPendingState>(ARCHIVE_AGENT_PENDING_QUERY_KEY) ?? {};
}
function selectPendingArchiveAgentIds(
export function selectPendingArchiveAgentIds(
pendingState: ArchiveAgentPendingState,
serverId: string,
): ReadonlySet<string> {
@@ -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<T extends AgentsListQueryData | undefined>(
export function removeAgentFromListPayload<T extends AgentsListQueryData | undefined>(
payload: T,
agentId: string,
): T {
@@ -150,7 +134,10 @@ function removeAgentFromListPayload<T extends AgentsListQueryData | undefined>(
} 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<T extends AgentHistoryQueryData | undefined>(
export function markAgentArchivedInHistoryPayload<T extends AgentHistoryQueryData | undefined>(
payload: T,
input: ArchiveAgentInput & { archivedAt: string },
): T {
@@ -204,7 +191,7 @@ function markAgentArchivedInHistoryPayload<T extends AgentHistoryQueryData | und
return changed ? ({ ...payload, pages } as T) : payload;
}
function markAgentArchivedInHistoryCache(
export function markAgentArchivedInHistoryCache(
queryClient: QueryClient,
input: ArchiveAgentInput & { archivedAt: string },
): void {
@@ -214,6 +201,29 @@ function markAgentArchivedInHistoryCache(
);
}
export function clearArchiveAgentPending(input: IsAgentArchivingInput): void {
setAgentArchiving({
...input,
isArchiving: false,
});
}
export interface ArchivedAgentCloseResult {
agentId: string;
archivedAt: string;
}
interface ArchivedAgentListCacheSnapshot {
sidebarAgentsList: AgentsListQueryData | undefined;
allAgents: AgentsListQueryData | undefined;
agentHistory: AgentHistoryQueryData | undefined;
}
interface ArchiveAgentMutationContext {
agent: ReturnType<typeof getStoredAgentSnapshot>;
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,
};
@@ -1,74 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const asyncStorageMock = vi.hoisted(() => ({
getItem: vi.fn<(_: string) => Promise<string | null>>(),
setItem: vi.fn<(_: string, __: string) => Promise<void>>(),
}));
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();
});
});
@@ -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<boolean | null> {
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<ChangesPreferences> {
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<ChangesPreferences>) => Promise<void>;
}
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<ChangesPreferences>) => {
const prev =
queryClient.getQueryData<ChangesPreferences>(CHANGES_PREFERENCES_QUERY_KEY) ??
DEFAULT_CHANGES_PREFERENCES;
const next = { ...prev, ...updates };
queryClient.setQueryData<ChangesPreferences>(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,
};
}
@@ -0,0 +1,20 @@
import type { KeyValueStorage } from "./storage";
export interface InMemoryKeyValueStorage extends KeyValueStorage {
readonly entries: Map<string, string>;
}
export function createInMemoryKeyValueStorage(
initial: Record<string, string> = {},
): InMemoryKeyValueStorage {
const entries = new Map<string, string>(Object.entries(initial));
return {
entries,
async getItem(key) {
return entries.get(key) ?? null;
},
async setItem(key, value) {
entries.set(key, value);
},
};
}
@@ -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<ChangesPreferences> {
return loadChangesPreferencesFromStoragePure(productionStorage);
}
export interface UseChangesPreferencesReturn {
preferences: ChangesPreferences;
isLoading: boolean;
updatePreferences: (updates: Partial<ChangesPreferences>) => Promise<void>;
}
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<ChangesPreferences>) => {
await saveChangesPreferencesPure({
queryClient,
updates,
storage: productionStorage,
});
},
[queryClient],
);
return {
preferences: data ?? DEFAULT_CHANGES_PREFERENCES,
isLoading: isPending,
updatePreferences,
};
}
@@ -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));
});
});
@@ -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<string | null>;
setItem(key: string, value: string): Promise<void>;
}
async function loadLegacyWrapLinesPreference(storage: KeyValueStorage): Promise<boolean | null> {
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<ChangesPreferences> {
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<ChangesPreferences>;
storage: KeyValueStorage;
}): Promise<void> {
const prev =
input.queryClient.getQueryData<ChangesPreferences>(CHANGES_PREFERENCES_QUERY_KEY) ??
DEFAULT_CHANGES_PREFERENCES;
const next = { ...prev, ...input.updates };
input.queryClient.setQueryData<ChangesPreferences>(CHANGES_PREFERENCES_QUERY_KEY, next);
await input.storage.setItem(CHANGES_PREFERENCES_STORAGE_KEY, JSON.stringify(next));
}
@@ -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<number | null>>(),
}));
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<typeof createTestClient>): HeartbeatPayload {
const call = client.sendHeartbeat.mock.calls.at(-1);
if (!call) {
throw new Error("Expected a heartbeat");
}
return call[0];
}
function heartbeatTimeMs(client: ReturnType<typeof createTestClient>): number {
return new Date(latestHeartbeat(client).lastActivityAt).getTime();
}
async function renderActivityHook({
client = createTestClient(),
focusedAgentId = "agent-1",
}: {
client?: ReturnType<typeof createTestClient>;
focusedAgentId?: string | null;
} = {}) {
function Probe({ focusedAgentId: probeFocusedAgentId }: { focusedAgentId: string | null }) {
useClientActivity({
client: client as unknown as Parameters<typeof useClientActivity>[0]["client"],
focusedAgentId: probeFocusedAgentId,
});
return null;
}
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
await act(async () => {
root.render(<Probe focusedAgentId={focusedAgentId} />);
});
const rerender = async (nextFocusedAgentId: string | null) => {
await act(async () => {
root.render(<Probe focusedAgentId={nextFocusedAgentId} />);
});
};
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("<!doctype html><html><body></body></html>", {
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);
});
});
+55 -108
View File
@@ -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<Date>(new Date());
const appVisibleRef = useRef(AppState.currentState === "active");
const appVisibilityChangedAtRef = useRef<Date>(new Date());
const backgroundedAtMsRef = useRef<number | null>(
AppState.currentState === "active" ? null : Date.now(),
);
const heartbeatIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const prevFocusedAgentIdRef = useRef<string | null>(focusedAgentId);
const lastImmediateHeartbeatAtRef = useRef<number>(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<ClientActivityTracker | null>(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<typeof setInterval> | 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]);
}
@@ -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<HTMLDivElement | null>(null);
const contentRef = useRef<HTMLDivElement | null>(null);
useHoverSafeZone({
enabled: true,
triggerRef: triggerRef as unknown as RefObject<View | null>,
contentRef: contentRef as unknown as RefObject<View | null>,
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 (
<>
<div ref={handleTriggerRef} />
<div ref={handleContentRef} />
</>
);
}
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(<Harness onEnterSafeZone={onEnterSafeZone} onLeaveSafeZone={onLeaveSafeZone} />);
});
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(<Harness onEnterSafeZone={onEnterSafeZone} onLeaveSafeZone={onLeaveSafeZone} />);
});
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(<Harness onEnterSafeZone={onEnterSafeZone} onLeaveSafeZone={onLeaveSafeZone} />);
});
act(() => {
window.dispatchEvent(new window.MouseEvent("pointerout", { bubbles: true }));
});
expect(onLeaveSafeZone).toHaveBeenCalledTimes(1);
act(() => {
window.dispatchEvent(new window.FocusEvent("blur"));
});
expect(onLeaveSafeZone).toHaveBeenCalledTimes(1);
});
});
+18 -67
View File
@@ -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<View | null>): 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;
}
@@ -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;
}
@@ -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();
});
});
@@ -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<HardwareKeyboardSubmitController | null>(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]);
}
@@ -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,
@@ -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<void>;
interface FakeClient extends LoadOlderAgentHistoryClient {
calls: Array<{
agentId: string;
request: Parameters<LoadOlderAgentHistoryClient["fetchAgentTimeline"]>[1];
}>;
}
function createToast(): ToastApi {
function createClient(behavior: () => Promise<void> = async () => undefined): FakeClient {
const calls: FakeClient["calls"] = [];
return {
show: vi.fn<ToastApi["show"]>(),
copied: vi.fn<ToastApi["copied"]>(),
error: vi.fn<ToastApi["error"]>(),
calls,
fetchAgentTimeline: async (id, request) => {
calls.push({ agentId: id, request });
await behavior();
},
};
}
function makeClient(
fetchAgentTimeline: FetchAgentTimeline = vi.fn<FetchAgentTimeline>().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<FetchAgentTimeline>(
() =>
new Promise<void>((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<FetchAgentTimeline>().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]);
});
});

Some files were not shown because too many files have changed in this diff Show More