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:
@@ -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.");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,62 @@
|
||||
import { useCallback } from "react";
|
||||
import type { ToastApi } from "@/components/toast-host";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useSessionStore, type AgentTimelineCursorState } from "@/stores/session-store";
|
||||
import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy";
|
||||
|
||||
export interface LoadOlderAgentHistoryClient {
|
||||
fetchAgentTimeline: (
|
||||
agentId: string,
|
||||
request: {
|
||||
direction: "before";
|
||||
cursor: { epoch: string; seq: number };
|
||||
limit: number;
|
||||
projection: "canonical";
|
||||
},
|
||||
) => Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface LoadOlderAgentHistoryLogger {
|
||||
warn: (...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
export interface LoadOlderAgentHistoryDeps {
|
||||
client: LoadOlderAgentHistoryClient | null;
|
||||
cursor: AgentTimelineCursorState | undefined;
|
||||
hasOlder: boolean;
|
||||
isLoadingOlder: boolean;
|
||||
setInFlight: (value: boolean) => void;
|
||||
toast?: ToastApi | null;
|
||||
logger?: LoadOlderAgentHistoryLogger;
|
||||
}
|
||||
|
||||
export async function loadOlderAgentHistory(
|
||||
agentId: string,
|
||||
deps: LoadOlderAgentHistoryDeps,
|
||||
): Promise<void> {
|
||||
const { client, cursor, hasOlder, isLoadingOlder, setInFlight, toast, logger } = deps;
|
||||
if (!client || !cursor || !hasOlder || isLoadingOlder) {
|
||||
return;
|
||||
}
|
||||
|
||||
setInFlight(true);
|
||||
try {
|
||||
await client.fetchAgentTimeline(agentId, {
|
||||
direction: "before",
|
||||
cursor: { epoch: cursor.epoch, seq: cursor.startSeq },
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
projection: "canonical",
|
||||
});
|
||||
} catch (error) {
|
||||
(logger ?? console).warn("[Timeline] failed to load older agent history", agentId, error);
|
||||
toast?.show("Couldn't load older history", {
|
||||
durationMs: 2200,
|
||||
testID: "agent-load-older-history-toast",
|
||||
});
|
||||
} finally {
|
||||
setInFlight(false);
|
||||
}
|
||||
}
|
||||
|
||||
export function useLoadOlderAgentHistory({
|
||||
serverId,
|
||||
agentId,
|
||||
@@ -12,10 +66,6 @@ export function useLoadOlderAgentHistory({
|
||||
agentId: string;
|
||||
toast?: ToastApi | null;
|
||||
}) {
|
||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||
const cursor = useSessionStore((state) =>
|
||||
state.sessions[serverId]?.agentTimelineCursor.get(agentId),
|
||||
);
|
||||
const hasOlder =
|
||||
useSessionStore((state) => state.sessions[serverId]?.agentTimelineHasOlder.get(agentId)) ===
|
||||
true;
|
||||
@@ -42,31 +92,16 @@ export function useLoadOlderAgentHistory({
|
||||
);
|
||||
|
||||
const loadOlder = useCallback(() => {
|
||||
const latestSession = useSessionStore.getState().sessions[serverId];
|
||||
const latestIsLoading = latestSession?.agentTimelineOlderFetchInFlight.get(agentId) === true;
|
||||
if (!client || !cursor || !hasOlder || isLoadingOlder || latestIsLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
setInFlight(true);
|
||||
void client
|
||||
.fetchAgentTimeline(agentId, {
|
||||
direction: "before",
|
||||
cursor: { epoch: cursor.epoch, seq: cursor.startSeq },
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
projection: "canonical",
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn("[Timeline] failed to load older agent history", agentId, error);
|
||||
toast?.show("Couldn't load older history", {
|
||||
durationMs: 2200,
|
||||
testID: "agent-load-older-history-toast",
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setInFlight(false);
|
||||
});
|
||||
}, [agentId, client, cursor, hasOlder, isLoadingOlder, serverId, setInFlight, toast]);
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
void loadOlderAgentHistory(agentId, {
|
||||
client: (session?.client ?? null) as LoadOlderAgentHistoryClient | null,
|
||||
cursor: session?.agentTimelineCursor.get(agentId),
|
||||
hasOlder: session?.agentTimelineHasOlder.get(agentId) === true,
|
||||
isLoadingOlder: session?.agentTimelineOlderFetchInFlight.get(agentId) === true,
|
||||
setInFlight,
|
||||
toast,
|
||||
});
|
||||
}, [agentId, serverId, setInFlight, toast]);
|
||||
|
||||
return {
|
||||
isLoadingOlder,
|
||||
|
||||
@@ -1,129 +1,129 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.hoisted(() => {
|
||||
(globalThis as typeof globalThis & { __DEV__?: boolean }).__DEV__ = false;
|
||||
});
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => {
|
||||
const storage = new Map<string, string>();
|
||||
return {
|
||||
default: {
|
||||
getItem: vi.fn(async (key: string) => storage.get(key) ?? null),
|
||||
setItem: vi.fn(async (key: string, value: string) => {
|
||||
storage.set(key, value);
|
||||
}),
|
||||
removeItem: vi.fn(async (key: string) => {
|
||||
storage.delete(key);
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const { replaceRoute } = vi.hoisted(() => ({
|
||||
replaceRoute: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-workspace-navigation", () => ({
|
||||
navigateToWorkspace: replaceRoute,
|
||||
}));
|
||||
|
||||
import { openProjectDirectly } from "@/hooks/use-open-project";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
collectAllTabs,
|
||||
useWorkspaceLayoutStore,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
import { generateDraftId } from "@/stores/draft-keys";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { openProjectDirectly } from "@/hooks/open-project";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
|
||||
const SERVER_ID = "server-1";
|
||||
const WORKSPACE_ID = "/repo/project";
|
||||
const PROJECT_PATH = "/repo/project";
|
||||
|
||||
function createOpenDraftTab() {
|
||||
return (workspaceKey: string) =>
|
||||
useWorkspaceLayoutStore.getState().openTabFocused(workspaceKey, {
|
||||
kind: "draft",
|
||||
draftId: generateDraftId(),
|
||||
});
|
||||
function buildWorkspacePayload() {
|
||||
return {
|
||||
id: "1",
|
||||
projectId: "1",
|
||||
projectDisplayName: "project",
|
||||
projectRootPath: PROJECT_PATH,
|
||||
workspaceDirectory: PROJECT_PATH,
|
||||
projectKind: "git" as const,
|
||||
workspaceKind: "checkout" as const,
|
||||
name: "project",
|
||||
archivingAt: null,
|
||||
status: "done" as const,
|
||||
activityAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
};
|
||||
}
|
||||
|
||||
interface RecordedMerge {
|
||||
serverId: string;
|
||||
workspaces: WorkspaceDescriptor[];
|
||||
}
|
||||
|
||||
interface RecordedHydrated {
|
||||
serverId: string;
|
||||
hydrated: boolean;
|
||||
}
|
||||
|
||||
interface RecordedOpenDraftTab {
|
||||
workspaceKey: string;
|
||||
}
|
||||
|
||||
interface RecordedNavigate {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
function createFakeSession() {
|
||||
const merges: RecordedMerge[] = [];
|
||||
const hydrated: RecordedHydrated[] = [];
|
||||
return {
|
||||
merges,
|
||||
hydrated,
|
||||
mergeWorkspaces: (serverId: string, workspaces: Iterable<WorkspaceDescriptor>) => {
|
||||
merges.push({ serverId, workspaces: Array.from(workspaces) });
|
||||
},
|
||||
setHasHydratedWorkspaces: (serverId: string, value: boolean) => {
|
||||
hydrated.push({ serverId, hydrated: value });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createFakeWorkspaceLayout() {
|
||||
const openedTabs: RecordedOpenDraftTab[] = [];
|
||||
return {
|
||||
openedTabs,
|
||||
openDraftTab: (workspaceKey: string) => {
|
||||
openedTabs.push({ workspaceKey });
|
||||
return "tab-1";
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createFakeNavigator() {
|
||||
const navigations: RecordedNavigate[] = [];
|
||||
return {
|
||||
navigations,
|
||||
navigateToWorkspace: (serverId: string, workspaceId: string) => {
|
||||
navigations.push({ serverId, workspaceId });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("openProjectDirectly", () => {
|
||||
beforeEach(() => {
|
||||
replaceRoute.mockReset();
|
||||
useSessionStore.setState({
|
||||
sessions: {},
|
||||
});
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, {} as never);
|
||||
useWorkspaceLayoutStore.setState({
|
||||
layoutByWorkspace: {},
|
||||
splitSizesByWorkspace: {},
|
||||
pinnedAgentIdsByWorkspace: {},
|
||||
});
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
it("opens the workspace, marks workspaces hydrated, and seeds a draft tab", async () => {
|
||||
const session = createFakeSession();
|
||||
const layout = createFakeWorkspaceLayout();
|
||||
const navigator = createFakeNavigator();
|
||||
const workspacePayload = buildWorkspacePayload();
|
||||
|
||||
it("opens the workspace directly, marks workspaces hydrated, and seeds a draft tab", async () => {
|
||||
const result = await openProjectDirectly({
|
||||
serverId: SERVER_ID,
|
||||
projectPath: WORKSPACE_ID,
|
||||
projectPath: PROJECT_PATH,
|
||||
isConnected: true,
|
||||
client: {
|
||||
openProject: vi.fn(async () => ({
|
||||
requestId: "request-1",
|
||||
error: null,
|
||||
workspace: {
|
||||
id: "1",
|
||||
projectId: "1",
|
||||
projectDisplayName: "project",
|
||||
projectRootPath: WORKSPACE_ID,
|
||||
workspaceDirectory: WORKSPACE_ID,
|
||||
projectKind: "git" as const,
|
||||
workspaceKind: "checkout" as const,
|
||||
name: "project",
|
||||
archivingAt: null,
|
||||
status: "done" as const,
|
||||
activityAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
},
|
||||
workspace: workspacePayload,
|
||||
})),
|
||||
},
|
||||
mergeWorkspaces: useSessionStore.getState().mergeWorkspaces,
|
||||
setHasHydratedWorkspaces: useSessionStore.getState().setHasHydratedWorkspaces,
|
||||
openDraftTab: createOpenDraftTab(),
|
||||
navigateToWorkspace: replaceRoute,
|
||||
mergeWorkspaces: session.mergeWorkspaces,
|
||||
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
|
||||
openDraftTab: layout.openDraftTab,
|
||||
navigateToWorkspace: navigator.navigateToWorkspace,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(useSessionStore.getState().sessions[SERVER_ID]?.hasHydratedWorkspaces).toBe(true);
|
||||
expect(
|
||||
Array.from(useSessionStore.getState().sessions[SERVER_ID]?.workspaces.values() ?? []),
|
||||
).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "1",
|
||||
projectId: "1",
|
||||
projectRootPath: WORKSPACE_ID,
|
||||
workspaceDirectory: WORKSPACE_ID,
|
||||
}),
|
||||
]);
|
||||
|
||||
const workspaceKey = buildWorkspaceTabPersistenceKey({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: "1",
|
||||
expect(session.merges).toHaveLength(1);
|
||||
expect(session.merges[0]?.serverId).toBe(SERVER_ID);
|
||||
expect(session.merges[0]?.workspaces[0]).toMatchObject({
|
||||
id: "1",
|
||||
projectId: "1",
|
||||
projectRootPath: PROJECT_PATH,
|
||||
workspaceDirectory: PROJECT_PATH,
|
||||
});
|
||||
expect(workspaceKey).toBeTruthy();
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey as string];
|
||||
expect(layout.root.kind).toBe("pane");
|
||||
const tabs = collectAllTabs(layout.root);
|
||||
expect(tabs).toHaveLength(1);
|
||||
expect(tabs[0]?.target.kind).toBe("draft");
|
||||
expect(replaceRoute).toHaveBeenCalledWith("server-1", "1");
|
||||
expect(session.hydrated).toEqual([{ serverId: SERVER_ID, hydrated: true }]);
|
||||
expect(layout.openedTabs).toEqual([{ workspaceKey: `${SERVER_ID}:1` }]);
|
||||
expect(navigator.navigations).toEqual([{ serverId: SERVER_ID, workspaceId: "1" }]);
|
||||
});
|
||||
|
||||
it("does not navigate or seed tabs when openProject fails", async () => {
|
||||
const session = createFakeSession();
|
||||
const layout = createFakeWorkspaceLayout();
|
||||
const navigator = createFakeNavigator();
|
||||
|
||||
const result = await openProjectDirectly({
|
||||
serverId: SERVER_ID,
|
||||
projectPath: WORKSPACE_ID,
|
||||
projectPath: PROJECT_PATH,
|
||||
isConnected: true,
|
||||
client: {
|
||||
openProject: vi.fn(async () => ({
|
||||
@@ -132,16 +132,16 @@ describe("openProjectDirectly", () => {
|
||||
workspace: null,
|
||||
})),
|
||||
},
|
||||
mergeWorkspaces: useSessionStore.getState().mergeWorkspaces,
|
||||
setHasHydratedWorkspaces: useSessionStore.getState().setHasHydratedWorkspaces,
|
||||
openDraftTab: createOpenDraftTab(),
|
||||
navigateToWorkspace: replaceRoute,
|
||||
mergeWorkspaces: session.mergeWorkspaces,
|
||||
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
|
||||
openDraftTab: layout.openDraftTab,
|
||||
navigateToWorkspace: navigator.navigateToWorkspace,
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(useSessionStore.getState().sessions[SERVER_ID]?.hasHydratedWorkspaces).toBe(false);
|
||||
expect(useSessionStore.getState().sessions[SERVER_ID]?.workspaces.size).toBe(0);
|
||||
expect(useWorkspaceLayoutStore.getState().layoutByWorkspace).toEqual({});
|
||||
expect(replaceRoute).not.toHaveBeenCalled();
|
||||
expect(session.merges).toEqual([]);
|
||||
expect(session.hydrated).toEqual([]);
|
||||
expect(layout.openedTabs).toEqual([]);
|
||||
expect(navigator.navigations).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,57 +1,10 @@
|
||||
import { useCallback } from "react";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import {
|
||||
normalizeWorkspaceDescriptor,
|
||||
type WorkspaceDescriptor,
|
||||
useSessionStore,
|
||||
} from "@/stores/session-store";
|
||||
import {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
useWorkspaceLayoutStore,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { generateDraftId } from "@/stores/draft-keys";
|
||||
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
|
||||
|
||||
interface OpenProjectDirectlyInput {
|
||||
serverId: string;
|
||||
projectPath: string;
|
||||
isConnected: boolean;
|
||||
client: Pick<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;
|
||||
}
|
||||
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||
import { openProjectDirectly } from "@/hooks/open-project";
|
||||
|
||||
export function useOpenProject(serverId: string | null): (path: string) => Promise<boolean> {
|
||||
const normalizedServerId = serverId?.trim() ?? "";
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,19 @@
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import type {
|
||||
CheckoutPrStatusResponse,
|
||||
PullRequestTimelineResponse,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { mapPrPaneData, type PrPaneData } from "@/git/pr-pane-data";
|
||||
import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
|
||||
import { prPaneTimelineQueryKey } from "@/git/query-keys";
|
||||
|
||||
type CheckoutPrStatus = CheckoutPrStatusResponse["payload"]["status"];
|
||||
type CheckoutPrStatusPayloadError = CheckoutPrStatusResponse["payload"]["error"];
|
||||
type PullRequestTimeline = PullRequestTimelineResponse["payload"];
|
||||
|
||||
const unsupportedTimelineKeys = new Set<string>();
|
||||
|
||||
export interface UsePrPaneDataOptions {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
@@ -29,23 +30,21 @@ export interface UsePrPaneDataResult {
|
||||
githubFeaturesEnabled: boolean;
|
||||
}
|
||||
|
||||
interface PrRepoIdentity {
|
||||
export interface PrRepoIdentity {
|
||||
prNumber: number | null;
|
||||
repoOwner: string | null;
|
||||
repoName: string | null;
|
||||
}
|
||||
|
||||
function extractPrRepoIdentity(status: CheckoutPrStatusLike): PrRepoIdentity {
|
||||
export function extractPrRepoIdentity(status: CheckoutPrStatus | null): PrRepoIdentity {
|
||||
const prNumber = status?.number ?? null;
|
||||
const repoOwner = status?.repoOwner && status.repoOwner.length > 0 ? status.repoOwner : null;
|
||||
const repoName = status?.repoName && status.repoName.length > 0 ? status.repoName : null;
|
||||
return { prNumber, repoOwner, repoName };
|
||||
}
|
||||
|
||||
type CheckoutPrStatusLike = ReturnType<typeof useCheckoutPrStatusQuery>["status"];
|
||||
|
||||
interface ShouldFetchTimelineArgs {
|
||||
daemonClient: unknown;
|
||||
export interface ShouldFetchTimelineArgs {
|
||||
hasClient: boolean;
|
||||
isConnected: boolean;
|
||||
timelineEnabled: boolean;
|
||||
githubFeaturesEnabled: boolean;
|
||||
@@ -54,8 +53,8 @@ interface ShouldFetchTimelineArgs {
|
||||
timelineUnsupported: boolean;
|
||||
}
|
||||
|
||||
function shouldFetchTimelineFrom({
|
||||
daemonClient,
|
||||
export function shouldFetchTimelineFrom({
|
||||
hasClient,
|
||||
isConnected,
|
||||
timelineEnabled,
|
||||
githubFeaturesEnabled,
|
||||
@@ -64,7 +63,7 @@ function shouldFetchTimelineFrom({
|
||||
timelineUnsupported,
|
||||
}: ShouldFetchTimelineArgs): boolean {
|
||||
return (
|
||||
!!daemonClient &&
|
||||
hasClient &&
|
||||
isConnected &&
|
||||
timelineEnabled &&
|
||||
githubFeaturesEnabled &&
|
||||
@@ -76,6 +75,112 @@ function shouldFetchTimelineFrom({
|
||||
);
|
||||
}
|
||||
|
||||
export type PrPaneTimelineClient = Pick<DaemonClient, "pullRequestTimeline">;
|
||||
|
||||
export interface UnsupportedTimelineRegistry {
|
||||
has(key: string): boolean;
|
||||
add(key: string): void;
|
||||
}
|
||||
|
||||
export function unsupportedTimelineKey({
|
||||
serverId,
|
||||
cwd,
|
||||
prNumber,
|
||||
}: {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
prNumber: number;
|
||||
}): string {
|
||||
return `${serverId}\0${cwd}\0${prNumber}`;
|
||||
}
|
||||
|
||||
export function createInMemoryUnsupportedTimelineRegistry(): UnsupportedTimelineRegistry {
|
||||
const keys = new Set<string>();
|
||||
return {
|
||||
has: (key) => keys.has(key),
|
||||
add: (key) => {
|
||||
keys.add(key);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const defaultUnsupportedTimelineRegistry = createInMemoryUnsupportedTimelineRegistry();
|
||||
|
||||
export interface FetchPrPaneTimelinePageInput {
|
||||
client: PrPaneTimelineClient;
|
||||
registry: UnsupportedTimelineRegistry;
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
prNumber: number;
|
||||
repoOwner: string;
|
||||
repoName: string;
|
||||
}
|
||||
|
||||
export async function fetchPrPaneTimelinePage(
|
||||
input: FetchPrPaneTimelinePageInput,
|
||||
): Promise<PullRequestTimeline> {
|
||||
try {
|
||||
return await input.client.pullRequestTimeline({
|
||||
cwd: input.cwd,
|
||||
prNumber: input.prNumber,
|
||||
repoOwner: input.repoOwner,
|
||||
repoName: input.repoName,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isUnsupportedTimelineError(error)) {
|
||||
input.registry.add(
|
||||
unsupportedTimelineKey({
|
||||
serverId: input.serverId,
|
||||
cwd: input.cwd,
|
||||
prNumber: input.prNumber,
|
||||
}),
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export interface SelectPrPaneStateInput {
|
||||
status: CheckoutPrStatus | null;
|
||||
statusPayloadError: CheckoutPrStatusPayloadError;
|
||||
statusError: Error | null;
|
||||
statusIsLoading: boolean;
|
||||
statusIsFetching: boolean;
|
||||
githubFeaturesEnabled: boolean;
|
||||
timelineEnabled: boolean;
|
||||
shouldFetchTimeline: boolean;
|
||||
timelinePayload: PullRequestTimeline | undefined;
|
||||
timelineError: Error | null;
|
||||
timelineIsLoading: boolean;
|
||||
timelineIsFetching: boolean;
|
||||
}
|
||||
|
||||
export function selectPrPaneState(input: SelectPrPaneStateInput): UsePrPaneDataResult {
|
||||
const identity = extractPrRepoIdentity(input.status);
|
||||
const data =
|
||||
identity.prNumber === null || !input.timelineEnabled
|
||||
? null
|
||||
: mapPrPaneData(input.status, input.timelinePayload);
|
||||
const statusRefreshing = input.statusIsFetching && !input.statusIsLoading;
|
||||
const timelineRefreshing = input.timelineIsFetching && !input.timelineIsLoading;
|
||||
|
||||
return {
|
||||
data,
|
||||
prNumber: identity.prNumber,
|
||||
isLoading:
|
||||
input.statusIsLoading ||
|
||||
(input.shouldFetchTimeline && input.timelineIsLoading && input.timelinePayload === undefined),
|
||||
isRefreshing: statusRefreshing || timelineRefreshing,
|
||||
error: firstNonSuppressedError({
|
||||
statusPayloadError: input.statusPayloadError,
|
||||
statusError: input.statusError,
|
||||
timelineError: input.timelineError,
|
||||
timelinePayloadError: input.timelinePayload?.error ?? null,
|
||||
}),
|
||||
githubFeaturesEnabled: input.githubFeaturesEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
export function usePrPaneData({
|
||||
serverId,
|
||||
cwd,
|
||||
@@ -86,41 +191,47 @@ export function usePrPaneData({
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const checkoutPrStatus = useCheckoutPrStatusQuery({ serverId, cwd, enabled });
|
||||
const status = checkoutPrStatus.status;
|
||||
const { prNumber, repoOwner, repoName } = extractPrRepoIdentity(status);
|
||||
const identity = extractPrRepoIdentity(status);
|
||||
const githubFeaturesEnabled = checkoutPrStatus.githubFeaturesEnabled;
|
||||
const registry = defaultUnsupportedTimelineRegistry;
|
||||
const unsupportedKey =
|
||||
prNumber === null ? null : timelineUnsupportedKey({ serverId, cwd, prNumber });
|
||||
const timelineUnsupported = unsupportedKey ? unsupportedTimelineKeys.has(unsupportedKey) : false;
|
||||
identity.prNumber === null
|
||||
? null
|
||||
: unsupportedTimelineKey({ serverId, cwd, prNumber: identity.prNumber });
|
||||
const timelineUnsupported = unsupportedKey ? registry.has(unsupportedKey) : false;
|
||||
const shouldFetchTimeline = shouldFetchTimelineFrom({
|
||||
daemonClient,
|
||||
hasClient: !!daemonClient,
|
||||
isConnected,
|
||||
timelineEnabled,
|
||||
githubFeaturesEnabled,
|
||||
cwd,
|
||||
identity: { prNumber, repoOwner, repoName },
|
||||
identity,
|
||||
timelineUnsupported,
|
||||
});
|
||||
|
||||
const timelineQuery = useQuery<PullRequestTimeline>({
|
||||
queryKey: prPaneTimelineQueryKey({ serverId, cwd, prNumber }),
|
||||
queryKey: useMemo(
|
||||
() => prPaneTimelineQueryKey({ serverId, cwd, prNumber: identity.prNumber }),
|
||||
[serverId, cwd, identity.prNumber],
|
||||
),
|
||||
queryFn: async () => {
|
||||
if (!daemonClient || prNumber === null || repoOwner === null || repoName === null) {
|
||||
if (
|
||||
!daemonClient ||
|
||||
identity.prNumber === null ||
|
||||
identity.repoOwner === null ||
|
||||
identity.repoName === null
|
||||
) {
|
||||
throw new Error("Daemon client not available");
|
||||
}
|
||||
|
||||
try {
|
||||
return await daemonClient.pullRequestTimeline({
|
||||
cwd,
|
||||
prNumber,
|
||||
repoOwner,
|
||||
repoName,
|
||||
});
|
||||
} catch (error) {
|
||||
if (unsupportedKey && isUnsupportedTimelineError(error)) {
|
||||
unsupportedTimelineKeys.add(unsupportedKey);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return fetchPrPaneTimelinePage({
|
||||
client: daemonClient,
|
||||
registry,
|
||||
serverId,
|
||||
cwd,
|
||||
prNumber: identity.prNumber,
|
||||
repoOwner: identity.repoOwner,
|
||||
repoName: identity.repoName,
|
||||
});
|
||||
},
|
||||
enabled: shouldFetchTimeline,
|
||||
staleTime: Infinity,
|
||||
@@ -131,26 +242,20 @@ export function usePrPaneData({
|
||||
retry: (failureCount, error) => !isUnsupportedTimelineError(error) && failureCount < 3,
|
||||
});
|
||||
|
||||
const data =
|
||||
prNumber === null || !timelineEnabled ? null : mapPrPaneData(status, timelineQuery.data);
|
||||
const statusRefreshing = checkoutPrStatus.isFetching && !checkoutPrStatus.isLoading;
|
||||
const timelineRefreshing = timelineQuery.isFetching && !timelineQuery.isLoading;
|
||||
|
||||
return {
|
||||
data,
|
||||
prNumber,
|
||||
isLoading:
|
||||
checkoutPrStatus.isLoading ||
|
||||
(shouldFetchTimeline && timelineQuery.isLoading && timelineQuery.data === undefined),
|
||||
isRefreshing: statusRefreshing || timelineRefreshing,
|
||||
error: firstNonSuppressedError({
|
||||
statusPayloadError: checkoutPrStatus.payloadError,
|
||||
statusError: checkoutPrStatus.error,
|
||||
timelineError: timelineQuery.error,
|
||||
timelinePayloadError: timelineQuery.data?.error ?? null,
|
||||
}),
|
||||
return selectPrPaneState({
|
||||
status,
|
||||
statusPayloadError: checkoutPrStatus.payloadError,
|
||||
statusError: checkoutPrStatus.error,
|
||||
statusIsLoading: checkoutPrStatus.isLoading,
|
||||
statusIsFetching: checkoutPrStatus.isFetching,
|
||||
githubFeaturesEnabled,
|
||||
};
|
||||
timelineEnabled,
|
||||
shouldFetchTimeline,
|
||||
timelinePayload: timelineQuery.data,
|
||||
timelineError: timelineQuery.error,
|
||||
timelineIsLoading: timelineQuery.isLoading,
|
||||
timelineIsFetching: timelineQuery.isFetching,
|
||||
});
|
||||
}
|
||||
|
||||
function firstNonSuppressedError({
|
||||
@@ -183,19 +288,7 @@ function firstNonSuppressedError({
|
||||
return null;
|
||||
}
|
||||
|
||||
function timelineUnsupportedKey({
|
||||
serverId,
|
||||
cwd,
|
||||
prNumber,
|
||||
}: {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
prNumber: number;
|
||||
}): string {
|
||||
return `${serverId}\0${cwd}\0${prNumber}`;
|
||||
}
|
||||
|
||||
function isUnsupportedTimelineError(error: unknown): boolean {
|
||||
export function isUnsupportedTimelineError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,76 +1,71 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import React from "react";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages";
|
||||
import { useProjects } from "./use-projects";
|
||||
import {
|
||||
fetchAggregatedProjects,
|
||||
type ProjectsHostInput,
|
||||
type ProjectsRuntime,
|
||||
type ProjectsRuntimeSnapshot,
|
||||
} from "./use-projects";
|
||||
|
||||
interface MockDaemonClient {
|
||||
fetchWorkspaces: ReturnType<typeof vi.fn>;
|
||||
type FetchWorkspaces = DaemonClient["fetchWorkspaces"];
|
||||
type FetchWorkspacesResult = Awaited<ReturnType<FetchWorkspaces>>;
|
||||
|
||||
interface HostFixture {
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
status: "online" | "offline" | "missing-snapshot";
|
||||
workspaces?: WorkspaceDescriptorPayload[] | Error;
|
||||
}
|
||||
|
||||
const { hostState, clientsByServerId, runtimeStore } = vi.hoisted(() => {
|
||||
const state = {
|
||||
hosts: [
|
||||
{ serverId: "local", label: "Local" },
|
||||
{ serverId: "laptop", label: "Laptop" },
|
||||
],
|
||||
snapshots: new Map<string, { connectionStatus: string }>(),
|
||||
};
|
||||
const clients = new Map<string, { fetchWorkspaces: ReturnType<typeof vi.fn> }>();
|
||||
return {
|
||||
hostState: state,
|
||||
clientsByServerId: clients,
|
||||
runtimeStore: {
|
||||
getClient: (serverId: string) => clients.get(serverId) ?? null,
|
||||
getSnapshot: (serverId: string) => state.snapshots.get(serverId) ?? null,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/runtime/host-runtime", () => ({
|
||||
getHostRuntimeStore: () => runtimeStore,
|
||||
useHosts: () => hostState.hosts,
|
||||
}));
|
||||
|
||||
function createQueryClient(): QueryClient {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
interface RuntimeAdapter extends ProjectsRuntime {
|
||||
fetchCalls: Map<string, number>;
|
||||
}
|
||||
|
||||
function renderProjectsHook() {
|
||||
const queryClient = createQueryClient();
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
||||
function createRuntime(hosts: HostFixture[]): RuntimeAdapter {
|
||||
const snapshots = new Map<string, ProjectsRuntimeSnapshot | null>();
|
||||
const clients = new Map<string, Pick<DaemonClient, "fetchWorkspaces"> | null>();
|
||||
const fetchCalls = new Map<string, number>();
|
||||
|
||||
return renderHook(() => useProjects(), { wrapper });
|
||||
}
|
||||
for (const host of hosts) {
|
||||
if (host.status === "missing-snapshot") {
|
||||
snapshots.set(host.serverId, null);
|
||||
} else {
|
||||
snapshots.set(host.serverId, { connectionStatus: host.status });
|
||||
}
|
||||
|
||||
function makeClient(entries: WorkspaceDescriptorPayload[] | Error): MockDaemonClient {
|
||||
return {
|
||||
fetchWorkspaces: vi.fn(async () => {
|
||||
if (entries instanceof Error) {
|
||||
throw entries;
|
||||
if (host.workspaces === undefined) {
|
||||
clients.set(host.serverId, null);
|
||||
continue;
|
||||
}
|
||||
|
||||
const workspaces = host.workspaces;
|
||||
const fetchWorkspaces: FetchWorkspaces = async () => {
|
||||
fetchCalls.set(host.serverId, (fetchCalls.get(host.serverId) ?? 0) + 1);
|
||||
if (workspaces instanceof Error) {
|
||||
throw workspaces;
|
||||
}
|
||||
return {
|
||||
requestId: "req-workspaces",
|
||||
entries,
|
||||
pageInfo: {
|
||||
nextCursor: null,
|
||||
prevCursor: null,
|
||||
hasMore: false,
|
||||
},
|
||||
};
|
||||
}),
|
||||
requestId: `req-${host.serverId}`,
|
||||
entries: workspaces,
|
||||
pageInfo: { nextCursor: null, prevCursor: null, hasMore: false },
|
||||
} satisfies FetchWorkspacesResult;
|
||||
};
|
||||
|
||||
clients.set(host.serverId, { fetchWorkspaces });
|
||||
}
|
||||
|
||||
return {
|
||||
getClient: (serverId) => clients.get(serverId) ?? null,
|
||||
getSnapshot: (serverId) => snapshots.get(serverId),
|
||||
fetchCalls,
|
||||
};
|
||||
}
|
||||
|
||||
function hostInputs(hosts: HostFixture[]): ProjectsHostInput[] {
|
||||
return hosts.map((host) => ({ serverId: host.serverId, serverName: host.serverName }));
|
||||
}
|
||||
|
||||
function workspace(input: {
|
||||
id: string;
|
||||
projectKey: string;
|
||||
@@ -118,83 +113,82 @@ function workspace(input: {
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
clientsByServerId.clear();
|
||||
hostState.hosts = [
|
||||
{ serverId: "local", label: "Local" },
|
||||
{ serverId: "laptop", label: "Laptop" },
|
||||
];
|
||||
hostState.snapshots = new Map<string, { connectionStatus: string }>();
|
||||
});
|
||||
describe("fetchAggregatedProjects", () => {
|
||||
it("calls every online host's client and aggregates projects sorted by display name", async () => {
|
||||
const hosts: HostFixture[] = [
|
||||
{
|
||||
serverId: "local",
|
||||
serverName: "Local",
|
||||
status: "online",
|
||||
workspaces: [
|
||||
workspace({
|
||||
id: "z-main",
|
||||
projectKey: "remote:github.com/acme/zeta",
|
||||
projectName: "acme/zeta",
|
||||
cwd: "/repo/zeta",
|
||||
remoteUrl: "https://github.com/acme/zeta.git",
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
serverId: "laptop",
|
||||
serverName: "Laptop",
|
||||
status: "online",
|
||||
workspaces: [
|
||||
workspace({
|
||||
id: "a-main",
|
||||
projectKey: "remote:github.com/acme/alpha",
|
||||
projectName: "acme/alpha",
|
||||
cwd: "/repo/alpha",
|
||||
remoteUrl: "https://github.com/acme/alpha.git",
|
||||
}),
|
||||
],
|
||||
},
|
||||
];
|
||||
const runtime = createRuntime(hosts);
|
||||
|
||||
describe("useProjects", () => {
|
||||
it("calls all known daemon clients and returns aggregated projects sorted by display name", async () => {
|
||||
hostState.snapshots.set("local", { connectionStatus: "online" });
|
||||
hostState.snapshots.set("laptop", { connectionStatus: "online" });
|
||||
clientsByServerId.set(
|
||||
"local",
|
||||
makeClient([
|
||||
workspace({
|
||||
id: "z-main",
|
||||
projectKey: "remote:github.com/acme/zeta",
|
||||
projectName: "acme/zeta",
|
||||
cwd: "/repo/zeta",
|
||||
remoteUrl: "https://github.com/acme/zeta.git",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
clientsByServerId.set(
|
||||
"laptop",
|
||||
makeClient([
|
||||
workspace({
|
||||
id: "a-main",
|
||||
projectKey: "remote:github.com/acme/alpha",
|
||||
projectName: "acme/alpha",
|
||||
cwd: "/repo/alpha",
|
||||
remoteUrl: "https://github.com/acme/alpha.git",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const result = await fetchAggregatedProjects({ hosts: hostInputs(hosts), runtime });
|
||||
|
||||
const { result } = renderProjectsHook();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
expect(clientsByServerId.get("local")?.fetchWorkspaces).toHaveBeenCalledTimes(1);
|
||||
expect(clientsByServerId.get("laptop")?.fetchWorkspaces).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.projects.map((project) => project.projectName)).toEqual([
|
||||
expect(runtime.fetchCalls.get("local")).toBe(1);
|
||||
expect(runtime.fetchCalls.get("laptop")).toBe(1);
|
||||
expect(result.projects.map((project) => project.projectName)).toEqual([
|
||||
"acme/alpha",
|
||||
"acme/zeta",
|
||||
]);
|
||||
expect(result.hostErrors).toEqual([]);
|
||||
});
|
||||
|
||||
it("surfaces per-host fetch failures without dropping successful hosts", async () => {
|
||||
hostState.snapshots.set("local", { connectionStatus: "online" });
|
||||
hostState.snapshots.set("laptop", { connectionStatus: "online" });
|
||||
clientsByServerId.set(
|
||||
"local",
|
||||
makeClient([
|
||||
workspace({
|
||||
id: "main",
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
cwd: "/repo/app",
|
||||
remoteUrl: "https://github.com/acme/app.git",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
clientsByServerId.set("laptop", makeClient(new Error("laptop unavailable")));
|
||||
const hosts: HostFixture[] = [
|
||||
{
|
||||
serverId: "local",
|
||||
serverName: "Local",
|
||||
status: "online",
|
||||
workspaces: [
|
||||
workspace({
|
||||
id: "main",
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
cwd: "/repo/app",
|
||||
remoteUrl: "https://github.com/acme/app.git",
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
serverId: "laptop",
|
||||
serverName: "Laptop",
|
||||
status: "online",
|
||||
workspaces: new Error("laptop unavailable"),
|
||||
},
|
||||
];
|
||||
const runtime = createRuntime(hosts);
|
||||
|
||||
const { result } = renderProjectsHook();
|
||||
const result = await fetchAggregatedProjects({ hosts: hostInputs(hosts), runtime });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
expect(result.current.projects).toEqual([
|
||||
expect(result.projects).toEqual([
|
||||
expect.objectContaining({ projectKey: "remote:github.com/acme/app" }),
|
||||
]);
|
||||
expect(result.current.hostErrors).toEqual([
|
||||
expect(result.hostErrors).toEqual([
|
||||
{
|
||||
serverId: "laptop",
|
||||
serverName: "Laptop",
|
||||
@@ -204,53 +198,60 @@ describe("useProjects", () => {
|
||||
});
|
||||
|
||||
it("skips disconnected hosts silently without surfacing them as failures", async () => {
|
||||
hostState.snapshots.set("local", { connectionStatus: "online" });
|
||||
hostState.snapshots.set("laptop", { connectionStatus: "offline" });
|
||||
clientsByServerId.set(
|
||||
"local",
|
||||
makeClient([
|
||||
workspace({
|
||||
id: "main",
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
cwd: "/repo/app",
|
||||
remoteUrl: "https://github.com/acme/app.git",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const hosts: HostFixture[] = [
|
||||
{
|
||||
serverId: "local",
|
||||
serverName: "Local",
|
||||
status: "online",
|
||||
workspaces: [
|
||||
workspace({
|
||||
id: "main",
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
cwd: "/repo/app",
|
||||
remoteUrl: "https://github.com/acme/app.git",
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
serverId: "laptop",
|
||||
serverName: "Laptop",
|
||||
status: "offline",
|
||||
},
|
||||
];
|
||||
const runtime = createRuntime(hosts);
|
||||
|
||||
const { result } = renderProjectsHook();
|
||||
const result = await fetchAggregatedProjects({ hosts: hostInputs(hosts), runtime });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
expect(result.current.hostErrors).toEqual([]);
|
||||
expect(result.current.projects).toEqual([
|
||||
expect(result.hostErrors).toEqual([]);
|
||||
expect(result.projects).toEqual([
|
||||
expect.objectContaining({ projectKey: "remote:github.com/acme/app" }),
|
||||
]);
|
||||
expect(runtime.fetchCalls.get("laptop")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns only the stable public project and host entry shapes", async () => {
|
||||
hostState.snapshots.set("local", { connectionStatus: "online" });
|
||||
clientsByServerId.set(
|
||||
"local",
|
||||
makeClient([
|
||||
workspace({
|
||||
id: "main",
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
cwd: "/repo/app",
|
||||
remoteUrl: "https://github.com/acme/app.git",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const hosts: HostFixture[] = [
|
||||
{
|
||||
serverId: "local",
|
||||
serverName: "Local",
|
||||
status: "online",
|
||||
workspaces: [
|
||||
workspace({
|
||||
id: "main",
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
cwd: "/repo/app",
|
||||
remoteUrl: "https://github.com/acme/app.git",
|
||||
}),
|
||||
],
|
||||
},
|
||||
];
|
||||
const runtime = createRuntime(hosts);
|
||||
|
||||
const { result } = renderProjectsHook();
|
||||
const result = await fetchAggregatedProjects({ hosts: hostInputs(hosts), runtime });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
expect(Object.keys(result.current.projects[0] ?? {}).sort()).toEqual([
|
||||
expect(Object.keys(result.projects[0] ?? {}).sort()).toEqual([
|
||||
"githubUrl",
|
||||
"hostCount",
|
||||
"hosts",
|
||||
@@ -260,7 +261,7 @@ describe("useProjects", () => {
|
||||
"projectName",
|
||||
"totalWorkspaceCount",
|
||||
]);
|
||||
expect(Object.keys(result.current.projects[0]?.hosts[0] ?? {}).sort()).toEqual([
|
||||
expect(Object.keys(result.projects[0]?.hosts[0] ?? {}).sort()).toEqual([
|
||||
"gitRuntime",
|
||||
"githubRuntime",
|
||||
"isOnline",
|
||||
@@ -270,7 +271,7 @@ describe("useProjects", () => {
|
||||
"workspaceCount",
|
||||
"workspaces",
|
||||
]);
|
||||
expect(Object.keys(result.current.projects[0]?.hosts[0]?.workspaces[0] ?? {}).sort()).toEqual([
|
||||
expect(Object.keys(result.projects[0]?.hosts[0]?.workspaces[0] ?? {}).sort()).toEqual([
|
||||
"currentBranch",
|
||||
"id",
|
||||
"name",
|
||||
|
||||
@@ -21,8 +21,28 @@ export interface UseProjectsResult {
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
interface FetchAllWorkspaceDescriptorsInput {
|
||||
client: Pick<DaemonClient, "fetchWorkspaces">;
|
||||
export interface ProjectsRuntimeSnapshot {
|
||||
connectionStatus: string;
|
||||
}
|
||||
|
||||
export interface ProjectsRuntime {
|
||||
getClient(serverId: string): Pick<DaemonClient, "fetchWorkspaces"> | null;
|
||||
getSnapshot(serverId: string): ProjectsRuntimeSnapshot | null | undefined;
|
||||
}
|
||||
|
||||
export interface ProjectsHostInput {
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
}
|
||||
|
||||
export interface FetchAggregatedProjectsInput {
|
||||
hosts: ProjectsHostInput[];
|
||||
runtime: ProjectsRuntime;
|
||||
}
|
||||
|
||||
export interface FetchAggregatedProjectsResult {
|
||||
projects: ProjectSummary[];
|
||||
hostErrors: ProjectHostError[];
|
||||
}
|
||||
|
||||
interface HostWorkspacesResult {
|
||||
@@ -35,13 +55,13 @@ function toErrorMessage(error: unknown): string {
|
||||
}
|
||||
|
||||
async function fetchAllWorkspaceDescriptors(
|
||||
input: FetchAllWorkspaceDescriptorsInput,
|
||||
client: Pick<DaemonClient, "fetchWorkspaces">,
|
||||
): Promise<WorkspaceDescriptor[]> {
|
||||
const entries: WorkspaceDescriptor[] = [];
|
||||
let cursor: string | null = null;
|
||||
|
||||
while (true) {
|
||||
const payload = await input.client.fetchWorkspaces({
|
||||
const payload = await client.fetchWorkspaces({
|
||||
sort: [{ key: "name", direction: "asc" }],
|
||||
page: cursor ? { limit: 200, cursor } : { limit: 200 },
|
||||
});
|
||||
@@ -55,10 +75,66 @@ async function fetchAllWorkspaceDescriptors(
|
||||
return entries;
|
||||
}
|
||||
|
||||
export async function fetchAggregatedProjects(
|
||||
input: FetchAggregatedProjectsInput,
|
||||
): Promise<FetchAggregatedProjectsResult> {
|
||||
const results = await Promise.all(
|
||||
input.hosts.map(async (host): Promise<HostWorkspacesResult> => {
|
||||
const snapshot = input.runtime.getSnapshot(host.serverId);
|
||||
const isOnline = snapshot?.connectionStatus === "online";
|
||||
const client = input.runtime.getClient(host.serverId);
|
||||
|
||||
if (!client || !isOnline) {
|
||||
return {
|
||||
host: {
|
||||
serverId: host.serverId,
|
||||
serverName: host.serverName,
|
||||
isOnline,
|
||||
workspaces: [],
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
host: {
|
||||
serverId: host.serverId,
|
||||
serverName: host.serverName,
|
||||
isOnline,
|
||||
workspaces: await fetchAllWorkspaceDescriptors(client),
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
host: {
|
||||
serverId: host.serverId,
|
||||
serverName: host.serverName,
|
||||
isOnline,
|
||||
workspaces: [],
|
||||
},
|
||||
error: {
|
||||
serverId: host.serverId,
|
||||
serverName: host.serverName,
|
||||
message: toErrorMessage(error),
|
||||
},
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const hostErrors = results.flatMap((result) => (result.error ? [result.error] : []));
|
||||
return {
|
||||
...buildProjects({ hosts: results.map((result) => result.host) }),
|
||||
hostErrors,
|
||||
};
|
||||
}
|
||||
|
||||
export function useProjects(): UseProjectsResult {
|
||||
const hosts = useHosts();
|
||||
const runtime = getHostRuntimeStore();
|
||||
const hostInputs = useMemo(
|
||||
const hostInputs = useMemo<ProjectsHostInput[]>(
|
||||
() =>
|
||||
hosts.map((host) => ({
|
||||
serverId: host.serverId,
|
||||
@@ -69,59 +145,7 @@ export function useProjects(): UseProjectsResult {
|
||||
|
||||
const projectsQuery = useQuery({
|
||||
queryKey: projectsQueryKey,
|
||||
queryFn: async () => {
|
||||
const results = await Promise.all(
|
||||
hostInputs.map(async (host): Promise<HostWorkspacesResult> => {
|
||||
const snapshot = runtime.getSnapshot(host.serverId);
|
||||
const isOnline = snapshot?.connectionStatus === "online";
|
||||
const client = runtime.getClient(host.serverId);
|
||||
|
||||
if (!client || !isOnline) {
|
||||
return {
|
||||
host: {
|
||||
serverId: host.serverId,
|
||||
serverName: host.serverName,
|
||||
isOnline,
|
||||
workspaces: [],
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
host: {
|
||||
serverId: host.serverId,
|
||||
serverName: host.serverName,
|
||||
isOnline,
|
||||
workspaces: await fetchAllWorkspaceDescriptors({ client }),
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
host: {
|
||||
serverId: host.serverId,
|
||||
serverName: host.serverName,
|
||||
isOnline,
|
||||
workspaces: [],
|
||||
},
|
||||
error: {
|
||||
serverId: host.serverId,
|
||||
serverName: host.serverName,
|
||||
message: toErrorMessage(error),
|
||||
},
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const hostErrors = results.flatMap((result) => (result.error ? [result.error] : []));
|
||||
return {
|
||||
...buildProjects({ hosts: results.map((result) => result.host) }),
|
||||
hostErrors,
|
||||
};
|
||||
},
|
||||
queryFn: () => fetchAggregatedProjects({ hosts: hostInputs, runtime }),
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,92 +1,62 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import React from "react";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { providersSnapshotQueryKey, useProvidersSnapshot } from "./use-providers-snapshot";
|
||||
import {
|
||||
applyProvidersSnapshotUpdate,
|
||||
fetchProvidersSnapshot,
|
||||
providersSnapshotQueryKey,
|
||||
refreshAndApplyProvidersSnapshot,
|
||||
selectorOpenRefetchDecision,
|
||||
type ProvidersSnapshotClient,
|
||||
type ProvidersSnapshotUpdateMessage,
|
||||
} from "./use-providers-snapshot";
|
||||
|
||||
interface ProviderSnapshotUpdateMessage {
|
||||
type: "providers_snapshot_update";
|
||||
payload: {
|
||||
cwd?: string;
|
||||
entries: ProviderSnapshotEntry[];
|
||||
generatedAt: string;
|
||||
};
|
||||
}
|
||||
type ProviderSnapshotUpdateListener = (message: ProviderSnapshotUpdateMessage) => void;
|
||||
interface ProvidersSnapshot {
|
||||
entries: ProviderSnapshotEntry[];
|
||||
generatedAt: string;
|
||||
requestId: string;
|
||||
}
|
||||
type HookResult = ReturnType<typeof renderProvidersSnapshotHook>["result"];
|
||||
type GetProvidersSnapshotResult = Awaited<ReturnType<DaemonClient["getProvidersSnapshot"]>>;
|
||||
type RefreshProvidersSnapshotResult = Awaited<ReturnType<DaemonClient["refreshProvidersSnapshot"]>>;
|
||||
type GetProvidersSnapshotOptions = Parameters<DaemonClient["getProvidersSnapshot"]>[0];
|
||||
type RefreshProvidersSnapshotOptions = Parameters<DaemonClient["refreshProvidersSnapshot"]>[0];
|
||||
|
||||
const { mockClient, mockRuntime, snapshotUpdateListeners } = vi.hoisted(() => {
|
||||
const hoistedListeners: ProviderSnapshotUpdateListener[] = [];
|
||||
const hoistedClient = {
|
||||
getProvidersSnapshot: vi.fn(),
|
||||
refreshProvidersSnapshot: vi.fn(),
|
||||
on: vi.fn((_event: string, listener: ProviderSnapshotUpdateListener) => {
|
||||
hoistedListeners.push(listener);
|
||||
return () => {};
|
||||
}),
|
||||
interface FakeProvidersSnapshotClient extends ProvidersSnapshotClient {
|
||||
getCalls: GetProvidersSnapshotOptions[];
|
||||
refreshCalls: RefreshProvidersSnapshotOptions[];
|
||||
}
|
||||
|
||||
function createClient(
|
||||
input: {
|
||||
snapshots?: GetProvidersSnapshotResult[];
|
||||
refreshResult?: RefreshProvidersSnapshotResult;
|
||||
} = {},
|
||||
): FakeProvidersSnapshotClient {
|
||||
const snapshots = [...(input.snapshots ?? [])];
|
||||
const refreshResult: RefreshProvidersSnapshotResult = input.refreshResult ?? {
|
||||
acknowledged: true,
|
||||
requestId: "refresh-1",
|
||||
};
|
||||
|
||||
const getCalls: GetProvidersSnapshotOptions[] = [];
|
||||
const refreshCalls: RefreshProvidersSnapshotOptions[] = [];
|
||||
|
||||
return {
|
||||
mockClient: hoistedClient,
|
||||
mockRuntime: {
|
||||
client: hoistedClient,
|
||||
isConnected: true,
|
||||
getCalls,
|
||||
refreshCalls,
|
||||
async getProvidersSnapshot(options) {
|
||||
getCalls.push(options ?? {});
|
||||
const next = snapshots.shift();
|
||||
if (!next) {
|
||||
throw new Error("No snapshot configured for getProvidersSnapshot call");
|
||||
}
|
||||
return next;
|
||||
},
|
||||
async refreshProvidersSnapshot(options) {
|
||||
refreshCalls.push(options ?? {});
|
||||
return refreshResult;
|
||||
},
|
||||
snapshotUpdateListeners: hoistedListeners,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/runtime/host-runtime", () => ({
|
||||
useHostRuntimeClient: () => mockRuntime.client,
|
||||
useHostRuntimeIsConnected: () => mockRuntime.isConnected,
|
||||
}));
|
||||
|
||||
const serverId = "server-1";
|
||||
|
||||
function createQueryClient(): QueryClient {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function enableProvidersSnapshot(): void {
|
||||
act(() => {
|
||||
useSessionStore.getState().initializeSession(serverId, mockClient as unknown as DaemonClient);
|
||||
useSessionStore.getState().updateSessionServerInfo(serverId, {
|
||||
serverId,
|
||||
hostname: "localhost",
|
||||
version: "test",
|
||||
features: { providersSnapshot: true },
|
||||
} as never);
|
||||
});
|
||||
}
|
||||
|
||||
function renderProvidersSnapshotHook(options: { cwd?: string | null } = {}) {
|
||||
const queryClient = createQueryClient();
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
||||
|
||||
return renderHook(() => useProvidersSnapshot(serverId, options), { wrapper });
|
||||
}
|
||||
|
||||
const readyCodexModel = { provider: "codex", id: "gpt-5.4", label: "GPT-5.4" } as const;
|
||||
|
||||
function providersSnapshot(entries: ProviderSnapshotEntry[]): ProvidersSnapshot {
|
||||
function providersSnapshot(entries: ProviderSnapshotEntry[]): GetProvidersSnapshotResult {
|
||||
return {
|
||||
entries,
|
||||
generatedAt: "2026-01-01T00:00:00.000Z",
|
||||
@@ -106,56 +76,11 @@ function codexEntry(
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForSnapshotReads(count: number): Promise<void> {
|
||||
await waitFor(() => {
|
||||
expect(mockClient.getProvidersSnapshot).toHaveBeenCalledTimes(count);
|
||||
});
|
||||
}
|
||||
const readyCodexModel = { provider: "codex", id: "gpt-5.4", label: "GPT-5.4" } as const;
|
||||
const serverId = "server-1";
|
||||
|
||||
async function waitForSnapshotEntries(
|
||||
result: HookResult,
|
||||
entries: ProviderSnapshotEntry[],
|
||||
): Promise<void> {
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toEqual(entries);
|
||||
});
|
||||
}
|
||||
|
||||
async function emitProvidersSnapshotUpdate(
|
||||
entries: ProviderSnapshotEntry[],
|
||||
cwd?: string,
|
||||
): Promise<void> {
|
||||
const listener = snapshotUpdateListeners.at(-1);
|
||||
expect(listener).toBeDefined();
|
||||
|
||||
await act(async () => {
|
||||
listener?.({
|
||||
type: "providers_snapshot_update",
|
||||
payload: {
|
||||
...(cwd ? { cwd } : {}),
|
||||
entries,
|
||||
generatedAt: "2026-01-01T00:00:01.000Z",
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function openSelectorForSelectedProvider(result: HookResult): Promise<void> {
|
||||
await act(async () => {
|
||||
result.current.refetchIfStale("codex");
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
useSessionStore.getState().clearSession(serverId);
|
||||
});
|
||||
vi.clearAllMocks();
|
||||
snapshotUpdateListeners.length = 0;
|
||||
});
|
||||
|
||||
describe("providers snapshot hook cache scope", () => {
|
||||
it("uses separate query keys for home and workspace scopes", () => {
|
||||
describe("providersSnapshotQueryKey", () => {
|
||||
it("uses separate keys for home and workspace scopes", () => {
|
||||
expect(providersSnapshotQueryKey(serverId)).toEqual(["providersSnapshot", serverId, "home"]);
|
||||
expect(providersSnapshotQueryKey(serverId, "/repo-a")).toEqual([
|
||||
"providersSnapshot",
|
||||
@@ -164,118 +89,209 @@ describe("providers snapshot hook cache scope", () => {
|
||||
"/repo-a",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("sends no cwd for settings snapshot loads and refreshes", async () => {
|
||||
enableProvidersSnapshot();
|
||||
mockClient.getProvidersSnapshot.mockResolvedValue(providersSnapshot([]));
|
||||
mockClient.refreshProvidersSnapshot.mockResolvedValue({
|
||||
acknowledged: true,
|
||||
requestId: "settings-refresh",
|
||||
});
|
||||
describe("fetchProvidersSnapshot", () => {
|
||||
it("sends no cwd for the home scope", async () => {
|
||||
const client = createClient({ snapshots: [providersSnapshot([])] });
|
||||
|
||||
const { result } = renderProvidersSnapshotHook();
|
||||
await fetchProvidersSnapshot({ client, cwd: null });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockClient.getProvidersSnapshot).toHaveBeenCalledWith({});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refresh(["codex"]);
|
||||
});
|
||||
|
||||
expect(mockClient.refreshProvidersSnapshot).toHaveBeenCalledWith({ providers: ["codex"] });
|
||||
expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({});
|
||||
expect(client.getCalls).toEqual([{}]);
|
||||
});
|
||||
|
||||
it("sends cwd for workspace snapshot loads and refreshes", async () => {
|
||||
enableProvidersSnapshot();
|
||||
mockClient.getProvidersSnapshot.mockResolvedValue(providersSnapshot([]));
|
||||
mockClient.refreshProvidersSnapshot.mockResolvedValue({
|
||||
acknowledged: true,
|
||||
requestId: "workspace-refresh",
|
||||
it("sends the workspace cwd for the workspace scope", async () => {
|
||||
const client = createClient({ snapshots: [providersSnapshot([])] });
|
||||
|
||||
await fetchProvidersSnapshot({ client, cwd: "/repo-a" });
|
||||
|
||||
expect(client.getCalls).toEqual([{ cwd: "/repo-a" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("refreshAndApplyProvidersSnapshot", () => {
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient();
|
||||
});
|
||||
|
||||
it("refreshes then re-fetches the home snapshot and writes it into the home query cache", async () => {
|
||||
const client = createClient({
|
||||
snapshots: [providersSnapshot([codexEntry("ready", [readyCodexModel])])],
|
||||
});
|
||||
|
||||
const { result } = renderProvidersSnapshotHook({ cwd: "/repo-a" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockClient.getProvidersSnapshot).toHaveBeenCalledWith({ cwd: "/repo-a" });
|
||||
await refreshAndApplyProvidersSnapshot({
|
||||
client,
|
||||
queryClient,
|
||||
serverId,
|
||||
cwd: null,
|
||||
providers: ["codex"],
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refresh(["codex"]);
|
||||
expect(client.refreshCalls).toEqual([{ providers: ["codex"] }]);
|
||||
expect(client.getCalls).toEqual([{}]);
|
||||
expect(queryClient.getQueryData(providersSnapshotQueryKey(serverId))).toEqual(
|
||||
providersSnapshot([codexEntry("ready", [readyCodexModel])]),
|
||||
);
|
||||
});
|
||||
|
||||
it("refreshes then re-fetches the workspace snapshot with the cwd preserved", async () => {
|
||||
const client = createClient({
|
||||
snapshots: [providersSnapshot([codexEntry("ready", [readyCodexModel])])],
|
||||
});
|
||||
|
||||
expect(mockClient.refreshProvidersSnapshot).toHaveBeenCalledWith({
|
||||
await refreshAndApplyProvidersSnapshot({
|
||||
client,
|
||||
queryClient,
|
||||
serverId,
|
||||
cwd: "/repo-a",
|
||||
providers: ["codex"],
|
||||
});
|
||||
expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({ cwd: "/repo-a" });
|
||||
});
|
||||
|
||||
it("routes provider snapshot updates by cwd scope", async () => {
|
||||
enableProvidersSnapshot();
|
||||
mockClient.getProvidersSnapshot.mockResolvedValue(providersSnapshot([]));
|
||||
|
||||
const { result } = renderProvidersSnapshotHook({ cwd: "/repo-a" });
|
||||
|
||||
await waitForSnapshotEntries(result, []);
|
||||
await emitProvidersSnapshotUpdate([codexEntry("ready", [readyCodexModel])], "/repo-b");
|
||||
expect(result.current.entries).toEqual([]);
|
||||
|
||||
await emitProvidersSnapshotUpdate([codexEntry("ready", [readyCodexModel])], "/repo-a");
|
||||
await waitForSnapshotEntries(result, [codexEntry("ready", [readyCodexModel])]);
|
||||
});
|
||||
|
||||
it("applies loading snapshot updates without refetching through the read path", async () => {
|
||||
enableProvidersSnapshot();
|
||||
mockClient.getProvidersSnapshot.mockResolvedValueOnce(
|
||||
providersSnapshot([codexEntry("ready", [])]),
|
||||
expect(client.refreshCalls).toEqual([{ cwd: "/repo-a", providers: ["codex"] }]);
|
||||
expect(client.getCalls).toEqual([{ cwd: "/repo-a" }]);
|
||||
expect(queryClient.getQueryData(providersSnapshotQueryKey(serverId, "/repo-a"))).toEqual(
|
||||
providersSnapshot([codexEntry("ready", [readyCodexModel])]),
|
||||
);
|
||||
|
||||
const { result } = renderProvidersSnapshotHook();
|
||||
|
||||
await waitForSnapshotReads(1);
|
||||
|
||||
const loadingEntries = [codexEntry("loading")];
|
||||
await emitProvidersSnapshotUpdate(loadingEntries);
|
||||
|
||||
await waitForSnapshotEntries(result, loadingEntries);
|
||||
expect(mockClient.getProvidersSnapshot).toHaveBeenCalledTimes(1);
|
||||
expect(mockClient.refreshProvidersSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "missing", entries: [] },
|
||||
{ name: "loading", entries: [codexEntry("loading")] },
|
||||
])(
|
||||
"ensures a selected provider snapshot on selector open when it is $name",
|
||||
async ({ entries }) => {
|
||||
enableProvidersSnapshot();
|
||||
mockClient.getProvidersSnapshot
|
||||
.mockResolvedValueOnce(providersSnapshot(entries))
|
||||
.mockResolvedValueOnce(providersSnapshot([codexEntry("ready", [readyCodexModel])]));
|
||||
it("invalidates every scope under the server when refreshing the home snapshot", async () => {
|
||||
const client = createClient({ snapshots: [providersSnapshot([])] });
|
||||
queryClient.setQueryData(providersSnapshotQueryKey(serverId, "/repo-a"), providersSnapshot([]));
|
||||
queryClient.setQueryData(providersSnapshotQueryKey(serverId, "/repo-b"), providersSnapshot([]));
|
||||
|
||||
const { result } = renderProvidersSnapshotHook();
|
||||
await refreshAndApplyProvidersSnapshot({
|
||||
client,
|
||||
queryClient,
|
||||
serverId,
|
||||
cwd: null,
|
||||
});
|
||||
|
||||
await waitForSnapshotEntries(result, entries);
|
||||
await openSelectorForSelectedProvider(result);
|
||||
await waitForSnapshotReads(2);
|
||||
expect(
|
||||
queryClient.getQueryState(providersSnapshotQueryKey(serverId, "/repo-a"))?.isInvalidated,
|
||||
).toBe(true);
|
||||
expect(
|
||||
queryClient.getQueryState(providersSnapshotQueryKey(serverId, "/repo-b"))?.isInvalidated,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({});
|
||||
expect(mockClient.refreshProvidersSnapshot).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
it("does not invalidate sibling scopes when refreshing a workspace snapshot", async () => {
|
||||
const client = createClient({ snapshots: [providersSnapshot([])] });
|
||||
queryClient.setQueryData(providersSnapshotQueryKey(serverId), providersSnapshot([]));
|
||||
queryClient.setQueryData(providersSnapshotQueryKey(serverId, "/repo-b"), providersSnapshot([]));
|
||||
|
||||
it("does not ensure a selected provider snapshot on selector open when the provider is ready with no models", async () => {
|
||||
enableProvidersSnapshot();
|
||||
mockClient.getProvidersSnapshot.mockResolvedValue(providersSnapshot([codexEntry("ready", [])]));
|
||||
await refreshAndApplyProvidersSnapshot({
|
||||
client,
|
||||
queryClient,
|
||||
serverId,
|
||||
cwd: "/repo-a",
|
||||
});
|
||||
|
||||
const { result } = renderProvidersSnapshotHook();
|
||||
expect(queryClient.getQueryState(providersSnapshotQueryKey(serverId))?.isInvalidated).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
queryClient.getQueryState(providersSnapshotQueryKey(serverId, "/repo-b"))?.isInvalidated,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
await waitForSnapshotEntries(result, [codexEntry("ready", [])]);
|
||||
await openSelectorForSelectedProvider(result);
|
||||
describe("applyProvidersSnapshotUpdate", () => {
|
||||
let queryClient: QueryClient;
|
||||
|
||||
expect(mockClient.getProvidersSnapshot).toHaveBeenCalledTimes(1);
|
||||
expect(mockClient.refreshProvidersSnapshot).not.toHaveBeenCalled();
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient();
|
||||
});
|
||||
|
||||
function updateMessage(
|
||||
entries: ProviderSnapshotEntry[],
|
||||
cwd?: string,
|
||||
): ProvidersSnapshotUpdateMessage {
|
||||
return {
|
||||
type: "providers_snapshot_update",
|
||||
payload: {
|
||||
...(cwd ? { cwd } : {}),
|
||||
entries,
|
||||
generatedAt: "2026-01-01T00:00:01.000Z",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("routes updates to the home query cache when the message carries no cwd", () => {
|
||||
applyProvidersSnapshotUpdate({
|
||||
serverId,
|
||||
queryClient,
|
||||
message: updateMessage([codexEntry("ready", [readyCodexModel])]),
|
||||
});
|
||||
|
||||
expect(queryClient.getQueryData(providersSnapshotQueryKey(serverId))).toEqual({
|
||||
entries: [codexEntry("ready", [readyCodexModel])],
|
||||
generatedAt: "2026-01-01T00:00:01.000Z",
|
||||
requestId: "providers_snapshot_update",
|
||||
});
|
||||
});
|
||||
|
||||
it("routes workspace updates to the matching scope without touching siblings", () => {
|
||||
queryClient.setQueryData(providersSnapshotQueryKey(serverId, "/repo-b"), providersSnapshot([]));
|
||||
|
||||
applyProvidersSnapshotUpdate({
|
||||
serverId,
|
||||
queryClient,
|
||||
message: updateMessage([codexEntry("ready", [readyCodexModel])], "/repo-a"),
|
||||
});
|
||||
|
||||
expect(queryClient.getQueryData(providersSnapshotQueryKey(serverId, "/repo-a"))).toEqual({
|
||||
entries: [codexEntry("ready", [readyCodexModel])],
|
||||
generatedAt: "2026-01-01T00:00:01.000Z",
|
||||
requestId: "providers_snapshot_update",
|
||||
});
|
||||
expect(queryClient.getQueryData(providersSnapshotQueryKey(serverId, "/repo-b"))).toEqual(
|
||||
providersSnapshot([]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectorOpenRefetchDecision", () => {
|
||||
it("refetches stale entries when no provider is selected", () => {
|
||||
expect(
|
||||
selectorOpenRefetchDecision({
|
||||
entries: [codexEntry("ready", [readyCodexModel])],
|
||||
selectedProvider: null,
|
||||
}),
|
||||
).toBe("refetch-stale");
|
||||
});
|
||||
|
||||
it("forces a refetch when the selected provider has no entry", () => {
|
||||
expect(selectorOpenRefetchDecision({ entries: [], selectedProvider: "codex" })).toBe(
|
||||
"refetch-always",
|
||||
);
|
||||
});
|
||||
|
||||
it("forces a refetch when the selected provider is still loading", () => {
|
||||
expect(
|
||||
selectorOpenRefetchDecision({
|
||||
entries: [codexEntry("loading")],
|
||||
selectedProvider: "codex",
|
||||
}),
|
||||
).toBe("refetch-always");
|
||||
});
|
||||
|
||||
it("keeps a stale-only refetch when the selected provider is ready with no models", () => {
|
||||
expect(
|
||||
selectorOpenRefetchDecision({
|
||||
entries: [codexEntry("ready", [])],
|
||||
selectedProvider: "codex",
|
||||
}),
|
||||
).toBe("refetch-stale");
|
||||
});
|
||||
|
||||
it("keeps a stale-only refetch when the selected provider is ready with models", () => {
|
||||
expect(
|
||||
selectorOpenRefetchDecision({
|
||||
entries: [codexEntry("ready", [readyCodexModel])],
|
||||
selectedProvider: "codex",
|
||||
}),
|
||||
).toBe("refetch-stale");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
|
||||
import type { AgentProvider, ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
@@ -13,8 +13,85 @@ import {
|
||||
providersSnapshotRequestOptions,
|
||||
} from "@/hooks/providers-snapshot-query";
|
||||
|
||||
type GetProvidersSnapshotResult = Awaited<ReturnType<DaemonClient["getProvidersSnapshot"]>>;
|
||||
type RefreshProvidersSnapshotResult = Awaited<ReturnType<DaemonClient["refreshProvidersSnapshot"]>>;
|
||||
|
||||
export { providersSnapshotQueryKey, providersSnapshotQueryRoot };
|
||||
|
||||
export type ProvidersSnapshotClient = Pick<
|
||||
DaemonClient,
|
||||
"getProvidersSnapshot" | "refreshProvidersSnapshot"
|
||||
>;
|
||||
|
||||
export interface ProvidersSnapshotUpdateMessage {
|
||||
type: "providers_snapshot_update";
|
||||
payload: {
|
||||
cwd?: string;
|
||||
entries: ProviderSnapshotEntry[];
|
||||
generatedAt: string;
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchProvidersSnapshot(input: {
|
||||
client: ProvidersSnapshotClient;
|
||||
cwd: string | null;
|
||||
}): Promise<GetProvidersSnapshotResult> {
|
||||
return input.client.getProvidersSnapshot(providersSnapshotRequestOptions({ cwd: input.cwd }));
|
||||
}
|
||||
|
||||
export async function refreshAndApplyProvidersSnapshot(input: {
|
||||
client: ProvidersSnapshotClient;
|
||||
queryClient: QueryClient;
|
||||
serverId: string;
|
||||
cwd: string | null;
|
||||
providers?: AgentProvider[];
|
||||
}): Promise<RefreshProvidersSnapshotResult> {
|
||||
const refreshResult = await input.client.refreshProvidersSnapshot(
|
||||
providersSnapshotRequestOptions({ cwd: input.cwd, providers: input.providers }),
|
||||
);
|
||||
const snapshot = await fetchProvidersSnapshot({ client: input.client, cwd: input.cwd });
|
||||
input.queryClient.setQueryData(providersSnapshotQueryKey(input.serverId, input.cwd), snapshot);
|
||||
if (isProvidersSnapshotHomeScope(input.cwd)) {
|
||||
void input.queryClient.invalidateQueries({
|
||||
queryKey: providersSnapshotQueryRoot(input.serverId),
|
||||
exact: false,
|
||||
});
|
||||
}
|
||||
return refreshResult;
|
||||
}
|
||||
|
||||
export function applyProvidersSnapshotUpdate(input: {
|
||||
serverId: string;
|
||||
queryClient: QueryClient;
|
||||
message: ProvidersSnapshotUpdateMessage;
|
||||
}): void {
|
||||
if (input.message.type !== "providers_snapshot_update") {
|
||||
return;
|
||||
}
|
||||
const queryKey = providersSnapshotQueryKey(input.serverId, input.message.payload.cwd);
|
||||
input.queryClient.setQueryData(queryKey, {
|
||||
entries: input.message.payload.entries,
|
||||
generatedAt: input.message.payload.generatedAt,
|
||||
requestId: "providers_snapshot_update",
|
||||
});
|
||||
}
|
||||
|
||||
export type SelectorOpenRefetchDecision = "refetch-stale" | "refetch-always";
|
||||
|
||||
export function selectorOpenRefetchDecision(input: {
|
||||
entries: ProviderSnapshotEntry[] | undefined;
|
||||
selectedProvider: AgentProvider | null | undefined;
|
||||
}): SelectorOpenRefetchDecision {
|
||||
if (!input.selectedProvider) {
|
||||
return "refetch-stale";
|
||||
}
|
||||
const selectedEntry = input.entries?.find((entry) => entry.provider === input.selectedProvider);
|
||||
if (!selectedEntry || selectedEntry.status === "loading") {
|
||||
return "refetch-always";
|
||||
}
|
||||
return "refetch-stale";
|
||||
}
|
||||
|
||||
interface UseProvidersSnapshotResult {
|
||||
entries: ProviderSnapshotEntry[] | undefined;
|
||||
isLoading: boolean;
|
||||
@@ -45,8 +122,6 @@ export function useProvidersSnapshot(
|
||||
);
|
||||
|
||||
const queryKey = useMemo(() => providersSnapshotQueryKey(serverId, cwd), [cwd, serverId]);
|
||||
const queryRoot = useMemo(() => providersSnapshotQueryRoot(serverId), [serverId]);
|
||||
const requestOptions = useMemo(() => providersSnapshotRequestOptions({ cwd }), [cwd]);
|
||||
|
||||
const snapshotQuery = useQuery({
|
||||
queryKey,
|
||||
@@ -56,16 +131,22 @@ export function useProvidersSnapshot(
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
return client.getProvidersSnapshot(requestOptions);
|
||||
return fetchProvidersSnapshot({ client, cwd });
|
||||
},
|
||||
});
|
||||
|
||||
const refreshMutation = useMutation({
|
||||
mutationFn: async (providers?: AgentProvider[]) => {
|
||||
if (!client) {
|
||||
if (!client || !serverId) {
|
||||
return;
|
||||
}
|
||||
await client.refreshProvidersSnapshot(providersSnapshotRequestOptions({ cwd, providers }));
|
||||
await refreshAndApplyProvidersSnapshot({
|
||||
client,
|
||||
queryClient,
|
||||
serverId,
|
||||
cwd,
|
||||
providers,
|
||||
});
|
||||
},
|
||||
});
|
||||
const { mutateAsync: refreshSnapshot, isPending: isRefreshing } = refreshMutation;
|
||||
@@ -79,46 +160,27 @@ export function useProvidersSnapshot(
|
||||
if (message.type !== "providers_snapshot_update") {
|
||||
return;
|
||||
}
|
||||
const updateQueryKey = providersSnapshotQueryKey(serverId, message.payload.cwd);
|
||||
queryClient.setQueryData(updateQueryKey, {
|
||||
entries: message.payload.entries,
|
||||
generatedAt: message.payload.generatedAt,
|
||||
requestId: "providers_snapshot_update",
|
||||
});
|
||||
applyProvidersSnapshotUpdate({ serverId, queryClient, message });
|
||||
});
|
||||
}, [client, enabled, isConnected, queryClient, serverId, supportsSnapshot]);
|
||||
|
||||
const refresh = useCallback(
|
||||
async (providers?: AgentProvider[]) => {
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
await refreshSnapshot(providers);
|
||||
const snapshot = await client.getProvidersSnapshot(requestOptions);
|
||||
queryClient.setQueryData(queryKey, snapshot);
|
||||
if (isProvidersSnapshotHomeScope(cwd)) {
|
||||
void queryClient.invalidateQueries({ queryKey: queryRoot, exact: false });
|
||||
}
|
||||
},
|
||||
[client, cwd, queryClient, queryKey, queryRoot, refreshSnapshot, requestOptions],
|
||||
[refreshSnapshot],
|
||||
);
|
||||
|
||||
const refetchIfStale = useCallback(
|
||||
(selectedProvider?: AgentProvider | null) => {
|
||||
if (!selectedProvider) {
|
||||
void queryClient.refetchQueries({ queryKey, type: "active", stale: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedEntry = snapshotQuery.data?.entries.find(
|
||||
(entry) => entry.provider === selectedProvider,
|
||||
);
|
||||
|
||||
if (!selectedEntry || selectedEntry.status === "loading") {
|
||||
const decision = selectorOpenRefetchDecision({
|
||||
entries: snapshotQuery.data?.entries,
|
||||
selectedProvider,
|
||||
});
|
||||
if (decision === "refetch-always") {
|
||||
void queryClient.refetchQueries({ queryKey, type: "active" });
|
||||
return;
|
||||
}
|
||||
|
||||
void queryClient.refetchQueries({ queryKey, type: "active", stale: true });
|
||||
},
|
||||
[queryClient, queryKey, snapshotQuery.data?.entries],
|
||||
@@ -143,10 +205,9 @@ export function prefetchProvidersSnapshot(
|
||||
): void {
|
||||
const cwd = normalizeProvidersSnapshotCwd(options.cwd);
|
||||
const queryKey = providersSnapshotQueryKey(serverId, cwd);
|
||||
const requestOptions = providersSnapshotRequestOptions({ cwd });
|
||||
void singletonQueryClient.prefetchQuery({
|
||||
queryKey,
|
||||
staleTime: 60_000,
|
||||
queryFn: () => client.getProvidersSnapshot(requestOptions),
|
||||
queryFn: () => fetchProvidersSnapshot({ client, cwd }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,249 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
const asyncStorageMock = vi.hoisted(() => ({
|
||||
getItem: vi.fn<(_: string) => Promise<string | null>>(),
|
||||
setItem: vi.fn<(_: string, __: string) => Promise<void>>(),
|
||||
}));
|
||||
|
||||
const electronRuntimeState = vi.hoisted(() => ({
|
||||
isElectron: false,
|
||||
}));
|
||||
|
||||
const desktopSettingsMock = vi.hoisted(() => ({
|
||||
loadDesktopSettings: vi.fn<() => Promise<unknown>>(),
|
||||
migrateLegacyDesktopSettings: vi.fn<(_: unknown) => Promise<void>>(),
|
||||
}));
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||
default: asyncStorageMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/desktop/host", () => ({
|
||||
isElectronRuntime: () => electronRuntimeState.isElectron,
|
||||
}));
|
||||
|
||||
vi.mock("@/desktop/settings/desktop-settings", () => desktopSettingsMock);
|
||||
|
||||
describe("use-settings", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
asyncStorageMock.getItem.mockReset();
|
||||
asyncStorageMock.setItem.mockReset();
|
||||
electronRuntimeState.isElectron = false;
|
||||
desktopSettingsMock.loadDesktopSettings.mockReset();
|
||||
desktopSettingsMock.migrateLegacyDesktopSettings.mockReset();
|
||||
});
|
||||
|
||||
it("defaults built-in daemon management to enabled when storage is empty", async () => {
|
||||
asyncStorageMock.getItem.mockResolvedValue(null);
|
||||
asyncStorageMock.setItem.mockResolvedValue();
|
||||
|
||||
const mod = await import("./use-settings");
|
||||
const result = await mod.loadSettingsFromStorage();
|
||||
|
||||
expect(result).toEqual(mod.DEFAULT_APP_SETTINGS);
|
||||
expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
|
||||
mod.APP_SETTINGS_KEY,
|
||||
JSON.stringify(mod.DEFAULT_CLIENT_SETTINGS),
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults theme to auto when storage is empty", async () => {
|
||||
asyncStorageMock.getItem.mockResolvedValue(null);
|
||||
asyncStorageMock.setItem.mockResolvedValue();
|
||||
|
||||
const mod = await import("./use-settings");
|
||||
const result = await mod.loadSettingsFromStorage();
|
||||
|
||||
expect(result.theme).toBe("auto");
|
||||
});
|
||||
|
||||
it("defaults release channel to stable when storage is empty", async () => {
|
||||
asyncStorageMock.getItem.mockResolvedValue(null);
|
||||
asyncStorageMock.setItem.mockResolvedValue();
|
||||
|
||||
const mod = await import("./use-settings");
|
||||
const result = await mod.loadSettingsFromStorage();
|
||||
|
||||
expect(result.releaseChannel).toBe("stable");
|
||||
});
|
||||
|
||||
it("ignores renderer-owned daemon management state outside Electron", async () => {
|
||||
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
|
||||
if (key === "@paseo:app-settings") {
|
||||
return JSON.stringify({
|
||||
theme: "light",
|
||||
manageBuiltInDaemon: false,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const mod = await import("./use-settings");
|
||||
const result = await mod.loadSettingsFromStorage();
|
||||
|
||||
expect(result).toEqual({
|
||||
theme: "light",
|
||||
manageBuiltInDaemon: true,
|
||||
sendBehavior: "interrupt",
|
||||
serviceUrlBehavior: "ask",
|
||||
terminalScrollbackLines: 10_000,
|
||||
releaseChannel: "stable",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores renderer-owned release channel outside Electron", async () => {
|
||||
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
|
||||
if (key === "@paseo:app-settings") {
|
||||
return JSON.stringify({
|
||||
releaseChannel: "beta",
|
||||
});
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const mod = await import("./use-settings");
|
||||
const result = await mod.loadSettingsFromStorage();
|
||||
|
||||
expect(result.releaseChannel).toBe("stable");
|
||||
});
|
||||
|
||||
it("keeps legacy AsyncStorage migration for client settings only", async () => {
|
||||
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
|
||||
if (key === "@paseo:app-settings") {
|
||||
return null;
|
||||
}
|
||||
if (key === "@paseo:settings") {
|
||||
return JSON.stringify({
|
||||
theme: "dark",
|
||||
manageBuiltInDaemon: false,
|
||||
releaseChannel: "beta",
|
||||
});
|
||||
}
|
||||
return null;
|
||||
});
|
||||
asyncStorageMock.setItem.mockResolvedValue();
|
||||
|
||||
const mod = await import("./use-settings");
|
||||
const result = await mod.loadAppSettingsFromStorage();
|
||||
|
||||
expect(result).toEqual({
|
||||
theme: "dark",
|
||||
sendBehavior: "interrupt",
|
||||
serviceUrlBehavior: "ask",
|
||||
terminalScrollbackLines: 10_000,
|
||||
});
|
||||
expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
|
||||
mod.APP_SETTINGS_KEY,
|
||||
JSON.stringify(result),
|
||||
);
|
||||
});
|
||||
|
||||
it("migrates legacy desktop-owned settings through Electron before reading effective settings", async () => {
|
||||
electronRuntimeState.isElectron = true;
|
||||
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
|
||||
if (key === "@paseo:app-settings") {
|
||||
return JSON.stringify({
|
||||
theme: "light",
|
||||
manageBuiltInDaemon: false,
|
||||
releaseChannel: "beta",
|
||||
});
|
||||
}
|
||||
return null;
|
||||
});
|
||||
desktopSettingsMock.migrateLegacyDesktopSettings.mockResolvedValue();
|
||||
desktopSettingsMock.loadDesktopSettings.mockResolvedValue({
|
||||
releaseChannel: "beta",
|
||||
daemon: {
|
||||
manageBuiltInDaemon: false,
|
||||
keepRunningAfterQuit: true,
|
||||
},
|
||||
});
|
||||
|
||||
const mod = await import("./use-settings");
|
||||
const result = await mod.loadSettingsFromStorage();
|
||||
|
||||
expect(desktopSettingsMock.migrateLegacyDesktopSettings).toHaveBeenCalledWith({
|
||||
manageBuiltInDaemon: false,
|
||||
releaseChannel: "beta",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
theme: "light",
|
||||
sendBehavior: "interrupt",
|
||||
serviceUrlBehavior: "ask",
|
||||
terminalScrollbackLines: 10_000,
|
||||
manageBuiltInDaemon: false,
|
||||
releaseChannel: "beta",
|
||||
});
|
||||
});
|
||||
|
||||
it("skips desktop IPC when loading effective settings outside Electron", async () => {
|
||||
asyncStorageMock.getItem.mockResolvedValue(
|
||||
JSON.stringify({
|
||||
theme: "light",
|
||||
}),
|
||||
);
|
||||
|
||||
const mod = await import("./use-settings");
|
||||
const result = await mod.loadSettingsFromStorage();
|
||||
|
||||
expect(result).toEqual({
|
||||
theme: "light",
|
||||
sendBehavior: "interrupt",
|
||||
serviceUrlBehavior: "ask",
|
||||
terminalScrollbackLines: 10_000,
|
||||
manageBuiltInDaemon: true,
|
||||
releaseChannel: "stable",
|
||||
});
|
||||
});
|
||||
|
||||
it("loads configured terminal scrollback lines from app settings", async () => {
|
||||
asyncStorageMock.getItem.mockResolvedValue(
|
||||
JSON.stringify({
|
||||
terminalScrollbackLines: 42_000,
|
||||
}),
|
||||
);
|
||||
|
||||
const mod = await import("./use-settings");
|
||||
const result = await mod.loadAppSettingsFromStorage();
|
||||
|
||||
expect(result.terminalScrollbackLines).toBe(42_000);
|
||||
});
|
||||
|
||||
it("saves terminal scrollback through app settings persistence", async () => {
|
||||
asyncStorageMock.setItem.mockResolvedValue();
|
||||
|
||||
const mod = await import("./use-settings");
|
||||
asyncStorageMock.getItem.mockResolvedValue(JSON.stringify(mod.DEFAULT_CLIENT_SETTINGS));
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
await mod.saveAppSettings({
|
||||
queryClient,
|
||||
updates: { terminalScrollbackLines: 42_000 },
|
||||
});
|
||||
|
||||
expect(asyncStorageMock.setItem).toHaveBeenLastCalledWith(
|
||||
mod.APP_SETTINGS_KEY,
|
||||
JSON.stringify({
|
||||
...mod.DEFAULT_CLIENT_SETTINGS,
|
||||
terminalScrollbackLines: 42_000,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes terminal scrollback lines from storage", async () => {
|
||||
asyncStorageMock.getItem.mockResolvedValue(
|
||||
JSON.stringify({
|
||||
terminalScrollbackLines: 1_000_000.9,
|
||||
}),
|
||||
);
|
||||
|
||||
const mod = await import("./use-settings");
|
||||
const result = await mod.loadAppSettingsFromStorage();
|
||||
|
||||
expect(result.terminalScrollbackLines).toBe(1_000_000);
|
||||
expect(mod.parseTerminalScrollbackLines("-10")).toBe(0);
|
||||
expect(mod.parseTerminalScrollbackLines("abc")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,325 +0,0 @@
|
||||
import { useCallback } from "react";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
|
||||
import { queryClient as appQueryClient } from "@/query/query-client";
|
||||
import {
|
||||
DEFAULT_DESKTOP_SETTINGS,
|
||||
loadDesktopSettings,
|
||||
migrateLegacyDesktopSettings,
|
||||
useDesktopSettings,
|
||||
} from "@/desktop/settings/desktop-settings";
|
||||
import { isElectronRuntime } from "@/desktop/host";
|
||||
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
|
||||
|
||||
export const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||
const LEGACY_SETTINGS_KEY = "@paseo:settings";
|
||||
const APP_SETTINGS_QUERY_KEY = ["app-settings"];
|
||||
|
||||
export type SendBehavior = "interrupt" | "queue";
|
||||
export type ReleaseChannel = "stable" | "beta";
|
||||
export type ServiceUrlBehavior = "ask" | "in-app" | "external";
|
||||
|
||||
const VALID_THEMES = new Set<string>([...Object.keys(THEME_TO_UNISTYLES), "auto"]);
|
||||
const VALID_SERVICE_URL_BEHAVIORS = new Set<ServiceUrlBehavior>(["ask", "in-app", "external"]);
|
||||
export const DEFAULT_TERMINAL_SCROLLBACK_LINES = 10_000;
|
||||
export const MIN_TERMINAL_SCROLLBACK_LINES = 0;
|
||||
export const MAX_TERMINAL_SCROLLBACK_LINES = 1_000_000;
|
||||
|
||||
export interface AppSettings {
|
||||
theme: ThemeName | "auto";
|
||||
sendBehavior: SendBehavior;
|
||||
serviceUrlBehavior: ServiceUrlBehavior;
|
||||
terminalScrollbackLines: number;
|
||||
}
|
||||
|
||||
export interface Settings extends AppSettings {
|
||||
manageBuiltInDaemon: boolean;
|
||||
releaseChannel: ReleaseChannel;
|
||||
}
|
||||
|
||||
export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
|
||||
theme: "auto",
|
||||
sendBehavior: "interrupt",
|
||||
serviceUrlBehavior: "ask",
|
||||
terminalScrollbackLines: DEFAULT_TERMINAL_SCROLLBACK_LINES,
|
||||
};
|
||||
|
||||
export const DEFAULT_APP_SETTINGS: Settings = {
|
||||
...DEFAULT_CLIENT_SETTINGS,
|
||||
manageBuiltInDaemon: true,
|
||||
releaseChannel: "stable",
|
||||
};
|
||||
|
||||
export interface UseAppSettingsReturn {
|
||||
settings: AppSettings;
|
||||
isLoading: boolean;
|
||||
error: unknown;
|
||||
updateSettings: (updates: Partial<AppSettings>) => Promise<void>;
|
||||
resetSettings: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface UseSettingsReturn {
|
||||
settings: Settings;
|
||||
isLoading: boolean;
|
||||
error: unknown;
|
||||
updateSettings: (updates: Partial<Settings>) => Promise<void>;
|
||||
resetSettings: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useAppSettings(): UseAppSettingsReturn {
|
||||
const queryClient = useQueryClient();
|
||||
const { data, isPending, error } = useQuery({
|
||||
queryKey: APP_SETTINGS_QUERY_KEY,
|
||||
queryFn: loadAppSettingsFromStorage,
|
||||
staleTime: Infinity,
|
||||
gcTime: Infinity,
|
||||
});
|
||||
|
||||
const updateSettings = useCallback(
|
||||
async (updates: Partial<AppSettings>) => {
|
||||
try {
|
||||
await saveAppSettings({ queryClient, updates });
|
||||
} catch (err) {
|
||||
console.error("[AppSettings] Failed to save settings:", err);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
const resetSettings = useCallback(async () => {
|
||||
try {
|
||||
const next = { ...DEFAULT_CLIENT_SETTINGS };
|
||||
queryClient.setQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY, next);
|
||||
await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(next));
|
||||
} catch (err) {
|
||||
console.error("[AppSettings] Failed to reset settings:", err);
|
||||
throw err;
|
||||
}
|
||||
}, [queryClient]);
|
||||
|
||||
return {
|
||||
settings: data ?? DEFAULT_CLIENT_SETTINGS,
|
||||
isLoading: isPending,
|
||||
error: error ?? null,
|
||||
updateSettings,
|
||||
resetSettings,
|
||||
};
|
||||
}
|
||||
|
||||
export function useSettings(): UseSettingsReturn {
|
||||
const appSettings = useAppSettings();
|
||||
const desktopSettings = useDesktopSettings();
|
||||
|
||||
const updateSettings = useCallback(
|
||||
async (updates: Partial<Settings>) => {
|
||||
const appUpdates: Partial<AppSettings> = {};
|
||||
if (updates.theme !== undefined) {
|
||||
appUpdates.theme = updates.theme;
|
||||
}
|
||||
if (updates.sendBehavior !== undefined) {
|
||||
appUpdates.sendBehavior = updates.sendBehavior;
|
||||
}
|
||||
if (updates.serviceUrlBehavior !== undefined) {
|
||||
appUpdates.serviceUrlBehavior = updates.serviceUrlBehavior;
|
||||
}
|
||||
if (updates.terminalScrollbackLines !== undefined) {
|
||||
appUpdates.terminalScrollbackLines = updates.terminalScrollbackLines;
|
||||
}
|
||||
const promises: Promise<void>[] = [];
|
||||
if (Object.keys(appUpdates).length > 0) {
|
||||
promises.push(appSettings.updateSettings(appUpdates));
|
||||
}
|
||||
|
||||
if (isElectronRuntime()) {
|
||||
const desktopUpdates: Parameters<typeof desktopSettings.updateSettings>[0] = {};
|
||||
if (updates.manageBuiltInDaemon !== undefined) {
|
||||
desktopUpdates.daemon = {
|
||||
manageBuiltInDaemon: updates.manageBuiltInDaemon,
|
||||
};
|
||||
}
|
||||
if (updates.releaseChannel !== undefined) {
|
||||
desktopUpdates.releaseChannel = updates.releaseChannel;
|
||||
}
|
||||
if (Object.keys(desktopUpdates).length > 0) {
|
||||
promises.push(desktopSettings.updateSettings(desktopUpdates));
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
},
|
||||
[appSettings, desktopSettings],
|
||||
);
|
||||
|
||||
const resetSettings = useCallback(async () => {
|
||||
const resets: Promise<void>[] = [appSettings.resetSettings()];
|
||||
if (isElectronRuntime()) {
|
||||
resets.push(desktopSettings.updateSettings(DEFAULT_DESKTOP_SETTINGS));
|
||||
}
|
||||
await Promise.all(resets);
|
||||
}, [appSettings, desktopSettings]);
|
||||
|
||||
return {
|
||||
settings: {
|
||||
...DEFAULT_APP_SETTINGS,
|
||||
...appSettings.settings,
|
||||
manageBuiltInDaemon: desktopSettings.settings.daemon.manageBuiltInDaemon,
|
||||
releaseChannel: desktopSettings.settings.releaseChannel,
|
||||
},
|
||||
isLoading: appSettings.isLoading || desktopSettings.isLoading,
|
||||
error: appSettings.error ?? desktopSettings.error,
|
||||
updateSettings,
|
||||
resetSettings,
|
||||
};
|
||||
}
|
||||
|
||||
export async function persistAppSettings(updates: Partial<AppSettings>): Promise<void> {
|
||||
await saveAppSettings({ queryClient: appQueryClient, updates });
|
||||
}
|
||||
|
||||
export async function saveAppSettings(input: {
|
||||
queryClient: QueryClient;
|
||||
updates: Partial<AppSettings>;
|
||||
}): Promise<void> {
|
||||
const current =
|
||||
input.queryClient.getQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY) ??
|
||||
(await loadAppSettingsFromStorage());
|
||||
const next = { ...current, ...input.updates };
|
||||
input.queryClient.setQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY, next);
|
||||
await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(next));
|
||||
}
|
||||
|
||||
export async function loadAppSettingsFromStorage(): Promise<AppSettings> {
|
||||
try {
|
||||
const stored = await AsyncStorage.getItem(APP_SETTINGS_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored) as Partial<AppSettings>;
|
||||
return { ...DEFAULT_CLIENT_SETTINGS, ...pickAppSettings(parsed) };
|
||||
}
|
||||
|
||||
const legacyStored = await AsyncStorage.getItem(LEGACY_SETTINGS_KEY);
|
||||
if (legacyStored) {
|
||||
const legacyParsed = JSON.parse(legacyStored) as Record<string, unknown>;
|
||||
const next = {
|
||||
...DEFAULT_CLIENT_SETTINGS,
|
||||
...pickAppSettingsFromLegacy(legacyParsed),
|
||||
} satisfies AppSettings;
|
||||
await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(next));
|
||||
return next;
|
||||
}
|
||||
|
||||
await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(DEFAULT_CLIENT_SETTINGS));
|
||||
return DEFAULT_CLIENT_SETTINGS;
|
||||
} catch (error) {
|
||||
console.error("[AppSettings] Failed to load settings:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadSettingsFromStorage(): Promise<Settings> {
|
||||
const legacyDesktopSettings = isElectronRuntime()
|
||||
? await loadLegacyDesktopSettingsFromStorage()
|
||||
: null;
|
||||
const appSettings = await loadAppSettingsFromStorage();
|
||||
|
||||
if (!isElectronRuntime()) {
|
||||
return {
|
||||
...DEFAULT_APP_SETTINGS,
|
||||
...appSettings,
|
||||
};
|
||||
}
|
||||
|
||||
if (legacyDesktopSettings) {
|
||||
await migrateLegacyDesktopSettings(legacyDesktopSettings);
|
||||
}
|
||||
|
||||
const desktopSettings = await loadDesktopSettings();
|
||||
return {
|
||||
...DEFAULT_APP_SETTINGS,
|
||||
...appSettings,
|
||||
manageBuiltInDaemon: desktopSettings.daemon.manageBuiltInDaemon,
|
||||
releaseChannel: desktopSettings.releaseChannel,
|
||||
};
|
||||
}
|
||||
|
||||
function pickAppSettings(stored: Partial<AppSettings>): Partial<AppSettings> {
|
||||
const result: Partial<AppSettings> = {};
|
||||
if (typeof stored.theme === "string" && VALID_THEMES.has(stored.theme)) {
|
||||
result.theme = stored.theme;
|
||||
}
|
||||
if (stored.sendBehavior === "interrupt" || stored.sendBehavior === "queue") {
|
||||
result.sendBehavior = stored.sendBehavior;
|
||||
}
|
||||
if (
|
||||
typeof stored.serviceUrlBehavior === "string" &&
|
||||
VALID_SERVICE_URL_BEHAVIORS.has(stored.serviceUrlBehavior)
|
||||
) {
|
||||
result.serviceUrlBehavior = stored.serviceUrlBehavior;
|
||||
}
|
||||
const terminalScrollbackLines = parseTerminalScrollbackLines(stored.terminalScrollbackLines);
|
||||
if (terminalScrollbackLines !== null) {
|
||||
result.terminalScrollbackLines = terminalScrollbackLines;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function pickAppSettingsFromLegacy(legacy: Record<string, unknown>): Partial<AppSettings> {
|
||||
const result: Partial<AppSettings> = {};
|
||||
if (legacy.theme === "dark" || legacy.theme === "light" || legacy.theme === "auto") {
|
||||
result.theme = legacy.theme;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseTerminalScrollbackLines(value: unknown): number | null {
|
||||
let numericValue = NaN;
|
||||
if (typeof value === "number") {
|
||||
numericValue = value;
|
||||
} else if (typeof value === "string" && value.trim().length > 0) {
|
||||
numericValue = Number(value);
|
||||
}
|
||||
if (!Number.isFinite(numericValue)) {
|
||||
return null;
|
||||
}
|
||||
return Math.min(
|
||||
MAX_TERMINAL_SCROLLBACK_LINES,
|
||||
Math.max(MIN_TERMINAL_SCROLLBACK_LINES, Math.floor(numericValue)),
|
||||
);
|
||||
}
|
||||
|
||||
async function loadLegacyDesktopSettingsFromStorage(): Promise<{
|
||||
manageBuiltInDaemon?: boolean;
|
||||
releaseChannel?: ReleaseChannel;
|
||||
} | null> {
|
||||
const stored = await loadRendererSettingsPayload();
|
||||
if (!stored) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result: {
|
||||
manageBuiltInDaemon?: boolean;
|
||||
releaseChannel?: ReleaseChannel;
|
||||
} = {};
|
||||
|
||||
if (typeof stored.manageBuiltInDaemon === "boolean") {
|
||||
result.manageBuiltInDaemon = stored.manageBuiltInDaemon;
|
||||
}
|
||||
if (stored.releaseChannel === "stable" || stored.releaseChannel === "beta") {
|
||||
result.releaseChannel = stored.releaseChannel;
|
||||
}
|
||||
|
||||
return Object.keys(result).length > 0 ? result : null;
|
||||
}
|
||||
|
||||
async function loadRendererSettingsPayload(): Promise<Record<string, unknown> | null> {
|
||||
const current = await AsyncStorage.getItem(APP_SETTINGS_KEY);
|
||||
if (current) {
|
||||
return JSON.parse(current) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
const legacy = await AsyncStorage.getItem(LEGACY_SETTINGS_KEY);
|
||||
if (!legacy) {
|
||||
return null;
|
||||
}
|
||||
return JSON.parse(legacy) as Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { DesktopSettings } from "@/desktop/settings/desktop-settings";
|
||||
import type { DesktopSettingsBridge, KeyValueStorage, ReleaseChannel } from "./storage";
|
||||
|
||||
export interface InMemoryKeyValueStorage extends KeyValueStorage {
|
||||
readonly entries: Map<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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface FakeDesktopBridge extends DesktopSettingsBridge {
|
||||
readonly migrationsApplied: Array<{
|
||||
manageBuiltInDaemon?: boolean;
|
||||
releaseChannel?: ReleaseChannel;
|
||||
}>;
|
||||
}
|
||||
|
||||
const DEFAULT_DESKTOP: DesktopSettings = {
|
||||
releaseChannel: "stable",
|
||||
daemon: {
|
||||
manageBuiltInDaemon: true,
|
||||
keepRunningAfterQuit: true,
|
||||
},
|
||||
};
|
||||
|
||||
export function createFakeDesktopBridge(
|
||||
options: {
|
||||
isElectron?: boolean;
|
||||
settings?: DesktopSettings;
|
||||
} = {},
|
||||
): FakeDesktopBridge {
|
||||
const isElectron = options.isElectron ?? false;
|
||||
const settings = options.settings ?? DEFAULT_DESKTOP;
|
||||
const migrationsApplied: FakeDesktopBridge["migrationsApplied"] = [];
|
||||
return {
|
||||
migrationsApplied,
|
||||
isElectron: () => isElectron,
|
||||
async loadDesktopSettings() {
|
||||
return settings;
|
||||
},
|
||||
async migrateLegacyDesktopSettings(input) {
|
||||
migrationsApplied.push(input);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { useCallback } from "react";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
|
||||
import { queryClient as appQueryClient } from "@/query/query-client";
|
||||
import {
|
||||
DEFAULT_DESKTOP_SETTINGS,
|
||||
loadDesktopSettings,
|
||||
migrateLegacyDesktopSettings,
|
||||
useDesktopSettings,
|
||||
} from "@/desktop/settings/desktop-settings";
|
||||
import { isElectronRuntime } from "@/desktop/host";
|
||||
import {
|
||||
APP_SETTINGS_KEY,
|
||||
APP_SETTINGS_QUERY_KEY,
|
||||
DEFAULT_APP_SETTINGS,
|
||||
DEFAULT_CLIENT_SETTINGS,
|
||||
DEFAULT_TERMINAL_SCROLLBACK_LINES,
|
||||
MAX_TERMINAL_SCROLLBACK_LINES,
|
||||
MIN_TERMINAL_SCROLLBACK_LINES,
|
||||
loadAppSettingsFromStorage as loadAppSettingsFromStoragePure,
|
||||
loadSettingsFromStorage as loadSettingsFromStoragePure,
|
||||
parseTerminalScrollbackLines,
|
||||
saveAppSettings as saveAppSettingsPure,
|
||||
type AppSettings,
|
||||
type DesktopSettingsBridge,
|
||||
type KeyValueStorage,
|
||||
type ReleaseChannel,
|
||||
type SendBehavior,
|
||||
type ServiceUrlBehavior,
|
||||
type Settings,
|
||||
type SettingsDeps,
|
||||
} from "./storage";
|
||||
|
||||
export {
|
||||
APP_SETTINGS_KEY,
|
||||
DEFAULT_APP_SETTINGS,
|
||||
DEFAULT_CLIENT_SETTINGS,
|
||||
DEFAULT_TERMINAL_SCROLLBACK_LINES,
|
||||
MAX_TERMINAL_SCROLLBACK_LINES,
|
||||
MIN_TERMINAL_SCROLLBACK_LINES,
|
||||
parseTerminalScrollbackLines,
|
||||
};
|
||||
export type {
|
||||
AppSettings,
|
||||
DesktopSettingsBridge,
|
||||
KeyValueStorage,
|
||||
ReleaseChannel,
|
||||
SendBehavior,
|
||||
ServiceUrlBehavior,
|
||||
Settings,
|
||||
SettingsDeps,
|
||||
};
|
||||
|
||||
const productionDeps: SettingsDeps = {
|
||||
storage: AsyncStorage,
|
||||
desktop: {
|
||||
isElectron: isElectronRuntime,
|
||||
loadDesktopSettings,
|
||||
migrateLegacyDesktopSettings,
|
||||
},
|
||||
};
|
||||
|
||||
export interface UseAppSettingsReturn {
|
||||
settings: AppSettings;
|
||||
isLoading: boolean;
|
||||
error: unknown;
|
||||
updateSettings: (updates: Partial<AppSettings>) => Promise<void>;
|
||||
resetSettings: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface UseSettingsReturn {
|
||||
settings: Settings;
|
||||
isLoading: boolean;
|
||||
error: unknown;
|
||||
updateSettings: (updates: Partial<Settings>) => Promise<void>;
|
||||
resetSettings: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useAppSettings(): UseAppSettingsReturn {
|
||||
const queryClient = useQueryClient();
|
||||
const { data, isPending, error } = useQuery({
|
||||
queryKey: APP_SETTINGS_QUERY_KEY,
|
||||
queryFn: () => loadAppSettingsFromStorage(),
|
||||
staleTime: Infinity,
|
||||
gcTime: Infinity,
|
||||
});
|
||||
|
||||
const updateSettings = useCallback(
|
||||
async (updates: Partial<AppSettings>) => {
|
||||
try {
|
||||
await saveAppSettings({ queryClient, updates });
|
||||
} catch (err) {
|
||||
console.error("[AppSettings] Failed to save settings:", err);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
const resetSettings = useCallback(async () => {
|
||||
try {
|
||||
const next = { ...DEFAULT_CLIENT_SETTINGS };
|
||||
queryClient.setQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY, next);
|
||||
await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(next));
|
||||
} catch (err) {
|
||||
console.error("[AppSettings] Failed to reset settings:", err);
|
||||
throw err;
|
||||
}
|
||||
}, [queryClient]);
|
||||
|
||||
return {
|
||||
settings: data ?? DEFAULT_CLIENT_SETTINGS,
|
||||
isLoading: isPending,
|
||||
error: error ?? null,
|
||||
updateSettings,
|
||||
resetSettings,
|
||||
};
|
||||
}
|
||||
|
||||
export function useSettings(): UseSettingsReturn {
|
||||
const appSettings = useAppSettings();
|
||||
const desktopSettings = useDesktopSettings();
|
||||
|
||||
const updateSettings = useCallback(
|
||||
async (updates: Partial<Settings>) => {
|
||||
const appUpdates: Partial<AppSettings> = {};
|
||||
if (updates.theme !== undefined) {
|
||||
appUpdates.theme = updates.theme;
|
||||
}
|
||||
if (updates.sendBehavior !== undefined) {
|
||||
appUpdates.sendBehavior = updates.sendBehavior;
|
||||
}
|
||||
if (updates.serviceUrlBehavior !== undefined) {
|
||||
appUpdates.serviceUrlBehavior = updates.serviceUrlBehavior;
|
||||
}
|
||||
if (updates.terminalScrollbackLines !== undefined) {
|
||||
appUpdates.terminalScrollbackLines = updates.terminalScrollbackLines;
|
||||
}
|
||||
const promises: Promise<void>[] = [];
|
||||
if (Object.keys(appUpdates).length > 0) {
|
||||
promises.push(appSettings.updateSettings(appUpdates));
|
||||
}
|
||||
|
||||
if (isElectronRuntime()) {
|
||||
const desktopUpdates: Parameters<typeof desktopSettings.updateSettings>[0] = {};
|
||||
if (updates.manageBuiltInDaemon !== undefined) {
|
||||
desktopUpdates.daemon = {
|
||||
manageBuiltInDaemon: updates.manageBuiltInDaemon,
|
||||
};
|
||||
}
|
||||
if (updates.releaseChannel !== undefined) {
|
||||
desktopUpdates.releaseChannel = updates.releaseChannel;
|
||||
}
|
||||
if (Object.keys(desktopUpdates).length > 0) {
|
||||
promises.push(desktopSettings.updateSettings(desktopUpdates));
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
},
|
||||
[appSettings, desktopSettings],
|
||||
);
|
||||
|
||||
const resetSettings = useCallback(async () => {
|
||||
const resets: Promise<void>[] = [appSettings.resetSettings()];
|
||||
if (isElectronRuntime()) {
|
||||
resets.push(desktopSettings.updateSettings(DEFAULT_DESKTOP_SETTINGS));
|
||||
}
|
||||
await Promise.all(resets);
|
||||
}, [appSettings, desktopSettings]);
|
||||
|
||||
return {
|
||||
settings: {
|
||||
...DEFAULT_APP_SETTINGS,
|
||||
...appSettings.settings,
|
||||
manageBuiltInDaemon: desktopSettings.settings.daemon.manageBuiltInDaemon,
|
||||
releaseChannel: desktopSettings.settings.releaseChannel,
|
||||
},
|
||||
isLoading: appSettings.isLoading || desktopSettings.isLoading,
|
||||
error: appSettings.error ?? desktopSettings.error,
|
||||
updateSettings,
|
||||
resetSettings,
|
||||
};
|
||||
}
|
||||
|
||||
export async function persistAppSettings(updates: Partial<AppSettings>): Promise<void> {
|
||||
await saveAppSettings({ queryClient: appQueryClient, updates });
|
||||
}
|
||||
|
||||
export async function saveAppSettings(input: {
|
||||
queryClient: QueryClient;
|
||||
updates: Partial<AppSettings>;
|
||||
deps?: SettingsDeps;
|
||||
}): Promise<void> {
|
||||
await saveAppSettingsPure({
|
||||
queryClient: input.queryClient,
|
||||
updates: input.updates,
|
||||
deps: input.deps ?? productionDeps,
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadAppSettingsFromStorage(deps?: SettingsDeps): Promise<AppSettings> {
|
||||
return loadAppSettingsFromStoragePure(deps ?? productionDeps);
|
||||
}
|
||||
|
||||
export async function loadSettingsFromStorage(deps?: SettingsDeps): Promise<Settings> {
|
||||
return loadSettingsFromStoragePure(deps ?? productionDeps);
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
APP_SETTINGS_KEY,
|
||||
DEFAULT_APP_SETTINGS,
|
||||
DEFAULT_CLIENT_SETTINGS,
|
||||
loadAppSettingsFromStorage,
|
||||
loadSettingsFromStorage,
|
||||
parseTerminalScrollbackLines,
|
||||
saveAppSettings,
|
||||
type SettingsDeps,
|
||||
} from "./storage";
|
||||
import { createFakeDesktopBridge, createInMemoryKeyValueStorage } from "./fakes";
|
||||
|
||||
const LEGACY_SETTINGS_KEY = "@paseo:settings";
|
||||
|
||||
function makeDeps(
|
||||
overrides: {
|
||||
storage?: ReturnType<typeof createInMemoryKeyValueStorage>;
|
||||
desktop?: ReturnType<typeof createFakeDesktopBridge>;
|
||||
} = {},
|
||||
): SettingsDeps & {
|
||||
storage: ReturnType<typeof createInMemoryKeyValueStorage>;
|
||||
desktop: ReturnType<typeof createFakeDesktopBridge>;
|
||||
} {
|
||||
return {
|
||||
storage: overrides.storage ?? createInMemoryKeyValueStorage(),
|
||||
desktop: overrides.desktop ?? createFakeDesktopBridge(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("loadAppSettingsFromStorage", () => {
|
||||
it("defaults theme to auto when storage is empty", async () => {
|
||||
const deps = makeDeps();
|
||||
|
||||
const result = await loadAppSettingsFromStorage(deps);
|
||||
|
||||
expect(result.theme).toBe("auto");
|
||||
});
|
||||
|
||||
it("seeds storage with the client defaults when nothing is persisted", async () => {
|
||||
const deps = makeDeps();
|
||||
|
||||
const result = await loadAppSettingsFromStorage(deps);
|
||||
|
||||
expect(result).toEqual(DEFAULT_CLIENT_SETTINGS);
|
||||
expect(deps.storage.entries.get(APP_SETTINGS_KEY)).toBe(
|
||||
JSON.stringify(DEFAULT_CLIENT_SETTINGS),
|
||||
);
|
||||
});
|
||||
|
||||
it("loads configured terminal scrollback lines from app settings", async () => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({ terminalScrollbackLines: 42_000 }),
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await loadAppSettingsFromStorage(deps);
|
||||
|
||||
expect(result.terminalScrollbackLines).toBe(42_000);
|
||||
});
|
||||
|
||||
it("normalizes terminal scrollback lines from storage", async () => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({ terminalScrollbackLines: 1_000_000.9 }),
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await loadAppSettingsFromStorage(deps);
|
||||
|
||||
expect(result.terminalScrollbackLines).toBe(1_000_000);
|
||||
});
|
||||
|
||||
it("migrates the legacy theme key into the new settings object", async () => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
[LEGACY_SETTINGS_KEY]: JSON.stringify({
|
||||
theme: "dark",
|
||||
manageBuiltInDaemon: false,
|
||||
releaseChannel: "beta",
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await loadAppSettingsFromStorage(deps);
|
||||
|
||||
expect(result).toEqual({
|
||||
theme: "dark",
|
||||
sendBehavior: "interrupt",
|
||||
serviceUrlBehavior: "ask",
|
||||
terminalScrollbackLines: 10_000,
|
||||
});
|
||||
expect(deps.storage.entries.get(APP_SETTINGS_KEY)).toBe(JSON.stringify(result));
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadSettingsFromStorage", () => {
|
||||
it("defaults built-in daemon management to enabled when storage is empty", async () => {
|
||||
const deps = makeDeps();
|
||||
|
||||
const result = await loadSettingsFromStorage(deps);
|
||||
|
||||
expect(result).toEqual(DEFAULT_APP_SETTINGS);
|
||||
});
|
||||
|
||||
it("defaults release channel to stable when storage is empty", async () => {
|
||||
const deps = makeDeps();
|
||||
|
||||
const result = await loadSettingsFromStorage(deps);
|
||||
|
||||
expect(result.releaseChannel).toBe("stable");
|
||||
});
|
||||
|
||||
it("ignores renderer-owned daemon management state outside Electron", async () => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({
|
||||
theme: "light",
|
||||
manageBuiltInDaemon: false,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await loadSettingsFromStorage(deps);
|
||||
|
||||
expect(result).toEqual({
|
||||
theme: "light",
|
||||
manageBuiltInDaemon: true,
|
||||
sendBehavior: "interrupt",
|
||||
serviceUrlBehavior: "ask",
|
||||
terminalScrollbackLines: 10_000,
|
||||
releaseChannel: "stable",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores renderer-owned release channel outside Electron", async () => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({ releaseChannel: "beta" }),
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await loadSettingsFromStorage(deps);
|
||||
|
||||
expect(result.releaseChannel).toBe("stable");
|
||||
});
|
||||
|
||||
it("migrates legacy desktop-owned settings through the bridge before reading effective settings", async () => {
|
||||
const desktop = createFakeDesktopBridge({
|
||||
isElectron: true,
|
||||
settings: {
|
||||
releaseChannel: "beta",
|
||||
daemon: { manageBuiltInDaemon: false, keepRunningAfterQuit: true },
|
||||
},
|
||||
});
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({
|
||||
theme: "light",
|
||||
manageBuiltInDaemon: false,
|
||||
releaseChannel: "beta",
|
||||
}),
|
||||
}),
|
||||
desktop,
|
||||
});
|
||||
|
||||
const result = await loadSettingsFromStorage(deps);
|
||||
|
||||
expect(desktop.migrationsApplied).toEqual([
|
||||
{ manageBuiltInDaemon: false, releaseChannel: "beta" },
|
||||
]);
|
||||
expect(result).toEqual({
|
||||
theme: "light",
|
||||
sendBehavior: "interrupt",
|
||||
serviceUrlBehavior: "ask",
|
||||
terminalScrollbackLines: 10_000,
|
||||
manageBuiltInDaemon: false,
|
||||
releaseChannel: "beta",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not call the desktop bridge outside Electron", async () => {
|
||||
const desktop = createFakeDesktopBridge({ isElectron: false });
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({ theme: "light" }),
|
||||
}),
|
||||
desktop,
|
||||
});
|
||||
|
||||
const result = await loadSettingsFromStorage(deps);
|
||||
|
||||
expect(desktop.migrationsApplied).toEqual([]);
|
||||
expect(result).toEqual({
|
||||
theme: "light",
|
||||
sendBehavior: "interrupt",
|
||||
serviceUrlBehavior: "ask",
|
||||
terminalScrollbackLines: 10_000,
|
||||
manageBuiltInDaemon: true,
|
||||
releaseChannel: "stable",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveAppSettings", () => {
|
||||
it("saves terminal scrollback through app settings persistence", async () => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
[APP_SETTINGS_KEY]: JSON.stringify(DEFAULT_CLIENT_SETTINGS),
|
||||
}),
|
||||
});
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
await saveAppSettings({
|
||||
queryClient,
|
||||
updates: { terminalScrollbackLines: 42_000 },
|
||||
deps,
|
||||
});
|
||||
|
||||
expect(deps.storage.entries.get(APP_SETTINGS_KEY)).toBe(
|
||||
JSON.stringify({
|
||||
...DEFAULT_CLIENT_SETTINGS,
|
||||
terminalScrollbackLines: 42_000,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseTerminalScrollbackLines", () => {
|
||||
it("clamps negative values to the minimum and rejects non-numeric strings", () => {
|
||||
expect(parseTerminalScrollbackLines("-10")).toBe(0);
|
||||
expect(parseTerminalScrollbackLines("abc")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import type { DesktopSettings } from "@/desktop/settings/desktop-settings";
|
||||
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
|
||||
|
||||
export const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||
export const APP_SETTINGS_QUERY_KEY = ["app-settings"];
|
||||
const LEGACY_SETTINGS_KEY = "@paseo:settings";
|
||||
|
||||
export type SendBehavior = "interrupt" | "queue";
|
||||
export type ReleaseChannel = "stable" | "beta";
|
||||
export type ServiceUrlBehavior = "ask" | "in-app" | "external";
|
||||
|
||||
const VALID_THEMES = new Set<string>([...Object.keys(THEME_TO_UNISTYLES), "auto"]);
|
||||
const VALID_SERVICE_URL_BEHAVIORS = new Set<ServiceUrlBehavior>(["ask", "in-app", "external"]);
|
||||
export const DEFAULT_TERMINAL_SCROLLBACK_LINES = 10_000;
|
||||
export const MIN_TERMINAL_SCROLLBACK_LINES = 0;
|
||||
export const MAX_TERMINAL_SCROLLBACK_LINES = 1_000_000;
|
||||
|
||||
export interface AppSettings {
|
||||
theme: ThemeName | "auto";
|
||||
sendBehavior: SendBehavior;
|
||||
serviceUrlBehavior: ServiceUrlBehavior;
|
||||
terminalScrollbackLines: number;
|
||||
}
|
||||
|
||||
export interface Settings extends AppSettings {
|
||||
manageBuiltInDaemon: boolean;
|
||||
releaseChannel: ReleaseChannel;
|
||||
}
|
||||
|
||||
export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
|
||||
theme: "auto",
|
||||
sendBehavior: "interrupt",
|
||||
serviceUrlBehavior: "ask",
|
||||
terminalScrollbackLines: DEFAULT_TERMINAL_SCROLLBACK_LINES,
|
||||
};
|
||||
|
||||
export const DEFAULT_APP_SETTINGS: Settings = {
|
||||
...DEFAULT_CLIENT_SETTINGS,
|
||||
manageBuiltInDaemon: true,
|
||||
releaseChannel: "stable",
|
||||
};
|
||||
|
||||
export interface KeyValueStorage {
|
||||
getItem(key: string): Promise<string | null>;
|
||||
setItem(key: string, value: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface DesktopSettingsBridge {
|
||||
isElectron(): boolean;
|
||||
loadDesktopSettings(): Promise<DesktopSettings>;
|
||||
migrateLegacyDesktopSettings(input: {
|
||||
manageBuiltInDaemon?: boolean;
|
||||
releaseChannel?: ReleaseChannel;
|
||||
}): Promise<void>;
|
||||
}
|
||||
|
||||
export interface SettingsDeps {
|
||||
storage: KeyValueStorage;
|
||||
desktop: DesktopSettingsBridge;
|
||||
}
|
||||
|
||||
export async function saveAppSettings(input: {
|
||||
queryClient: QueryClient;
|
||||
updates: Partial<AppSettings>;
|
||||
deps: SettingsDeps;
|
||||
}): Promise<void> {
|
||||
const current =
|
||||
input.queryClient.getQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY) ??
|
||||
(await loadAppSettingsFromStorage(input.deps));
|
||||
const next = { ...current, ...input.updates };
|
||||
input.queryClient.setQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY, next);
|
||||
await input.deps.storage.setItem(APP_SETTINGS_KEY, JSON.stringify(next));
|
||||
}
|
||||
|
||||
export async function loadAppSettingsFromStorage(deps: SettingsDeps): Promise<AppSettings> {
|
||||
try {
|
||||
const stored = await deps.storage.getItem(APP_SETTINGS_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored) as Partial<AppSettings>;
|
||||
return { ...DEFAULT_CLIENT_SETTINGS, ...pickAppSettings(parsed) };
|
||||
}
|
||||
|
||||
const legacyStored = await deps.storage.getItem(LEGACY_SETTINGS_KEY);
|
||||
if (legacyStored) {
|
||||
const legacyParsed = JSON.parse(legacyStored) as Record<string, unknown>;
|
||||
const next = {
|
||||
...DEFAULT_CLIENT_SETTINGS,
|
||||
...pickAppSettingsFromLegacy(legacyParsed),
|
||||
} satisfies AppSettings;
|
||||
await deps.storage.setItem(APP_SETTINGS_KEY, JSON.stringify(next));
|
||||
return next;
|
||||
}
|
||||
|
||||
await deps.storage.setItem(APP_SETTINGS_KEY, JSON.stringify(DEFAULT_CLIENT_SETTINGS));
|
||||
return DEFAULT_CLIENT_SETTINGS;
|
||||
} catch (error) {
|
||||
console.error("[AppSettings] Failed to load settings:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadSettingsFromStorage(deps: SettingsDeps): Promise<Settings> {
|
||||
const legacyDesktopSettings = deps.desktop.isElectron()
|
||||
? await loadLegacyDesktopSettingsFromStorage(deps.storage)
|
||||
: null;
|
||||
const appSettings = await loadAppSettingsFromStorage(deps);
|
||||
|
||||
if (!deps.desktop.isElectron()) {
|
||||
return {
|
||||
...DEFAULT_APP_SETTINGS,
|
||||
...appSettings,
|
||||
};
|
||||
}
|
||||
|
||||
if (legacyDesktopSettings) {
|
||||
await deps.desktop.migrateLegacyDesktopSettings(legacyDesktopSettings);
|
||||
}
|
||||
|
||||
const desktopSettings = await deps.desktop.loadDesktopSettings();
|
||||
return {
|
||||
...DEFAULT_APP_SETTINGS,
|
||||
...appSettings,
|
||||
manageBuiltInDaemon: desktopSettings.daemon.manageBuiltInDaemon,
|
||||
releaseChannel: desktopSettings.releaseChannel,
|
||||
};
|
||||
}
|
||||
|
||||
function pickAppSettings(stored: Partial<AppSettings>): Partial<AppSettings> {
|
||||
const result: Partial<AppSettings> = {};
|
||||
if (typeof stored.theme === "string" && VALID_THEMES.has(stored.theme)) {
|
||||
result.theme = stored.theme;
|
||||
}
|
||||
if (stored.sendBehavior === "interrupt" || stored.sendBehavior === "queue") {
|
||||
result.sendBehavior = stored.sendBehavior;
|
||||
}
|
||||
if (
|
||||
typeof stored.serviceUrlBehavior === "string" &&
|
||||
VALID_SERVICE_URL_BEHAVIORS.has(stored.serviceUrlBehavior)
|
||||
) {
|
||||
result.serviceUrlBehavior = stored.serviceUrlBehavior;
|
||||
}
|
||||
const terminalScrollbackLines = parseTerminalScrollbackLines(stored.terminalScrollbackLines);
|
||||
if (terminalScrollbackLines !== null) {
|
||||
result.terminalScrollbackLines = terminalScrollbackLines;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function pickAppSettingsFromLegacy(legacy: Record<string, unknown>): Partial<AppSettings> {
|
||||
const result: Partial<AppSettings> = {};
|
||||
if (legacy.theme === "dark" || legacy.theme === "light" || legacy.theme === "auto") {
|
||||
result.theme = legacy.theme;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseTerminalScrollbackLines(value: unknown): number | null {
|
||||
let numericValue = NaN;
|
||||
if (typeof value === "number") {
|
||||
numericValue = value;
|
||||
} else if (typeof value === "string" && value.trim().length > 0) {
|
||||
numericValue = Number(value);
|
||||
}
|
||||
if (!Number.isFinite(numericValue)) {
|
||||
return null;
|
||||
}
|
||||
return Math.min(
|
||||
MAX_TERMINAL_SCROLLBACK_LINES,
|
||||
Math.max(MIN_TERMINAL_SCROLLBACK_LINES, Math.floor(numericValue)),
|
||||
);
|
||||
}
|
||||
|
||||
async function loadLegacyDesktopSettingsFromStorage(storage: KeyValueStorage): Promise<{
|
||||
manageBuiltInDaemon?: boolean;
|
||||
releaseChannel?: ReleaseChannel;
|
||||
} | null> {
|
||||
const stored = await loadRendererSettingsPayload(storage);
|
||||
if (!stored) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result: {
|
||||
manageBuiltInDaemon?: boolean;
|
||||
releaseChannel?: ReleaseChannel;
|
||||
} = {};
|
||||
|
||||
if (typeof stored.manageBuiltInDaemon === "boolean") {
|
||||
result.manageBuiltInDaemon = stored.manageBuiltInDaemon;
|
||||
}
|
||||
if (stored.releaseChannel === "stable" || stored.releaseChannel === "beta") {
|
||||
result.releaseChannel = stored.releaseChannel;
|
||||
}
|
||||
|
||||
return Object.keys(result).length > 0 ? result : null;
|
||||
}
|
||||
|
||||
async function loadRendererSettingsPayload(
|
||||
storage: KeyValueStorage,
|
||||
): Promise<Record<string, unknown> | null> {
|
||||
const current = await storage.getItem(APP_SETTINGS_KEY);
|
||||
if (current) {
|
||||
return JSON.parse(current) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
const legacy = await storage.getItem(LEGACY_SETTINGS_KEY);
|
||||
if (!legacy) {
|
||||
return null;
|
||||
}
|
||||
return JSON.parse(legacy) as Record<string, unknown>;
|
||||
}
|
||||
@@ -1,298 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { act } from "@testing-library/react";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import React from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.hoisted(() => {
|
||||
(globalThis as unknown as { __DEV__: boolean }).__DEV__ = false;
|
||||
});
|
||||
|
||||
import {
|
||||
appendMissingOrderKeys,
|
||||
applyStoredOrdering,
|
||||
buildSidebarProjectsFromStructure,
|
||||
useSidebarWorkspacesList,
|
||||
} from "./use-sidebar-workspaces-list";
|
||||
import type { WorkspaceStructureProject } from "@/stores/session-store-hooks";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||
|
||||
interface OrderedItem {
|
||||
key: string;
|
||||
}
|
||||
|
||||
function item(key: string): OrderedItem {
|
||||
return { key };
|
||||
}
|
||||
|
||||
function project(input: {
|
||||
projectKey: string;
|
||||
projectName?: string;
|
||||
projectKind?: WorkspaceStructureProject["projectKind"];
|
||||
iconWorkingDir?: string;
|
||||
workspaceKeys: string[];
|
||||
}): WorkspaceStructureProject {
|
||||
return {
|
||||
projectKey: input.projectKey,
|
||||
projectName: input.projectName ?? input.projectKey,
|
||||
projectKind: input.projectKind ?? "git",
|
||||
iconWorkingDir: input.iconWorkingDir ?? input.projectKey,
|
||||
workspaceKeys: input.workspaceKeys,
|
||||
};
|
||||
}
|
||||
|
||||
function workspaceDescriptor(id: string): WorkspaceDescriptor {
|
||||
return {
|
||||
id,
|
||||
projectId: "project-1",
|
||||
projectDisplayName: "Project 1",
|
||||
projectRootPath: "/repo/main",
|
||||
workspaceDirectory: `/repo/main/${id}`,
|
||||
projectKind: "git",
|
||||
workspaceKind: "worktree",
|
||||
name: id,
|
||||
status: "done",
|
||||
archivingAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
};
|
||||
}
|
||||
|
||||
function DisabledHookProbe({ serverId }: { serverId: string }): null {
|
||||
const result = useSidebarWorkspacesList({ serverId, enabled: false });
|
||||
|
||||
expect(result.projects).toEqual([]);
|
||||
expect(result.isLoading).toBe(false);
|
||||
expect(result.isInitialLoad).toBe(false);
|
||||
expect(result.isRevalidating).toBe(false);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function DisabledRenderCountProbe({
|
||||
onRender,
|
||||
serverId,
|
||||
}: {
|
||||
onRender: () => void;
|
||||
serverId: string;
|
||||
}): null {
|
||||
useSidebarWorkspacesList({ serverId, enabled: false });
|
||||
onRender();
|
||||
return null;
|
||||
}
|
||||
|
||||
function HookResultProbe({
|
||||
onResult,
|
||||
serverId,
|
||||
}: {
|
||||
onResult: (result: ReturnType<typeof useSidebarWorkspacesList>) => void;
|
||||
serverId: string;
|
||||
}): null {
|
||||
onResult(useSidebarWorkspacesList({ serverId }));
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("applyStoredOrdering", () => {
|
||||
it("keeps unknown items on the baseline while applying stored order", () => {
|
||||
const result = applyStoredOrdering({
|
||||
items: [item("new"), item("a"), item("b")],
|
||||
storedOrder: ["b", "a"],
|
||||
getKey: (entry) => entry.key,
|
||||
});
|
||||
|
||||
expect(result.map((entry) => entry.key)).toEqual(["new", "b", "a"]);
|
||||
});
|
||||
|
||||
it("ignores stale and duplicate stored keys", () => {
|
||||
const result = applyStoredOrdering({
|
||||
items: [item("x"), item("y")],
|
||||
storedOrder: ["missing", "y", "y", "x"],
|
||||
getKey: (entry) => entry.key,
|
||||
});
|
||||
|
||||
expect(result.map((entry) => entry.key)).toEqual(["y", "x"]);
|
||||
});
|
||||
|
||||
it("returns baseline when there is no persisted order", () => {
|
||||
const baseline = [item("first"), item("second")];
|
||||
const result = applyStoredOrdering({
|
||||
items: baseline,
|
||||
storedOrder: [],
|
||||
getKey: (entry) => entry.key,
|
||||
});
|
||||
|
||||
expect(result).toBe(baseline);
|
||||
});
|
||||
});
|
||||
|
||||
describe("appendMissingOrderKeys", () => {
|
||||
it("appends unseen keys while preserving existing order", () => {
|
||||
const result = appendMissingOrderKeys({
|
||||
currentOrder: ["project-b", "project-a"],
|
||||
visibleKeys: ["project-a", "project-b", "project-c"],
|
||||
});
|
||||
|
||||
expect(result).toEqual(["project-b", "project-a", "project-c"]);
|
||||
});
|
||||
|
||||
it("returns the same array when there are no unseen keys", () => {
|
||||
const currentOrder = ["project-a", "project-b"];
|
||||
|
||||
const result = appendMissingOrderKeys({
|
||||
currentOrder,
|
||||
visibleKeys: ["project-b", "project-a"],
|
||||
});
|
||||
|
||||
expect(result).toBe(currentOrder);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSidebarProjectsFromStructure", () => {
|
||||
it("creates structural workspace rows from ordered workspace keys", () => {
|
||||
const projects = buildSidebarProjectsFromStructure({
|
||||
serverId: "srv",
|
||||
projects: [
|
||||
project({
|
||||
projectKey: "project-1",
|
||||
projectName: "Project 1",
|
||||
iconWorkingDir: "/repo/main",
|
||||
workspaceKeys: ["ws-main"],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(projects).toHaveLength(1);
|
||||
expect(projects[0]?.projectName).toBe("Project 1");
|
||||
expect(projects[0]?.workspaces[0]).toMatchObject({
|
||||
workspaceKey: "srv:ws-main",
|
||||
serverId: "srv",
|
||||
workspaceId: "ws-main",
|
||||
projectRootPath: "/repo/main",
|
||||
projectKind: "git",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the structure hook project order", () => {
|
||||
const projects = buildSidebarProjectsFromStructure({
|
||||
serverId: "srv",
|
||||
projects: [
|
||||
project({ projectKey: "project-b", workspaceKeys: ["ws-b"] }),
|
||||
project({ projectKey: "project-a", workspaceKeys: ["ws-a"] }),
|
||||
],
|
||||
});
|
||||
|
||||
expect(projects.map((entry) => entry.projectKey)).toEqual(["project-b", "project-a"]);
|
||||
});
|
||||
|
||||
it("preserves the structure hook workspace order", () => {
|
||||
const projects = buildSidebarProjectsFromStructure({
|
||||
serverId: "srv",
|
||||
projects: [project({ projectKey: "project-1", workspaceKeys: ["feature", "main"] })],
|
||||
});
|
||||
|
||||
expect(projects[0]?.workspaces.map((workspace) => workspace.workspaceId)).toEqual([
|
||||
"feature",
|
||||
"main",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSidebarWorkspacesList", () => {
|
||||
let root: Root | null = null;
|
||||
let container: HTMLElement | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
root = null;
|
||||
container?.remove();
|
||||
container = null;
|
||||
act(() => {
|
||||
getHostRuntimeStore().syncHosts([]);
|
||||
useSessionStore.getState().clearSession("srv-disabled");
|
||||
useSessionStore.getState().clearSession("srv-loading");
|
||||
useSidebarOrderStore.setState({
|
||||
projectOrderByServerId: {},
|
||||
workspaceOrderByServerAndProject: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("honors enabled false without appending persisted order keys", async () => {
|
||||
act(() => {
|
||||
useSessionStore.getState().initializeSession("srv-disabled", null as unknown as DaemonClient);
|
||||
useSessionStore
|
||||
.getState()
|
||||
.setWorkspaces("srv-disabled", new Map([["ws-main", workspaceDescriptor("ws-main")]]));
|
||||
useSessionStore.getState().setHasHydratedWorkspaces("srv-disabled", true);
|
||||
});
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(React.createElement(DisabledHookProbe, { serverId: "srv-disabled" }));
|
||||
});
|
||||
|
||||
expect(useSidebarOrderStore.getState().projectOrderByServerId).toEqual({});
|
||||
expect(useSidebarOrderStore.getState().workspaceOrderByServerAndProject).toEqual({});
|
||||
});
|
||||
|
||||
it("keeps the sidebar in initial load until workspace hydration succeeds", async () => {
|
||||
const onResult = vi.fn();
|
||||
|
||||
act(() => {
|
||||
useSessionStore.getState().initializeSession("srv-loading", null as unknown as DaemonClient);
|
||||
});
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(React.createElement(HookResultProbe, { serverId: "srv-loading", onResult }));
|
||||
});
|
||||
|
||||
expect(onResult).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
projects: [],
|
||||
isLoading: true,
|
||||
isInitialLoad: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not subscribe to order updates while disabled", async () => {
|
||||
const onRender = vi.fn();
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
React.createElement(DisabledRenderCountProbe, {
|
||||
serverId: "srv-disabled",
|
||||
onRender,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(onRender).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
useSidebarOrderStore.getState().setProjectOrder("srv-disabled", ["project-a"]);
|
||||
});
|
||||
|
||||
expect(onRender).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -5,81 +5,31 @@ import {
|
||||
useSessionStore,
|
||||
type WorkspaceDescriptor,
|
||||
} from "@/stores/session-store";
|
||||
import { selectPrHintFromStatus, type PrHint } from "@/git/use-pr-status-query";
|
||||
import {
|
||||
useWorkspaceStructure,
|
||||
type WorkspaceStructureProject,
|
||||
} from "@/stores/session-store-hooks";
|
||||
import { selectPrHintFromStatus } from "@/git/use-pr-status-query";
|
||||
import { useWorkspaceStructure } from "@/stores/session-store-hooks";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||
import { shouldSuppressWorkspaceForLocalArchive } from "@/contexts/session-workspace-upserts";
|
||||
import {
|
||||
buildSidebarProjectsFromStructure,
|
||||
computeSidebarOrderUpdates,
|
||||
deriveSidebarLoadingState,
|
||||
type SidebarProjectEntry,
|
||||
type SidebarWorkspaceEntry,
|
||||
} from "./sidebar-workspaces-view-model";
|
||||
|
||||
const EMPTY_ORDER: string[] = [];
|
||||
const EMPTY_PROJECTS: SidebarProjectEntry[] = [];
|
||||
|
||||
export type SidebarStateBucket = WorkspaceDescriptor["status"];
|
||||
|
||||
export interface SidebarWorkspaceEntry {
|
||||
workspaceKey: string;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
projectKey: string;
|
||||
projectRootPath?: string;
|
||||
workspaceDirectory?: string;
|
||||
projectKind: WorkspaceDescriptor["projectKind"];
|
||||
workspaceKind: WorkspaceDescriptor["workspaceKind"];
|
||||
name: string;
|
||||
statusBucket: SidebarStateBucket;
|
||||
archivingAt: string | null;
|
||||
diffStat: { additions: number; deletions: number } | null;
|
||||
prHint: PrHint | null;
|
||||
archiveHasUncommittedChanges: boolean | null;
|
||||
archiveUnpushedCommitCount: number | null;
|
||||
scripts: WorkspaceDescriptor["scripts"];
|
||||
hasRunningScripts: boolean;
|
||||
}
|
||||
|
||||
export interface SidebarProjectEntry {
|
||||
projectKey: string;
|
||||
projectName: string;
|
||||
projectKind: WorkspaceDescriptor["projectKind"];
|
||||
iconWorkingDir: string;
|
||||
workspaces: SidebarWorkspaceEntry[];
|
||||
}
|
||||
|
||||
export interface SidebarWorkspacesListResult {
|
||||
projects: SidebarProjectEntry[];
|
||||
isLoading: boolean;
|
||||
isInitialLoad: boolean;
|
||||
isRevalidating: boolean;
|
||||
refreshAll: () => void;
|
||||
}
|
||||
|
||||
function createStructuralWorkspaceEntry(input: {
|
||||
serverId: string;
|
||||
project: WorkspaceStructureProject;
|
||||
workspaceId: string;
|
||||
}): SidebarWorkspaceEntry {
|
||||
return {
|
||||
workspaceKey: `${input.serverId}:${input.workspaceId}`,
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspaceId,
|
||||
projectKey: input.project.projectKey,
|
||||
projectRootPath: input.project.iconWorkingDir,
|
||||
workspaceDirectory: undefined,
|
||||
projectKind: input.project.projectKind,
|
||||
workspaceKind: "checkout",
|
||||
name: input.workspaceId,
|
||||
statusBucket: "done",
|
||||
archivingAt: null,
|
||||
diffStat: null,
|
||||
prHint: null,
|
||||
archiveHasUncommittedChanges: null,
|
||||
archiveUnpushedCommitCount: null,
|
||||
scripts: [],
|
||||
hasRunningScripts: false,
|
||||
};
|
||||
}
|
||||
export {
|
||||
appendMissingOrderKeys,
|
||||
applyStoredOrdering,
|
||||
buildSidebarProjectsFromStructure,
|
||||
computeSidebarOrderUpdates,
|
||||
deriveSidebarLoadingState,
|
||||
type SidebarLoadingState,
|
||||
type SidebarOrderUpdates,
|
||||
type SidebarProjectEntry,
|
||||
type SidebarStateBucket,
|
||||
type SidebarWorkspaceEntry,
|
||||
} from "./sidebar-workspaces-view-model";
|
||||
|
||||
export function createSidebarWorkspaceEntry(input: {
|
||||
serverId: string;
|
||||
@@ -106,91 +56,15 @@ export function createSidebarWorkspaceEntry(input: {
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSidebarProjectsFromStructure(input: {
|
||||
serverId: string;
|
||||
projects: WorkspaceStructureProject[];
|
||||
}): SidebarProjectEntry[] {
|
||||
if (input.projects.length === 0) {
|
||||
return EMPTY_PROJECTS;
|
||||
}
|
||||
const EMPTY_ORDER: string[] = [];
|
||||
const EMPTY_PROJECTS: SidebarProjectEntry[] = [];
|
||||
|
||||
return input.projects.map((project) => ({
|
||||
projectKey: project.projectKey,
|
||||
projectName: project.projectName,
|
||||
projectKind: project.projectKind,
|
||||
iconWorkingDir: project.iconWorkingDir,
|
||||
workspaces: project.workspaceKeys.map((workspaceId) =>
|
||||
createStructuralWorkspaceEntry({
|
||||
serverId: input.serverId,
|
||||
project,
|
||||
workspaceId,
|
||||
}),
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
export function applyStoredOrdering<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 SidebarWorkspacesListResult {
|
||||
projects: SidebarProjectEntry[];
|
||||
isLoading: boolean;
|
||||
isInitialLoad: boolean;
|
||||
isRevalidating: boolean;
|
||||
refreshAll: () => void;
|
||||
}
|
||||
|
||||
function toWorkspaceDescriptor(payload: WorkspaceDescriptorPayload): WorkspaceDescriptor {
|
||||
@@ -252,31 +126,22 @@ export function useSidebarWorkspacesList(options?: {
|
||||
}, [connectionStatus, hasHydratedWorkspaces, projects, serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!serverId || projects.length === 0) {
|
||||
if (!serverId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextProjectOrder = appendMissingOrderKeys({
|
||||
currentOrder: persistedProjectOrder,
|
||||
visibleKeys: projects.map((project) => project.projectKey),
|
||||
const orderStore = useSidebarOrderStore.getState();
|
||||
const updates = computeSidebarOrderUpdates({
|
||||
projects,
|
||||
persistedProjectOrder,
|
||||
getWorkspaceOrder: (projectKey) => orderStore.getWorkspaceOrder(serverId, projectKey),
|
||||
});
|
||||
if (nextProjectOrder !== persistedProjectOrder) {
|
||||
useSidebarOrderStore.getState().setProjectOrder(serverId, nextProjectOrder);
|
||||
}
|
||||
|
||||
for (const project of projects) {
|
||||
const persistedWorkspaceOrder = useSidebarOrderStore
|
||||
.getState()
|
||||
.getWorkspaceOrder(serverId, project.projectKey);
|
||||
const nextWorkspaceOrder = appendMissingOrderKeys({
|
||||
currentOrder: persistedWorkspaceOrder,
|
||||
visibleKeys: project.workspaces.map((workspace) => workspace.workspaceKey),
|
||||
});
|
||||
if (nextWorkspaceOrder !== persistedWorkspaceOrder) {
|
||||
useSidebarOrderStore
|
||||
.getState()
|
||||
.setWorkspaceOrder(serverId, project.projectKey, nextWorkspaceOrder);
|
||||
}
|
||||
if (updates.projectOrder) {
|
||||
orderStore.setProjectOrder(serverId, updates.projectOrder);
|
||||
}
|
||||
for (const { projectKey, order } of updates.workspaceOrders) {
|
||||
orderStore.setWorkspaceOrder(serverId, projectKey, order);
|
||||
}
|
||||
}, [persistedProjectOrder, projects, serverId]);
|
||||
|
||||
@@ -323,15 +188,16 @@ export function useSidebarWorkspacesList(options?: {
|
||||
})();
|
||||
}, [connectionStatus, isActive, runtime, serverId]);
|
||||
|
||||
const isLoading = isActive && Boolean(serverId) && !hasHydratedWorkspaces;
|
||||
const isInitialLoad = isLoading && projects.length === 0;
|
||||
const isRevalidating = false;
|
||||
const loadingState = deriveSidebarLoadingState({
|
||||
isActive,
|
||||
serverId,
|
||||
hasHydratedWorkspaces,
|
||||
hasProjects: projects.length > 0,
|
||||
});
|
||||
|
||||
return {
|
||||
projects,
|
||||
isLoading,
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
...loadingState,
|
||||
refreshAll,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const navigateToWorkspaceMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/stores/navigation-active-workspace-store", () => ({
|
||||
navigateToWorkspace: navigateToWorkspaceMock,
|
||||
}));
|
||||
|
||||
import { navigateToWorkspace, useWorkspaceNavigation } from "@/hooks/use-workspace-navigation";
|
||||
|
||||
describe("useWorkspaceNavigation", () => {
|
||||
it("re-exports the workspace navigation action", () => {
|
||||
expect(navigateToWorkspace).toBe(navigateToWorkspaceMock);
|
||||
});
|
||||
|
||||
it("returns a stable callback wrapper", () => {
|
||||
const { result } = renderHook(() => useWorkspaceNavigation());
|
||||
|
||||
result.current.navigateToWorkspace("server-1", "workspace-a");
|
||||
|
||||
expect(navigateToWorkspaceMock).toHaveBeenCalledWith("server-1", "workspace-a", undefined);
|
||||
});
|
||||
});
|
||||
@@ -1,19 +0,0 @@
|
||||
import { useCallback } from "react";
|
||||
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||
|
||||
export { navigateToWorkspace };
|
||||
|
||||
export function useWorkspaceNavigation() {
|
||||
return {
|
||||
navigateToWorkspace: useCallback(
|
||||
(
|
||||
serverId: string,
|
||||
workspaceId: string,
|
||||
options?: Parameters<typeof navigateToWorkspace>[2],
|
||||
) => {
|
||||
navigateToWorkspace(serverId, workspaceId, options);
|
||||
},
|
||||
[],
|
||||
),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user