diff --git a/docs/testing.md b/docs/testing.md
index f9488ec44..2c6a30705 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -157,3 +157,12 @@ If code isn't testable, refactor it. Signs:
- Setup requires too much global state
Aim for deep modules: small interface, deep implementation. Fewer methods = fewer tests needed, simpler params = simpler setup.
+
+## Two test categories, no others
+
+Every test in this repo lives in exactly one of these shapes:
+
+1. **Unit tests with ports and adapters** — production code receives its real-world dependencies (DB, HTTP, CLI process, clock, randomness, filesystem, other modules) through an injected interface. Tests wire a typed in-memory fake colocated with the production module. **No `vi.mock`, `vi.hoisted`, `vi.spyOn` of own exports, JSDOM, `@testing-library` component mounting, RN test renderer, monkey-patched globals, or fake-server fixtures.** If a test needs any of those, the production module is missing a port — fix the seam, then write the test against a fake adapter.
+2. **Real end-to-end tests** — real daemon, real network, real browser (Playwright for app code) or a real isolated server instance (for daemon code). No JSDOM, no mocked transport.
+
+Anything in between — component tests in JSDOM, vitest tests that mock the module under test, tests that assert on private state — is slop on its way out.
diff --git a/packages/app/src/app/index.test.tsx b/packages/app/src/app/index.test.tsx
deleted file mode 100644
index 0740675ad..000000000
--- a/packages/app/src/app/index.test.tsx
+++ /dev/null
@@ -1,149 +0,0 @@
-/**
- * @vitest-environment jsdom
- */
-import React from "react";
-import { act } from "@testing-library/react";
-import { createRoot, type Root } from "react-dom/client";
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import type { HostRuntimeBootstrapState } from "./_layout";
-import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
-
-const { redirectMock, state } = vi.hoisted(() => {
- const hoistedState = {
- pathname: "/",
- bootstrapState: {
- splashError: null,
- retry: vi.fn(),
- hasGivenUpWaitingForHost: false,
- storeReady: false,
- } as HostRuntimeBootstrapState,
- anyOnlineHostServerId: null as string | null,
- isWorkspaceSelectionLoaded: true,
- workspaceSelection: null as ActiveWorkspaceSelection | null,
- };
-
- return {
- redirectMock: vi.fn(),
- state: hoistedState,
- };
-});
-
-vi.mock("expo-router", () => ({
- Redirect: ({ href }: { href: string }) => {
- redirectMock(href);
- return React.createElement("div", { "data-testid": "redirect", "data-href": href });
- },
- usePathname: () => state.pathname,
-}));
-
-vi.mock("@/app/_layout", () => ({
- useHostRuntimeBootstrapState: () => state.bootstrapState,
- useEarliestOnlineHostServerId: () => state.anyOnlineHostServerId,
-}));
-
-vi.mock("@/desktop/daemon/desktop-daemon", () => ({
- shouldUseDesktopDaemon: () => false,
-}));
-
-vi.mock("@/screens/startup-splash-screen", () => ({
- StartupSplashScreen: () => React.createElement("div", { "data-testid": "startup-splash" }),
-}));
-
-vi.mock("@/stores/navigation-active-workspace-store", () => ({
- useIsLastWorkspaceSelectionHydrated: () => state.isWorkspaceSelectionLoaded,
- useLastWorkspaceSelection: () => state.workspaceSelection,
-}));
-
-describe("Index route startup navigation", () => {
- let container: HTMLDivElement;
- let root: Root;
-
- beforeEach(() => {
- vi.resetModules();
- state.pathname = "/";
- state.bootstrapState = {
- splashError: null,
- retry: vi.fn(),
- hasGivenUpWaitingForHost: false,
- storeReady: false,
- };
- state.anyOnlineHostServerId = null;
- state.isWorkspaceSelectionLoaded = true;
- state.workspaceSelection = null;
- redirectMock.mockReset();
-
- container = document.createElement("div");
- document.body.appendChild(container);
- root = createRoot(container);
- });
-
- afterEach(() => {
- act(() => {
- root.unmount();
- });
- container.remove();
- });
-
- async function renderIndex() {
- const { default: Index } = await import("./index");
- await act(async () => {
- root.render();
- });
- }
-
- it("shows the startup splash while no host is online and the welcome timer has not fired", async () => {
- await renderIndex();
-
- expect(container.querySelector("[data-testid='startup-splash']")).not.toBeNull();
- expect(redirectMock).not.toHaveBeenCalled();
- });
-
- it("shows the startup splash while the persisted workspace selection has not loaded", async () => {
- state.anyOnlineHostServerId = "server-1";
- state.isWorkspaceSelectionLoaded = false;
-
- await renderIndex();
-
- expect(container.querySelector("[data-testid='startup-splash']")).not.toBeNull();
- expect(redirectMock).not.toHaveBeenCalled();
- });
-
- it("restores the persisted workspace when the online host matches its server id", async () => {
- state.anyOnlineHostServerId = "server-1";
- state.workspaceSelection = { serverId: "server-1", workspaceId: "workspace-a" };
-
- await renderIndex();
-
- expect(redirectMock).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a");
- expect(container.querySelector("[data-testid='redirect']")).not.toBeNull();
- });
-
- it("restores the persisted workspace even when the first online host is different", async () => {
- state.anyOnlineHostServerId = "server-2";
- state.workspaceSelection = { serverId: "server-1", workspaceId: "workspace-a" };
-
- await renderIndex();
-
- expect(redirectMock).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a");
- });
-
- it("navigates to the host root when no persisted workspace exists", async () => {
- state.anyOnlineHostServerId = "server-2";
- state.workspaceSelection = null;
-
- await renderIndex();
-
- expect(redirectMock).toHaveBeenCalledWith("/h/server-2");
- });
-
- it("falls back to welcome when the give-up timer fires with no host online", async () => {
- state.bootstrapState = {
- ...state.bootstrapState,
- hasGivenUpWaitingForHost: true,
- };
-
- await renderIndex();
-
- expect(redirectMock).toHaveBeenCalledWith("/welcome");
- });
-});
diff --git a/packages/app/src/attachments/attachment-file-system.ts b/packages/app/src/attachments/attachment-file-system.ts
new file mode 100644
index 000000000..30a22a776
--- /dev/null
+++ b/packages/app/src/attachments/attachment-file-system.ts
@@ -0,0 +1,54 @@
+import { File } from "expo-file-system";
+import * as FileSystem from "expo-file-system/legacy";
+
+export type AttachmentFileInfo =
+ | { exists: true; isDirectory: boolean; size: number | null }
+ | { exists: false };
+
+export interface AttachmentFileSystem {
+ readonly cacheDirectory: string | null;
+ getInfo(uri: string): Promise;
+ makeDirectory(uri: string, options: { intermediates: boolean }): Promise;
+ writeBytes(uri: string, bytes: Uint8Array): Promise;
+ copy(input: { from: string; to: string }): Promise;
+ readAsBase64(uri: string): Promise;
+ delete(uri: string, options: { idempotent: boolean }): Promise;
+ listDirectory(uri: string): Promise;
+}
+
+export function createExpoAttachmentFileSystem(): AttachmentFileSystem {
+ return {
+ cacheDirectory: FileSystem.cacheDirectory,
+ async getInfo(uri) {
+ const info = await FileSystem.getInfoAsync(uri);
+ if (!info.exists) {
+ return { exists: false };
+ }
+ const size =
+ typeof (info as { size?: number }).size === "number"
+ ? (info as { size: number }).size
+ : null;
+ return { exists: true, isDirectory: info.isDirectory ?? false, size };
+ },
+ async makeDirectory(uri, options) {
+ await FileSystem.makeDirectoryAsync(uri, options);
+ },
+ async writeBytes(uri, bytes) {
+ new File(uri).write(bytes);
+ },
+ async copy(input) {
+ await FileSystem.copyAsync(input);
+ },
+ async readAsBase64(uri) {
+ return await FileSystem.readAsStringAsync(uri, {
+ encoding: FileSystem.EncodingType.Base64,
+ });
+ },
+ async delete(uri, options) {
+ await FileSystem.deleteAsync(uri, options);
+ },
+ async listDirectory(uri) {
+ return await FileSystem.readDirectoryAsync(uri);
+ },
+ };
+}
diff --git a/packages/app/src/attachments/local-file-attachment-store.test.ts b/packages/app/src/attachments/local-file-attachment-store.test.ts
index a9cc02bc0..48f974fa1 100644
--- a/packages/app/src/attachments/local-file-attachment-store.test.ts
+++ b/packages/app/src/attachments/local-file-attachment-store.test.ts
@@ -1,64 +1,14 @@
-import { beforeEach, describe, expect, it, vi } from "vitest";
+import { describe, expect, it } from "vitest";
import { createLocalFileAttachmentStore } from "./local-file-attachment-store";
-
-const fileSystemMock = vi.hoisted(() => ({
- getInfoAsync: vi.fn(async (uri: string) => {
- if (uri.endsWith("/preview-assets/")) {
- return { exists: true, isDirectory: true };
- }
- return { exists: true, isDirectory: false, size: 4 };
- }),
- makeDirectoryAsync: vi.fn(async () => {}),
- writeAsStringAsync: vi.fn(async () => {}),
- copyAsync: vi.fn(async () => {}),
- readAsStringAsync: vi.fn(async () => "AAECAw=="),
- deleteAsync: vi.fn(async () => {}),
- readDirectoryAsync: vi.fn(async () => []),
- fileWrite: vi.fn((_uri: string, _content: Uint8Array) => {}),
-}));
-
-vi.mock("expo-file-system", () => ({
- File: class {
- uri: string;
-
- constructor(uri: string) {
- this.uri = uri;
- }
-
- write(content: Uint8Array) {
- fileSystemMock.fileWrite(this.uri, content);
- }
- },
-}));
-
-vi.mock("expo-file-system/legacy", () => ({
- cacheDirectory: "file:///cache/",
- EncodingType: { Base64: "base64" },
- getInfoAsync: fileSystemMock.getInfoAsync,
- makeDirectoryAsync: fileSystemMock.makeDirectoryAsync,
- writeAsStringAsync: fileSystemMock.writeAsStringAsync,
- copyAsync: fileSystemMock.copyAsync,
- readAsStringAsync: fileSystemMock.readAsStringAsync,
- deleteAsync: fileSystemMock.deleteAsync,
- readDirectoryAsync: fileSystemMock.readDirectoryAsync,
-}));
+import { createTestAttachmentFileSystem } from "./test-attachment-file-system";
describe("local file attachment store", () => {
- beforeEach(() => {
- fileSystemMock.getInfoAsync.mockClear();
- fileSystemMock.makeDirectoryAsync.mockClear();
- fileSystemMock.writeAsStringAsync.mockClear();
- fileSystemMock.copyAsync.mockClear();
- fileSystemMock.readAsStringAsync.mockClear();
- fileSystemMock.deleteAsync.mockClear();
- fileSystemMock.readDirectoryAsync.mockClear();
- fileSystemMock.fileWrite.mockClear();
- });
-
it("writes raw byte sources directly to the managed file path", async () => {
+ const fileSystem = createTestAttachmentFileSystem();
const store = createLocalFileAttachmentStore({
storageType: "native-file",
baseDirectoryName: "preview-assets",
+ fileSystem,
resolvePreviewUrl: async (attachment) => `file://${attachment.storageKey}`,
});
@@ -69,11 +19,6 @@ describe("local file attachment store", () => {
source: { kind: "bytes", bytes: new Uint8Array([0, 1, 2, 3]) },
});
- expect(fileSystemMock.fileWrite).toHaveBeenCalledWith(
- "file:///cache/preview-assets/preview_8_test.png",
- new Uint8Array([0, 1, 2, 3]),
- );
- expect(fileSystemMock.writeAsStringAsync).not.toHaveBeenCalled();
expect(attachment).toMatchObject({
id: "preview_8_test",
mimeType: "image/png",
@@ -82,5 +27,9 @@ describe("local file attachment store", () => {
fileName: "result.png",
byteSize: 4,
});
+ expect(fileSystem.files.get("file:///cache/preview-assets/preview_8_test.png")).toEqual(
+ new Uint8Array([0, 1, 2, 3]),
+ );
+ expect(fileSystem.directories.has("file:///cache/preview-assets")).toBe(true);
});
});
diff --git a/packages/app/src/attachments/local-file-attachment-store.ts b/packages/app/src/attachments/local-file-attachment-store.ts
index 1ed2bd69e..b9e76a86e 100644
--- a/packages/app/src/attachments/local-file-attachment-store.ts
+++ b/packages/app/src/attachments/local-file-attachment-store.ts
@@ -1,5 +1,4 @@
-import { File } from "expo-file-system";
-import * as FileSystem from "expo-file-system/legacy";
+import type { AttachmentFileSystem } from "@/attachments/attachment-file-system";
import {
type AttachmentStore,
type AttachmentStorageType,
@@ -37,12 +36,12 @@ function extensionForAttachment(params: { fileName?: string | null; mimeType: st
return IMAGE_EXTENSION_BY_MIME_TYPE[params.mimeType] ?? ".img";
}
-async function ensureDirectory(uri: string): Promise {
- const info = await FileSystem.getInfoAsync(uri);
+async function ensureDirectory(fileSystem: AttachmentFileSystem, uri: string): Promise {
+ const info = await fileSystem.getInfo(uri);
if (info.exists && info.isDirectory) {
return;
}
- await FileSystem.makeDirectoryAsync(uri, { intermediates: true });
+ await fileSystem.makeDirectory(uri, { intermediates: true });
}
async function dataUrlToBytes(dataUrl: string): Promise {
@@ -55,6 +54,7 @@ async function blobToBytes(blob: Blob): Promise {
}
async function writeFromSource(input: {
+ fileSystem: AttachmentFileSystem;
source: SaveAttachmentInput["source"];
targetUri: string;
mimeType: string;
@@ -64,7 +64,7 @@ async function writeFromSource(input: {
if (from === input.targetUri) {
return;
}
- await FileSystem.copyAsync({ from, to: input.targetUri });
+ await input.fileSystem.copy({ from, to: input.targetUri });
return;
}
@@ -77,7 +77,7 @@ async function writeFromSource(input: {
bytes = input.source.bytes;
}
- new File(input.targetUri).write(bytes);
+ await input.fileSystem.writeBytes(input.targetUri, bytes);
}
function attachmentUri(metadata: AttachmentMetadata): string {
@@ -87,11 +87,13 @@ function attachmentUri(metadata: AttachmentMetadata): string {
export function createLocalFileAttachmentStore(params: {
storageType: Extract;
baseDirectoryName: string;
+ fileSystem: AttachmentFileSystem;
resolvePreviewUrl: (attachment: AttachmentMetadata) => Promise;
releasePreviewUrl?: (input: { attachment: AttachmentMetadata; url: string }) => Promise;
}): AttachmentStore {
- const baseDirectory = FileSystem.cacheDirectory
- ? `${FileSystem.cacheDirectory}${params.baseDirectoryName}/`
+ const { fileSystem } = params;
+ const baseDirectory = fileSystem.cacheDirectory
+ ? `${fileSystem.cacheDirectory}${params.baseDirectoryName}/`
: null;
async function resolveTarget(input: SaveAttachmentInput): Promise<{
@@ -103,10 +105,10 @@ export function createLocalFileAttachmentStore(params: {
storageKey: string;
}> {
if (!baseDirectory) {
- throw new Error("expo-file-system cacheDirectory is unavailable.");
+ throw new Error("Attachment file-system cacheDirectory is unavailable.");
}
- await ensureDirectory(baseDirectory);
+ await ensureDirectory(fileSystem, baseDirectory);
const id = input.id ?? generateAttachmentId();
let mimeTypeFromSource: string | undefined;
@@ -140,16 +142,14 @@ export function createLocalFileAttachmentStore(params: {
async save(input): Promise {
const target = await resolveTarget(input);
await writeFromSource({
+ fileSystem,
source: input.source,
targetUri: target.targetUri,
mimeType: target.mimeType,
});
- const info = await FileSystem.getInfoAsync(target.targetUri);
- const byteSize =
- info.exists && typeof (info as { size?: number }).size === "number"
- ? (info as { size: number }).size
- : null;
+ const info = await fileSystem.getInfo(target.targetUri);
+ const byteSize = info.exists ? info.size : null;
return {
id: target.id,
mimeType: target.mimeType,
@@ -162,10 +162,7 @@ export function createLocalFileAttachmentStore(params: {
},
async encodeBase64({ attachment }): Promise {
- const uri = attachmentUri(attachment);
- return await FileSystem.readAsStringAsync(uri, {
- encoding: FileSystem.EncodingType.Base64,
- });
+ return await fileSystem.readAsBase64(attachmentUri(attachment));
},
async resolvePreviewUrl({ attachment }): Promise {
@@ -184,22 +181,22 @@ export function createLocalFileAttachmentStore(params: {
: {}),
async delete({ attachment }): Promise {
- await FileSystem.deleteAsync(attachmentUri(attachment), { idempotent: true });
+ await fileSystem.delete(attachmentUri(attachment), { idempotent: true });
},
async garbageCollect({ referencedIds }): Promise {
if (!baseDirectory) {
return;
}
- await ensureDirectory(baseDirectory);
- const entries = await FileSystem.readDirectoryAsync(baseDirectory);
+ await ensureDirectory(fileSystem, baseDirectory);
+ const entries = await fileSystem.listDirectory(baseDirectory);
await Promise.all(
entries.map(async (entryName) => {
const id = entryName.split(".", 1)[0] ?? "";
if (!id || referencedIds.has(id)) {
return;
}
- await FileSystem.deleteAsync(`${baseDirectory}${entryName}`, {
+ await fileSystem.delete(`${baseDirectory}${entryName}`, {
idempotent: true,
});
}),
diff --git a/packages/app/src/attachments/native/native-file-attachment-store.ts b/packages/app/src/attachments/native/native-file-attachment-store.ts
index 78c3d97cd..0d5135563 100644
--- a/packages/app/src/attachments/native/native-file-attachment-store.ts
+++ b/packages/app/src/attachments/native/native-file-attachment-store.ts
@@ -1,3 +1,4 @@
+import { createExpoAttachmentFileSystem } from "@/attachments/attachment-file-system";
import { createLocalFileAttachmentStore } from "@/attachments/local-file-attachment-store";
import { isAbsolutePath } from "@/utils/path";
@@ -5,6 +6,7 @@ export function createNativeFileAttachmentStore() {
return createLocalFileAttachmentStore({
storageType: "native-file",
baseDirectoryName: "paseo-native-attachments",
+ fileSystem: createExpoAttachmentFileSystem(),
resolvePreviewUrl: async (attachment) => {
if (attachment.storageKey.startsWith("file://")) {
return attachment.storageKey;
diff --git a/packages/app/src/attachments/store.ts b/packages/app/src/attachments/store.ts
index ffcfc6e50..8371d3e45 100644
--- a/packages/app/src/attachments/store.ts
+++ b/packages/app/src/attachments/store.ts
@@ -9,7 +9,9 @@ async function createAttachmentStore(): Promise {
if (isElectronRuntime()) {
const { createDesktopAttachmentStore } =
await import("../desktop/attachments/desktop-attachment-store");
- return createDesktopAttachmentStore();
+ const { createDesktopAttachmentBridge } =
+ await import("../desktop/attachments/desktop-attachment-bridge");
+ return createDesktopAttachmentStore(createDesktopAttachmentBridge());
}
const { createIndexedDbAttachmentStore } = await import("./web/indexeddb-attachment-store");
diff --git a/packages/app/src/attachments/test-attachment-file-system.ts b/packages/app/src/attachments/test-attachment-file-system.ts
new file mode 100644
index 000000000..f035da96a
--- /dev/null
+++ b/packages/app/src/attachments/test-attachment-file-system.ts
@@ -0,0 +1,95 @@
+import type { AttachmentFileInfo, AttachmentFileSystem } from "./attachment-file-system";
+
+export interface TestAttachmentFileSystem extends AttachmentFileSystem {
+ readonly files: ReadonlyMap;
+ readonly directories: ReadonlySet;
+ setFile(uri: string, bytes: Uint8Array): void;
+ setDirectory(uri: string): void;
+}
+
+export function createTestAttachmentFileSystem(options?: {
+ cacheDirectory?: string | null;
+}): TestAttachmentFileSystem {
+ const files = new Map();
+ const directories = new Set();
+ const cacheDirectory =
+ options && Object.hasOwn(options, "cacheDirectory")
+ ? (options.cacheDirectory ?? null)
+ : "file:///cache/";
+
+ function describe(uri: string): AttachmentFileInfo {
+ if (directories.has(uri) || directories.has(stripTrailingSlash(uri))) {
+ return { exists: true, isDirectory: true, size: null };
+ }
+ const bytes = files.get(uri);
+ if (bytes) {
+ return { exists: true, isDirectory: false, size: bytes.byteLength };
+ }
+ return { exists: false };
+ }
+
+ return {
+ files,
+ directories,
+ cacheDirectory,
+ setFile(uri, bytes) {
+ files.set(uri, bytes);
+ },
+ setDirectory(uri) {
+ directories.add(stripTrailingSlash(uri));
+ },
+ async getInfo(uri) {
+ return describe(uri);
+ },
+ async makeDirectory(uri) {
+ directories.add(stripTrailingSlash(uri));
+ },
+ async writeBytes(uri, bytes) {
+ files.set(uri, bytes);
+ },
+ async copy({ from, to }) {
+ const bytes = files.get(from);
+ if (!bytes) {
+ throw new Error(`copy: source does not exist: ${from}`);
+ }
+ files.set(to, bytes);
+ },
+ async readAsBase64(uri) {
+ const bytes = files.get(uri);
+ if (!bytes) {
+ throw new Error(`readAsBase64: file does not exist: ${uri}`);
+ }
+ return toBase64(bytes);
+ },
+ async delete(uri, deleteOptions) {
+ if (!files.delete(uri) && !deleteOptions.idempotent) {
+ throw new Error(`delete: file does not exist: ${uri}`);
+ }
+ },
+ async listDirectory(uri) {
+ const prefix = uri.endsWith("/") ? uri : `${uri}/`;
+ const entries: string[] = [];
+ for (const path of files.keys()) {
+ if (path.startsWith(prefix)) {
+ entries.push(path.slice(prefix.length));
+ }
+ }
+ return entries;
+ },
+ };
+}
+
+function stripTrailingSlash(uri: string): string {
+ return uri.endsWith("/") ? uri.slice(0, -1) : uri;
+}
+
+function toBase64(bytes: Uint8Array): string {
+ let binary = "";
+ for (const byte of bytes) {
+ binary += String.fromCharCode(byte);
+ }
+ if (typeof btoa === "function") {
+ return btoa(binary);
+ }
+ return Buffer.from(binary, "binary").toString("base64");
+}
diff --git a/packages/app/src/components/drag-reorder/drag-reducer.test.ts b/packages/app/src/components/drag-reorder/drag-reducer.test.ts
new file mode 100644
index 000000000..623f7cdca
--- /dev/null
+++ b/packages/app/src/components/drag-reorder/drag-reducer.test.ts
@@ -0,0 +1,32 @@
+import { describe, expect, it } from "vitest";
+import { dragStateInitial, dragStateReducer } from "./drag-reducer";
+
+describe("dragStateReducer", () => {
+ it("starts tracking the active item with a snapshot of the data", () => {
+ const next = dragStateReducer(dragStateInitial(), {
+ type: "start",
+ id: "alpha",
+ data: ["alpha", "beta"],
+ });
+
+ expect(next).toEqual({ activeId: "alpha", dragItems: ["alpha", "beta"] });
+ });
+
+ it("clears the active item and the snapshot", () => {
+ const next = dragStateReducer(
+ { activeId: "alpha", dragItems: ["alpha", "beta"] },
+ { type: "clear" },
+ );
+
+ expect(next).toEqual({ activeId: null, dragItems: null });
+ });
+
+ it("replaces an in-flight drag when a new one starts", () => {
+ const next = dragStateReducer(
+ { activeId: "alpha", dragItems: ["alpha", "beta"] },
+ { type: "start", id: "beta", data: ["beta", "gamma"] },
+ );
+
+ expect(next).toEqual({ activeId: "beta", dragItems: ["beta", "gamma"] });
+ });
+});
diff --git a/packages/app/src/components/drag-reorder/drag-reducer.ts b/packages/app/src/components/drag-reorder/drag-reducer.ts
new file mode 100644
index 000000000..b9b508f2d
--- /dev/null
+++ b/packages/app/src/components/drag-reorder/drag-reducer.ts
@@ -0,0 +1,19 @@
+export interface DragState {
+ activeId: string | null;
+ dragItems: T[] | null;
+}
+
+export type DragAction = { type: "start"; id: string; data: T[] } | { type: "clear" };
+
+export function dragStateInitial(): DragState {
+ return { activeId: null, dragItems: null };
+}
+
+export function dragStateReducer(state: DragState, action: DragAction): DragState {
+ switch (action.type) {
+ case "start":
+ return { activeId: action.id, dragItems: action.data };
+ case "clear":
+ return { activeId: null, dragItems: null };
+ }
+}
diff --git a/packages/app/src/components/drag-reorder/index.ts b/packages/app/src/components/drag-reorder/index.ts
new file mode 100644
index 000000000..4016bd1e6
--- /dev/null
+++ b/packages/app/src/components/drag-reorder/index.ts
@@ -0,0 +1,12 @@
+export { reorderItemsOnDragEnd } from "./reorder-items";
+export type { DragEndInput } from "./reorder-items";
+export {
+ getPointerActivationConstraint,
+ type PointerActivationConfig,
+ type PointerActivationConstraint,
+} from "./pointer-activation";
+export {
+ useDragReorderState,
+ type DragReorderHandlers,
+ type DragReorderState,
+} from "./use-drag-reorder-state";
diff --git a/packages/app/src/components/drag-reorder/pointer-activation.test.ts b/packages/app/src/components/drag-reorder/pointer-activation.test.ts
new file mode 100644
index 000000000..d1c6fa041
--- /dev/null
+++ b/packages/app/src/components/drag-reorder/pointer-activation.test.ts
@@ -0,0 +1,14 @@
+import { describe, expect, it } from "vitest";
+import { getPointerActivationConstraint } from "./pointer-activation";
+
+const config = { defaultDistance: 6, holdDelayMs: 250, holdTolerance: 8 };
+
+describe("getPointerActivationConstraint", () => {
+ it("uses distance activation for default draggable rows", () => {
+ expect(getPointerActivationConstraint(false, config)).toEqual({ distance: 6 });
+ });
+
+ it("requires a held pointer before activating handle-based drags", () => {
+ expect(getPointerActivationConstraint(true, config)).toEqual({ delay: 250, tolerance: 8 });
+ });
+});
diff --git a/packages/app/src/components/drag-reorder/pointer-activation.ts b/packages/app/src/components/drag-reorder/pointer-activation.ts
new file mode 100644
index 000000000..cb68e2bda
--- /dev/null
+++ b/packages/app/src/components/drag-reorder/pointer-activation.ts
@@ -0,0 +1,19 @@
+export type PointerActivationConstraint =
+ | { distance: number }
+ | { delay: number; tolerance: number };
+
+export interface PointerActivationConfig {
+ defaultDistance: number;
+ holdDelayMs: number;
+ holdTolerance: number;
+}
+
+export function getPointerActivationConstraint(
+ useDragHandle: boolean,
+ config: PointerActivationConfig,
+): PointerActivationConstraint {
+ if (useDragHandle) {
+ return { delay: config.holdDelayMs, tolerance: config.holdTolerance };
+ }
+ return { distance: config.defaultDistance };
+}
diff --git a/packages/app/src/components/drag-reorder/reorder-items.test.ts b/packages/app/src/components/drag-reorder/reorder-items.test.ts
new file mode 100644
index 000000000..29992cf24
--- /dev/null
+++ b/packages/app/src/components/drag-reorder/reorder-items.test.ts
@@ -0,0 +1,62 @@
+import { describe, expect, it } from "vitest";
+import { reorderItemsOnDragEnd } from "./reorder-items";
+
+const items = ["alpha", "beta", "gamma"];
+const byValue = (item: string): string => item;
+
+describe("reorderItemsOnDragEnd", () => {
+ it("moves the active item to the over position", () => {
+ expect(
+ reorderItemsOnDragEnd({
+ items,
+ activeId: "alpha",
+ overId: "gamma",
+ keyExtractor: byValue,
+ }),
+ ).toEqual(["beta", "gamma", "alpha"]);
+ });
+
+ it("is a no-op when the drop target is missing", () => {
+ expect(
+ reorderItemsOnDragEnd({
+ items,
+ activeId: "alpha",
+ overId: null,
+ keyExtractor: byValue,
+ }),
+ ).toBeNull();
+ });
+
+ it("is a no-op when the active and over items are the same", () => {
+ expect(
+ reorderItemsOnDragEnd({
+ items,
+ activeId: "beta",
+ overId: "beta",
+ keyExtractor: byValue,
+ }),
+ ).toBeNull();
+ });
+
+ it("is a no-op when the active id is not in the list", () => {
+ expect(
+ reorderItemsOnDragEnd({
+ items,
+ activeId: "delta",
+ overId: "beta",
+ keyExtractor: byValue,
+ }),
+ ).toBeNull();
+ });
+
+ it("is a no-op when the over id is not in the list", () => {
+ expect(
+ reorderItemsOnDragEnd({
+ items,
+ activeId: "alpha",
+ overId: "delta",
+ keyExtractor: byValue,
+ }),
+ ).toBeNull();
+ });
+});
diff --git a/packages/app/src/components/drag-reorder/reorder-items.ts b/packages/app/src/components/drag-reorder/reorder-items.ts
new file mode 100644
index 000000000..b3de8afec
--- /dev/null
+++ b/packages/app/src/components/drag-reorder/reorder-items.ts
@@ -0,0 +1,24 @@
+import { arrayMove } from "@dnd-kit/sortable";
+
+export interface DragEndInput {
+ items: T[];
+ activeId: string;
+ overId: string | null | undefined;
+ keyExtractor: (item: T, index: number) => string;
+}
+
+export function reorderItemsOnDragEnd({
+ items,
+ activeId,
+ overId,
+ keyExtractor,
+}: DragEndInput): T[] | null {
+ if (!overId || activeId === overId) return null;
+
+ const oldIndex = items.findIndex((item, i) => keyExtractor(item, i) === activeId);
+ const newIndex = items.findIndex((item, i) => keyExtractor(item, i) === overId);
+
+ if (oldIndex < 0 || newIndex < 0 || oldIndex === newIndex) return null;
+
+ return arrayMove(items, oldIndex, newIndex);
+}
diff --git a/packages/app/src/components/drag-reorder/use-drag-reorder-state.ts b/packages/app/src/components/drag-reorder/use-drag-reorder-state.ts
new file mode 100644
index 000000000..400beeb28
--- /dev/null
+++ b/packages/app/src/components/drag-reorder/use-drag-reorder-state.ts
@@ -0,0 +1,73 @@
+import { useCallback, useReducer } from "react";
+import type { DragEndEvent, DragStartEvent } from "@dnd-kit/core";
+import { dragStateInitial, dragStateReducer } from "./drag-reducer";
+import { reorderItemsOnDragEnd } from "./reorder-items";
+
+export interface DragReorderHandlers {
+ onDragStart: (event: DragStartEvent) => void;
+ onDragCancel: () => void;
+ onDragEnd: (event: DragEndEvent) => void;
+}
+
+export interface DragReorderState {
+ activeId: string | null;
+ items: T[];
+ handlers: DragReorderHandlers;
+}
+
+export function useDragReorderState({
+ data,
+ keyExtractor,
+ onDragEnd,
+ onDragBegin,
+ disabled = false,
+}: {
+ data: T[];
+ keyExtractor: (item: T, index: number) => string;
+ onDragEnd?: (items: T[]) => void;
+ onDragBegin?: () => void;
+ disabled?: boolean;
+}): DragReorderState {
+ const [state, dispatch] = useReducer(dragStateReducer, undefined, dragStateInitial);
+
+ const handleDragStart = useCallback(
+ (event: DragStartEvent) => {
+ if (disabled) return;
+ dispatch({ type: "start", id: String(event.active.id), data });
+ onDragBegin?.();
+ },
+ [data, disabled, onDragBegin],
+ );
+
+ const clearDragState = useCallback(() => {
+ dispatch({ type: "clear" });
+ }, []);
+
+ const handleDragEnd = useCallback(
+ (event: DragEndEvent) => {
+ const { active, over } = event;
+ const items = state.dragItems ?? data;
+ dispatch({ type: "clear" });
+ if (disabled) return;
+
+ const reordered = reorderItemsOnDragEnd({
+ items,
+ activeId: String(active.id),
+ overId: over ? String(over.id) : null,
+ keyExtractor,
+ });
+ if (reordered) onDragEnd?.(reordered);
+ },
+ [data, disabled, keyExtractor, onDragEnd, state.dragItems],
+ );
+
+ return {
+ activeId: state.activeId,
+ items: state.dragItems ?? data,
+ handlers: {
+ onDragStart: handleDragStart,
+ onDragCancel: clearDragState,
+ onDragEnd: handleDragEnd,
+ },
+ };
+}
diff --git a/packages/app/src/components/draggable-list.web.test.tsx b/packages/app/src/components/draggable-list.web.test.tsx
deleted file mode 100644
index 29fc391c4..000000000
--- a/packages/app/src/components/draggable-list.web.test.tsx
+++ /dev/null
@@ -1,167 +0,0 @@
-/**
- * @vitest-environment jsdom
- */
-import React from "react";
-import { act } from "react";
-import { createRoot, type Root } from "react-dom/client";
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { DraggableList } from "./draggable-list.web";
-
-interface DndContextProps {
- onDragStart?: (event: { active: { id: string } }) => void;
- onDragCancel?: () => void;
-}
-
-let latestDndContextProps: DndContextProps | null = null;
-const dndKitMocks = vi.hoisted(() => ({
- useSensor: vi.fn(() => ({})),
-}));
-
-vi.mock("@dnd-kit/core", () => ({
- DndContext: ({ children, ...props }: React.PropsWithChildren) => {
- latestDndContextProps = props;
- return {children}
;
- },
- closestCenter: vi.fn(),
- KeyboardSensor: vi.fn(),
- PointerSensor: vi.fn(),
- useSensor: dndKitMocks.useSensor,
- useSensors: vi.fn(() => []),
-}));
-
-vi.mock("@dnd-kit/sortable", () => ({
- SortableContext: ({ children }: React.PropsWithChildren) => children,
- arrayMove: (items: T[], from: number, to: number) => {
- const next = [...items];
- const [item] = next.splice(from, 1);
- if (item !== undefined) {
- next.splice(to, 0, item);
- }
- return next;
- },
- sortableKeyboardCoordinates: vi.fn(),
- useSortable: () => ({
- attributes: {},
- listeners: {},
- setNodeRef: vi.fn(),
- setActivatorNodeRef: vi.fn(),
- transform: null,
- transition: undefined,
- isDragging: false,
- }),
- verticalListSortingStrategy: {},
-}));
-
-vi.mock("./use-web-scrollbar", () => ({
- useWebScrollViewScrollbar: () => ({
- onLayout: vi.fn(),
- onContentSizeChange: vi.fn(),
- onScroll: vi.fn(),
- overlay: null,
- }),
-}));
-
-let root: Root | null = null;
-let container: HTMLElement | null = null;
-
-beforeEach(() => {
- vi.stubGlobal("React", React);
- vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
- vi.stubGlobal(
- "ResizeObserver",
- class ResizeObserver {
- observe() {}
- unobserve() {}
- disconnect() {}
- },
- );
- latestDndContextProps = null;
- container = document.createElement("div");
- document.body.appendChild(container);
- root = createRoot(container);
-});
-
-afterEach(() => {
- if (root) {
- act(() => {
- root?.unmount();
- });
- }
- root = null;
- container?.remove();
- container = null;
- vi.unstubAllGlobals();
-});
-
-const DATA: string[] = ["alpha", "beta"];
-
-function keyExtractor(item: string): string {
- return item;
-}
-
-function renderItem({ item, isActive }: { item: string; isActive: boolean }) {
- return (
-
- {item}
-
- );
-}
-
-function renderList({ useDragHandle = false }: { useDragHandle?: boolean } = {}): void {
- act(() => {
- root?.render(
- ,
- );
- });
-}
-
-function getItemActiveState(item: string): string | null {
- return (
- container?.querySelector(`[data-testid="item-${item}"]`)?.getAttribute("data-active") ?? null
- );
-}
-
-describe("DraggableList web", () => {
- it("uses distance activation for default draggable rows", () => {
- renderList();
-
- expect(dndKitMocks.useSensor).toHaveBeenCalledWith(
- expect.any(Function),
- expect.objectContaining({
- activationConstraint: { distance: 6 },
- }),
- );
- });
-
- it("requires a held pointer before activating handle-based drags", () => {
- renderList({ useDragHandle: true });
-
- expect(dndKitMocks.useSensor).toHaveBeenCalledWith(
- expect.any(Function),
- expect.objectContaining({
- activationConstraint: { delay: 250, tolerance: 8 },
- }),
- );
- });
-
- it("clears active drag state when a drag is cancelled", () => {
- renderList();
-
- act(() => {
- latestDndContextProps?.onDragStart?.({ active: { id: "alpha" } });
- });
- expect(getItemActiveState("alpha")).toBe("true");
-
- act(() => {
- latestDndContextProps?.onDragCancel?.();
- });
- expect(getItemActiveState("alpha")).toBe("false");
- });
-});
diff --git a/packages/app/src/components/draggable-list.web.tsx b/packages/app/src/components/draggable-list.web.tsx
index a6dd57f7f..e9f673c67 100644
--- a/packages/app/src/components/draggable-list.web.tsx
+++ b/packages/app/src/components/draggable-list.web.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useMemo, useRef, useState, type ReactElement } from "react";
+import { useCallback, useMemo, useRef, type ReactElement } from "react";
import { ScrollView, View } from "react-native";
import {
DndContext,
@@ -8,19 +8,17 @@ import {
type Modifier,
useSensor,
useSensors,
- type DragEndEvent,
- type DragStartEvent,
} from "@dnd-kit/core";
import {
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
useSortable,
- arrayMove,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { DraggableListProps, DraggableRenderItemInfo } from "./draggable-list.types";
import { useWebScrollViewScrollbar } from "./use-web-scrollbar";
+import { getPointerActivationConstraint, useDragReorderState } from "./drag-reorder";
export type { DraggableListProps, DraggableRenderItemInfo };
@@ -30,8 +28,11 @@ const restrictToVerticalAxis: Modifier = ({ transform }) => ({
});
const DND_MODIFIERS = [restrictToVerticalAxis];
-const DEFAULT_POINTER_ACTIVATION_CONSTRAINT = { distance: 6 };
-const HANDLE_POINTER_ACTIVATION_CONSTRAINT = { delay: 250, tolerance: 8 };
+const POINTER_ACTIVATION_CONFIG = {
+ defaultDistance: 6,
+ holdDelayMs: 250,
+ holdTolerance: 8,
+};
interface SortableItemProps {
id: string;
@@ -137,17 +138,21 @@ export function DraggableList({
onDragBegin,
nestable: _nestable = false,
}: DraggableListProps) {
- const [activeId, setActiveId] = useState(null);
- const [dragItems, setDragItems] = useState(null);
- const items = dragItems ?? data;
+ const { activeId, items, handlers } = useDragReorderState({
+ data,
+ keyExtractor,
+ onDragEnd,
+ onDragBegin,
+ });
const showCustomScrollbar = enableDesktopWebScrollbar && scrollEnabled;
const scrollViewRef = useRef(null);
const scrollbar = useWebScrollViewScrollbar(scrollViewRef, {
enabled: showCustomScrollbar,
});
- const pointerActivationConstraint = useDragHandle
- ? HANDLE_POINTER_ACTIVATION_CONSTRAINT
- : DEFAULT_POINTER_ACTIVATION_CONSTRAINT;
+ const pointerActivationConstraint = getPointerActivationConstraint(
+ useDragHandle,
+ POINTER_ACTIVATION_CONFIG,
+ );
const sensors = useSensors(
useSensor(PointerSensor, {
@@ -158,39 +163,6 @@ export function DraggableList({
}),
);
- const handleDragStart = useCallback(
- (event: DragStartEvent) => {
- setDragItems(data);
- setActiveId(String(event.active.id));
- onDragBegin?.();
- },
- [data, onDragBegin],
- );
-
- const clearDragState = useCallback(() => {
- setActiveId(null);
- setDragItems(null);
- }, []);
-
- const handleDragEnd = useCallback(
- (event: DragEndEvent) => {
- const { active, over } = event;
-
- clearDragState();
-
- if (over && active.id !== over.id) {
- const oldIndex = items.findIndex((item, i) => keyExtractor(item, i) === active.id);
- const newIndex = items.findIndex((item, i) => keyExtractor(item, i) === over.id);
-
- if (oldIndex >= 0 && newIndex >= 0 && oldIndex !== newIndex) {
- const newItems = arrayMove(items, oldIndex, newIndex);
- onDragEnd(newItems);
- }
- }
- },
- [clearDragState, items, keyExtractor, onDragEnd],
- );
-
const ids = useMemo(
() => items.map((item, index) => keyExtractor(item, index)),
[items, keyExtractor],
@@ -224,9 +196,9 @@ export function DraggableList({
sensors={sensors}
collisionDetection={closestCenter}
modifiers={DND_MODIFIERS}
- onDragStart={handleDragStart}
- onDragCancel={clearDragState}
- onDragEnd={handleDragEnd}
+ onDragStart={handlers.onDragStart}
+ onDragCancel={handlers.onDragCancel}
+ onDragEnd={handlers.onDragEnd}
>
{items.map((item, index) => {
@@ -255,9 +227,9 @@ export function DraggableList({
sensors={sensors}
collisionDetection={closestCenter}
modifiers={DND_MODIFIERS}
- onDragStart={handleDragStart}
- onDragCancel={clearDragState}
- onDragEnd={handleDragEnd}
+ onDragStart={handlers.onDragStart}
+ onDragCancel={handlers.onDragCancel}
+ onDragEnd={handlers.onDragEnd}
>
{items.map((item, index) => {
diff --git a/packages/app/src/components/import-session-sheet-view-model.test.ts b/packages/app/src/components/import-session-sheet-view-model.test.ts
new file mode 100644
index 000000000..dd79b45c7
--- /dev/null
+++ b/packages/app/src/components/import-session-sheet-view-model.test.ts
@@ -0,0 +1,273 @@
+import { describe, expect, it } from "vitest";
+import type { FetchRecentProviderSessionEntry } from "@getpaseo/client/internal/daemon-client";
+import {
+ aggregateSessionEntries,
+ ALL_FILTER_VALUE,
+ buildProviderLabelMap,
+ collectErroredProviderLabels,
+ computeEmptyState,
+ getPromptPreview,
+ getSessionTitle,
+ resolveProvidersToFetch,
+ type SessionsQueryResult,
+ sumFilteredAlreadyImportedCount,
+} from "@/components/import-session-sheet-view-model";
+
+function entry(
+ overrides: Partial = {},
+): FetchRecentProviderSessionEntry {
+ return {
+ providerId: "claude",
+ providerLabel: "Claude Code",
+ providerHandleId: "thread-1",
+ cwd: "/repo/paseo",
+ title: null,
+ firstPromptPreview: null,
+ lastPromptPreview: null,
+ lastActivityAt: "2026-04-30T10:00:00.000Z",
+ ...overrides,
+ };
+}
+
+function settled(
+ data: SessionsQueryResult["data"],
+ flags?: Partial>,
+): SessionsQueryResult {
+ return {
+ data,
+ isError: false,
+ isLoading: false,
+ isPending: false,
+ ...flags,
+ };
+}
+
+describe("resolveProvidersToFetch", () => {
+ it("returns null when the daemon does not support provider snapshots", () => {
+ expect(resolveProvidersToFetch(false, [{ provider: "claude" }])).toBeNull();
+ });
+
+ it("returns null while snapshot entries have not loaded yet", () => {
+ expect(resolveProvidersToFetch(true, undefined)).toBeNull();
+ });
+
+ it("returns only enabled importable providers", () => {
+ const providers = resolveProvidersToFetch(true, [
+ { provider: "claude" },
+ { provider: "codex" },
+ { provider: "opencode", enabled: false },
+ { provider: "z-ai" },
+ ]);
+ expect(providers).toEqual(["claude", "codex"]);
+ });
+
+ it("returns an empty array when snapshot has no enabled importable providers", () => {
+ const providers = resolveProvidersToFetch(true, [
+ { provider: "claude", enabled: false },
+ { provider: "z-ai" },
+ ]);
+ expect(providers).toEqual([]);
+ });
+});
+
+describe("buildProviderLabelMap", () => {
+ it("returns an empty map when snapshot entries are missing", () => {
+ expect(buildProviderLabelMap(undefined).size).toBe(0);
+ });
+
+ it("indexes labels by provider id, skipping entries without a label", () => {
+ const labels = buildProviderLabelMap([
+ { provider: "claude", label: "Claude Code" },
+ { provider: "codex" },
+ { provider: "z-ai", label: "Z.AI" },
+ ]);
+ expect(labels.get("claude")).toBe("Claude Code");
+ expect(labels.get("codex")).toBeUndefined();
+ expect(labels.get("z-ai")).toBe("Z.AI");
+ });
+});
+
+describe("aggregateSessionEntries", () => {
+ it("returns an empty array when no queries have data", () => {
+ expect(aggregateSessionEntries([settled(undefined)])).toEqual([]);
+ });
+
+ it("dedupes by providerId+providerHandleId across query results", () => {
+ const result = aggregateSessionEntries([
+ settled({
+ entries: [
+ entry({ providerHandleId: "thread-1", lastActivityAt: "2026-04-30T10:00:00.000Z" }),
+ ],
+ }),
+ settled({
+ entries: [
+ entry({ providerHandleId: "thread-1", lastActivityAt: "2026-04-30T11:00:00.000Z" }),
+ entry({ providerHandleId: "thread-2", lastActivityAt: "2026-04-30T09:00:00.000Z" }),
+ ],
+ }),
+ ]);
+ expect(result.map((e) => e.providerHandleId)).toEqual(["thread-1", "thread-2"]);
+ });
+
+ it("sorts collected entries by lastActivityAt descending", () => {
+ const result = aggregateSessionEntries([
+ settled({
+ entries: [
+ entry({ providerHandleId: "old", lastActivityAt: "2026-04-29T10:00:00.000Z" }),
+ entry({ providerHandleId: "new", lastActivityAt: "2026-04-30T10:00:00.000Z" }),
+ ],
+ }),
+ ]);
+ expect(result.map((e) => e.providerHandleId)).toEqual(["new", "old"]);
+ });
+});
+
+describe("sumFilteredAlreadyImportedCount", () => {
+ it("returns 0 when no queries report a filtered count", () => {
+ expect(sumFilteredAlreadyImportedCount([settled({ entries: [] })])).toBe(0);
+ });
+
+ it("sums the filtered already-imported counts across queries", () => {
+ const total = sumFilteredAlreadyImportedCount([
+ settled({ entries: [], filteredAlreadyImportedCount: 2 }),
+ settled({ entries: [], filteredAlreadyImportedCount: 3 }),
+ settled(undefined),
+ ]);
+ expect(total).toBe(5);
+ });
+});
+
+describe("collectErroredProviderLabels", () => {
+ it("returns no labels when no providers are being fetched", () => {
+ expect(collectErroredProviderLabels(null, [], new Map())).toEqual([]);
+ });
+
+ it("returns labels for each errored provider, falling back to provider id", () => {
+ const labels = collectErroredProviderLabels(
+ ["claude", "codex"],
+ [settled(undefined, { isError: true }), settled({ entries: [] })],
+ new Map([["claude", "Claude Code"]]),
+ );
+ expect(labels).toEqual(["Claude Code"]);
+ });
+
+ it("uses provider id when the label map has no entry", () => {
+ const labels = collectErroredProviderLabels(
+ ["codex"],
+ [settled(undefined, { isError: true })],
+ new Map(),
+ );
+ expect(labels).toEqual(["codex"]);
+ });
+});
+
+describe("getSessionTitle", () => {
+ it("prefers the trimmed title", () => {
+ expect(getSessionTitle(entry({ title: " Importable " }))).toBe("Importable");
+ });
+
+ it("falls back to the trimmed first prompt preview when title is empty", () => {
+ expect(getSessionTitle(entry({ title: " ", firstPromptPreview: " Hello " }))).toBe("Hello");
+ });
+
+ it("falls back to Untitled session when both title and first prompt are blank", () => {
+ expect(getSessionTitle(entry({ title: null, firstPromptPreview: " " }))).toBe(
+ "Untitled session",
+ );
+ });
+});
+
+describe("getPromptPreview", () => {
+ it("prefers the trimmed last prompt preview", () => {
+ expect(
+ getPromptPreview(
+ entry({ lastPromptPreview: " later ", firstPromptPreview: " earlier " }),
+ ),
+ ).toBe("later");
+ });
+
+ it("falls back to the first prompt preview when last is blank", () => {
+ expect(
+ getPromptPreview(entry({ lastPromptPreview: " ", firstPromptPreview: " earlier " })),
+ ).toBe("earlier");
+ });
+
+ it("falls back to a placeholder when both prompts are blank", () => {
+ expect(getPromptPreview(entry({ lastPromptPreview: null, firstPromptPreview: null }))).toBe(
+ "No prompt preview",
+ );
+ });
+});
+
+describe("computeEmptyState", () => {
+ const baseInputs = {
+ isLoadingSessions: false,
+ allQueriesErrored: false,
+ isQueryingProviders: true,
+ allQueriesSettled: true,
+ selectedProvider: ALL_FILTER_VALUE,
+ aggregatedCount: 0,
+ visibleCount: 0,
+ totalAlreadyImportedCount: 0,
+ providerLabelById: new Map(),
+ };
+
+ it("hides the empty state while sessions are still loading", () => {
+ const result = computeEmptyState({ ...baseInputs, isLoadingSessions: true });
+ expect(result.showEmptyState).toBe(false);
+ });
+
+ it("hides the empty state when every query errored", () => {
+ const result = computeEmptyState({ ...baseInputs, allQueriesErrored: true });
+ expect(result.showEmptyState).toBe(false);
+ });
+
+ it("hides the empty state until every provider query has settled", () => {
+ const result = computeEmptyState({ ...baseInputs, allQueriesSettled: false });
+ expect(result.showEmptyState).toBe(false);
+ });
+
+ it("hides the empty state when there are visible entries", () => {
+ const result = computeEmptyState({
+ ...baseInputs,
+ aggregatedCount: 2,
+ visibleCount: 2,
+ });
+ expect(result.showEmptyState).toBe(false);
+ });
+
+ it("shows the default no-sessions message when nothing is loaded and nothing is filtered", () => {
+ const result = computeEmptyState(baseInputs);
+ expect(result).toEqual({
+ showEmptyState: true,
+ emptyStateTitle: "No recent sessions to import.",
+ });
+ });
+
+ it("shows the already-imported message when imported entries were filtered out", () => {
+ const result = computeEmptyState({
+ ...baseInputs,
+ totalAlreadyImportedCount: 4,
+ });
+ expect(result.emptyStateTitle).toBe("All recent sessions are already imported.");
+ });
+
+ it("shows a provider-scoped message when a filter hides aggregated entries", () => {
+ const result = computeEmptyState({
+ ...baseInputs,
+ selectedProvider: "claude",
+ aggregatedCount: 3,
+ providerLabelById: new Map([["claude", "Claude Code"]]),
+ });
+ expect(result.emptyStateTitle).toBe("No Claude Code sessions found.");
+ });
+
+ it("falls back to the provider id when the filtered provider lacks a label", () => {
+ const result = computeEmptyState({
+ ...baseInputs,
+ selectedProvider: "z-ai",
+ aggregatedCount: 1,
+ });
+ expect(result.emptyStateTitle).toBe("No z-ai sessions found.");
+ });
+});
diff --git a/packages/app/src/components/import-session-sheet-view-model.ts b/packages/app/src/components/import-session-sheet-view-model.ts
new file mode 100644
index 000000000..31d81038e
--- /dev/null
+++ b/packages/app/src/components/import-session-sheet-view-model.ts
@@ -0,0 +1,145 @@
+import type { FetchRecentProviderSessionEntry } from "@getpaseo/client/internal/daemon-client";
+import type { AgentProvider } from "@getpaseo/protocol/agent-types";
+import { IMPORTABLE_PROVIDERS } from "@getpaseo/protocol/importable-providers";
+
+export const IMPORTABLE_PROVIDER_IDS: Set = new Set(IMPORTABLE_PROVIDERS);
+export const PER_PROVIDER_LIMIT = 15;
+export const ALL_FILTER_VALUE = "__all__";
+
+export interface SessionsQueryResult {
+ data:
+ | {
+ entries: FetchRecentProviderSessionEntry[];
+ filteredAlreadyImportedCount?: number;
+ }
+ | undefined;
+ isError: boolean;
+ isLoading: boolean;
+ isPending: boolean;
+}
+
+export function resolveProvidersToFetch(
+ supportsSnapshot: boolean,
+ snapshotEntries: ReadonlyArray<{ provider: string; enabled?: boolean }> | undefined,
+): AgentProvider[] | null {
+ // COMPAT(providersSnapshot): the import-recent-sessions feature ships alongside
+ // providersSnapshot (v0.1.48, 2026-04-05). Daemons older than that lack both —
+ // we render an "update host" empty state instead of degrading. Drop this gate
+ // when the supported daemon floor is >= v0.1.48 (target: 2026-10-05).
+ if (!supportsSnapshot) return null;
+ if (!snapshotEntries) return null;
+ return snapshotEntries
+ .filter((entry) => IMPORTABLE_PROVIDER_IDS.has(entry.provider) && entry.enabled !== false)
+ .map((entry) => entry.provider);
+}
+
+export function buildProviderLabelMap(
+ snapshotEntries: ReadonlyArray<{ provider: string; label?: string }> | undefined,
+): Map {
+ const map = new Map();
+ if (!snapshotEntries) return map;
+ for (const entry of snapshotEntries) {
+ if (entry.label) {
+ map.set(entry.provider, entry.label);
+ }
+ }
+ return map;
+}
+
+export function aggregateSessionEntries(
+ queries: ReadonlyArray,
+): FetchRecentProviderSessionEntry[] {
+ const seen = new Set();
+ const collected: FetchRecentProviderSessionEntry[] = [];
+ for (const query of queries) {
+ if (!query.data) continue;
+ for (const entry of query.data.entries) {
+ const key = `${entry.providerId}:${entry.providerHandleId}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+ collected.push(entry);
+ }
+ }
+ collected.sort(
+ (a, b) => new Date(b.lastActivityAt).getTime() - new Date(a.lastActivityAt).getTime(),
+ );
+ return collected;
+}
+
+export function sumFilteredAlreadyImportedCount(
+ queries: ReadonlyArray,
+): number {
+ let total = 0;
+ for (const query of queries) {
+ total += query.data?.filteredAlreadyImportedCount ?? 0;
+ }
+ return total;
+}
+
+export function collectErroredProviderLabels(
+ providersToFetch: AgentProvider[] | null,
+ queries: ReadonlyArray,
+ providerLabelById: ReadonlyMap,
+): string[] {
+ if (providersToFetch === null) return [];
+ const labels: string[] = [];
+ for (let index = 0; index < queries.length; index++) {
+ if (queries[index]?.isError) {
+ const provider = providersToFetch[index];
+ labels.push(providerLabelById.get(provider) ?? provider);
+ }
+ }
+ return labels;
+}
+
+export function getSessionTitle(entry: FetchRecentProviderSessionEntry): string {
+ const title = entry.title?.trim();
+ if (title) {
+ return title;
+ }
+ const firstPromptPreview = entry.firstPromptPreview?.trim();
+ if (firstPromptPreview) {
+ return firstPromptPreview;
+ }
+ return "Untitled session";
+}
+
+export function getPromptPreview(entry: FetchRecentProviderSessionEntry): string {
+ return entry.lastPromptPreview?.trim() || entry.firstPromptPreview?.trim() || "No prompt preview";
+}
+
+export interface EmptyStateInputs {
+ isLoadingSessions: boolean;
+ allQueriesErrored: boolean;
+ isQueryingProviders: boolean;
+ allQueriesSettled: boolean;
+ selectedProvider: string;
+ aggregatedCount: number;
+ visibleCount: number;
+ totalAlreadyImportedCount: number;
+ providerLabelById: ReadonlyMap;
+}
+
+export function computeEmptyState(input: EmptyStateInputs): {
+ showEmptyState: boolean;
+ emptyStateTitle: string;
+} {
+ const showEmptyState =
+ !input.isLoadingSessions &&
+ !input.allQueriesErrored &&
+ input.isQueryingProviders &&
+ input.allQueriesSettled &&
+ input.visibleCount === 0;
+ if (!showEmptyState) {
+ return { showEmptyState, emptyStateTitle: "" };
+ }
+ const isFilteredEmpty = input.selectedProvider !== ALL_FILTER_VALUE && input.aggregatedCount > 0;
+ if (isFilteredEmpty) {
+ const label = input.providerLabelById.get(input.selectedProvider) ?? input.selectedProvider;
+ return { showEmptyState, emptyStateTitle: `No ${label} sessions found.` };
+ }
+ if (input.totalAlreadyImportedCount > 0) {
+ return { showEmptyState, emptyStateTitle: "All recent sessions are already imported." };
+ }
+ return { showEmptyState, emptyStateTitle: "No recent sessions to import." };
+}
diff --git a/packages/app/src/components/import-session-sheet.tsx b/packages/app/src/components/import-session-sheet.tsx
index b737a10f1..b85976c88 100644
--- a/packages/app/src/components/import-session-sheet.tsx
+++ b/packages/app/src/components/import-session-sheet.tsx
@@ -6,7 +6,6 @@ import type {
FetchRecentProviderSessionEntry,
} from "@getpaseo/client/internal/daemon-client";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
-import { IMPORTABLE_PROVIDERS } from "@getpaseo/protocol/importable-providers";
import { Inbox, RotateCw } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
@@ -15,12 +14,21 @@ import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/s
import { getProviderIcon } from "@/components/provider-icons";
import { formatTimeAgo } from "@/utils/time";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
+import {
+ aggregateSessionEntries,
+ ALL_FILTER_VALUE,
+ buildProviderLabelMap,
+ collectErroredProviderLabels,
+ computeEmptyState,
+ getPromptPreview,
+ getSessionTitle,
+ PER_PROVIDER_LIMIT,
+ resolveProvidersToFetch,
+ sumFilteredAlreadyImportedCount,
+} from "@/components/import-session-sheet-view-model";
-const IMPORTABLE_PROVIDER_IDS: Set = new Set(IMPORTABLE_PROVIDERS);
-const PER_PROVIDER_LIMIT = 15;
const IMPORT_SHEET_SNAP_POINTS = ["70%", "92%"];
const DISABLED_ACCESSIBILITY_STATE = { disabled: true };
-const ALL_FILTER_VALUE = "__all__";
type RecentProviderSessionsClient = Pick<
DaemonClient,
@@ -49,41 +57,6 @@ interface SessionsQueryConfig {
queryFn: () => Promise;
}
-interface SessionsQueryResult {
- data: RecentSessionsResponse | undefined;
- isError: boolean;
- isLoading: boolean;
- isPending: boolean;
-}
-
-function resolveProvidersToFetch(
- supportsSnapshot: boolean,
- snapshotEntries: ReadonlyArray<{ provider: string; enabled?: boolean }> | undefined,
-): AgentProvider[] | null {
- // COMPAT(providersSnapshot): the import-recent-sessions feature ships alongside
- // providersSnapshot (v0.1.48, 2026-04-05). Daemons older than that lack both —
- // we render an "update host" empty state instead of degrading. Drop this gate
- // when the supported daemon floor is >= v0.1.48 (target: 2026-10-05).
- if (!supportsSnapshot) return null;
- if (!snapshotEntries) return null;
- return snapshotEntries
- .filter((entry) => IMPORTABLE_PROVIDER_IDS.has(entry.provider) && entry.enabled !== false)
- .map((entry) => entry.provider);
-}
-
-function buildProviderLabelMap(
- snapshotEntries: ReadonlyArray<{ provider: string; label?: string }> | undefined,
-): Map {
- const map = new Map();
- if (!snapshotEntries) return map;
- for (const entry of snapshotEntries) {
- if (entry.label) {
- map.set(entry.provider, entry.label);
- }
- }
- return map;
-}
-
function buildSessionsQueriesConfig(args: {
providersToFetch: AgentProvider[] | null;
sessionsQueryRoot: ReadonlyArray;
@@ -110,66 +83,6 @@ function buildSessionsQueriesConfig(args: {
}));
}
-function aggregateSessionEntries(
- queries: ReadonlyArray,
-): FetchRecentProviderSessionEntry[] {
- const seen = new Set();
- const collected: FetchRecentProviderSessionEntry[] = [];
- for (const query of queries) {
- if (!query.data) continue;
- for (const entry of query.data.entries) {
- const key = `${entry.providerId}:${entry.providerHandleId}`;
- if (seen.has(key)) continue;
- seen.add(key);
- collected.push(entry);
- }
- }
- collected.sort(
- (a, b) => new Date(b.lastActivityAt).getTime() - new Date(a.lastActivityAt).getTime(),
- );
- return collected;
-}
-
-function sumFilteredAlreadyImportedCount(queries: ReadonlyArray): number {
- let total = 0;
- for (const query of queries) {
- total += query.data?.filteredAlreadyImportedCount ?? 0;
- }
- return total;
-}
-
-function collectErroredProviderLabels(
- providersToFetch: AgentProvider[] | null,
- queries: ReadonlyArray,
- providerLabelById: ReadonlyMap,
-): string[] {
- if (providersToFetch === null) return [];
- const labels: string[] = [];
- for (let index = 0; index < queries.length; index++) {
- if (queries[index]?.isError) {
- const provider = providersToFetch[index];
- labels.push(providerLabelById.get(provider) ?? provider);
- }
- }
- return labels;
-}
-
-function getSessionTitle(entry: FetchRecentProviderSessionEntry): string {
- const title = entry.title?.trim();
- if (title) {
- return title;
- }
- const firstPromptPreview = entry.firstPromptPreview?.trim();
- if (firstPromptPreview) {
- return firstPromptPreview;
- }
- return "Untitled session";
-}
-
-function getPromptPreview(entry: FetchRecentProviderSessionEntry): string {
- return entry.lastPromptPreview?.trim() || entry.firstPromptPreview?.trim() || "No prompt preview";
-}
-
interface SheetStatusMessagesProps {
isClientReady: boolean;
isSnapshotUnsupported: boolean;
@@ -222,42 +135,6 @@ function SheetStatusMessages({
);
}
-interface EmptyStateInputs {
- isLoadingSessions: boolean;
- allQueriesErrored: boolean;
- isQueryingProviders: boolean;
- allQueriesSettled: boolean;
- selectedProvider: string;
- aggregatedCount: number;
- visibleCount: number;
- totalAlreadyImportedCount: number;
- providerLabelById: ReadonlyMap;
-}
-
-function computeEmptyState(input: EmptyStateInputs): {
- showEmptyState: boolean;
- emptyStateTitle: string;
-} {
- const showEmptyState =
- !input.isLoadingSessions &&
- !input.allQueriesErrored &&
- input.isQueryingProviders &&
- input.allQueriesSettled &&
- input.visibleCount === 0;
- if (!showEmptyState) {
- return { showEmptyState, emptyStateTitle: "" };
- }
- const isFilteredEmpty = input.selectedProvider !== ALL_FILTER_VALUE && input.aggregatedCount > 0;
- if (isFilteredEmpty) {
- const label = input.providerLabelById.get(input.selectedProvider) ?? input.selectedProvider;
- return { showEmptyState, emptyStateTitle: `No ${label} sessions found.` };
- }
- if (input.totalAlreadyImportedCount > 0) {
- return { showEmptyState, emptyStateTitle: "All recent sessions are already imported." };
- }
- return { showEmptyState, emptyStateTitle: "No recent sessions to import." };
-}
-
function RefreshAction({ isRefreshing, onPress }: { isRefreshing: boolean; onPress: () => void }) {
const { theme } = useUnistyles();
const pressableStyle = useCallback(
diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx
index 4a59a6f2f..aa8cc3ca3 100644
--- a/packages/app/src/components/message.tsx
+++ b/packages/app/src/components/message.tsx
@@ -74,6 +74,7 @@ import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
import { formatDuration, formatMessageTimestamp } from "@/utils/time";
import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard";
+import { getDefaultMarkdownClipboardEnvironment } from "@/utils/rich-clipboard-default-environment";
import {
getAssistantImageLoadStateFromMetadata,
getAssistantImageMetadata,
@@ -1149,7 +1150,7 @@ export const TurnCopyButton = memo(function TurnCopyButton({
return;
}
- await writeMarkdownToRichClipboard(content);
+ await writeMarkdownToRichClipboard(content, getDefaultMarkdownClipboardEnvironment());
setCopied(true);
if (copyTimeoutRef.current) {
diff --git a/packages/app/src/components/provider-icon-name.test.ts b/packages/app/src/components/provider-icon-name.test.ts
new file mode 100644
index 000000000..f2149d1a9
--- /dev/null
+++ b/packages/app/src/components/provider-icon-name.test.ts
@@ -0,0 +1,17 @@
+import { describe, expect, it } from "vitest";
+import { resolveProviderIconName } from "./provider-icon-name";
+
+describe("resolveProviderIconName", () => {
+ it("returns the built-in identifier for known provider ids", () => {
+ expect(resolveProviderIconName("kiro")).toEqual({ kind: "builtin", id: "kiro" });
+ expect(resolveProviderIconName("claude")).toEqual({ kind: "builtin", id: "claude" });
+ });
+
+ it("returns the catalog identifier for ACP catalog provider ids that ship an icon", () => {
+ expect(resolveProviderIconName("amp-acp")).toEqual({ kind: "catalog", id: "amp-acp" });
+ });
+
+ it("falls back to the bot icon for unknown custom providers", () => {
+ expect(resolveProviderIconName("custom-claude-profile")).toEqual({ kind: "bot" });
+ });
+});
diff --git a/packages/app/src/components/provider-icon-name.ts b/packages/app/src/components/provider-icon-name.ts
new file mode 100644
index 000000000..2b04168da
--- /dev/null
+++ b/packages/app/src/components/provider-icon-name.ts
@@ -0,0 +1,31 @@
+import { ACP_PROVIDER_CATALOG } from "@/data/acp-provider-catalog";
+
+export type BuiltinProviderIconName = "claude" | "codex" | "copilot" | "kiro" | "opencode" | "pi";
+
+export type ProviderIconName =
+ | { kind: "builtin"; id: BuiltinProviderIconName }
+ | { kind: "catalog"; id: string }
+ | { kind: "bot" };
+
+const BUILTIN_PROVIDER_IDS: ReadonlySet = new Set([
+ "claude",
+ "codex",
+ "copilot",
+ "kiro",
+ "opencode",
+ "pi",
+]);
+
+const CATALOG_ICON_PROVIDER_IDS: ReadonlySet = new Set(
+ ACP_PROVIDER_CATALOG.flatMap((entry) => (entry.iconSvg ? [entry.id] : [])),
+);
+
+export function resolveProviderIconName(provider: string): ProviderIconName {
+ if (BUILTIN_PROVIDER_IDS.has(provider as BuiltinProviderIconName)) {
+ return { kind: "builtin", id: provider as BuiltinProviderIconName };
+ }
+ if (CATALOG_ICON_PROVIDER_IDS.has(provider)) {
+ return { kind: "catalog", id: provider };
+ }
+ return { kind: "bot" };
+}
diff --git a/packages/app/src/components/provider-icons.test.ts b/packages/app/src/components/provider-icons.test.ts
deleted file mode 100644
index 681f44ecf..000000000
--- a/packages/app/src/components/provider-icons.test.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-
-vi.mock("lucide-react-native", () => ({
- Bot: function Bot() {
- return null;
- },
- PackagePlus: function PackagePlus() {
- return null;
- },
-}));
-
-import { Bot, PackagePlus } from "lucide-react-native";
-import { getProviderIcon } from "./provider-icons";
-
-describe("getProviderIcon", () => {
- it("keeps built-in provider icons", () => {
- expect(getProviderIcon("kiro")).toBe(PackagePlus);
- });
-
- it("uses vendored ACP catalog icons for catalog provider ids", () => {
- const icon = getProviderIcon("amp-acp");
-
- expect(icon).not.toBe(Bot);
- expect(getProviderIcon("amp-acp")).toBe(icon);
- });
-
- it("falls back to the robot icon for unknown custom providers", () => {
- expect(getProviderIcon("custom-claude-profile")).toBe(Bot);
- });
-});
diff --git a/packages/app/src/components/provider-icons.ts b/packages/app/src/components/provider-icons.ts
index 941c861ad..fea4e41a8 100644
--- a/packages/app/src/components/provider-icons.ts
+++ b/packages/app/src/components/provider-icons.ts
@@ -7,6 +7,10 @@ import { CopilotIcon } from "@/components/icons/copilot-icon";
import { OpenCodeIcon } from "@/components/icons/opencode-icon";
import { PiIcon } from "@/components/icons/pi-icon";
import { ACP_PROVIDER_CATALOG } from "@/data/acp-provider-catalog";
+import {
+ resolveProviderIconName,
+ type BuiltinProviderIconName,
+} from "@/components/provider-icon-name";
export interface ProviderIconProps {
size: number;
@@ -15,7 +19,7 @@ export interface ProviderIconProps {
export type ProviderIconComponent = ComponentType;
-const PROVIDER_ICONS: Record = {
+const BUILTIN_PROVIDER_ICONS: Record = {
claude: ClaudeIcon as unknown as ProviderIconComponent,
codex: CodexIcon as unknown as ProviderIconComponent,
copilot: CopilotIcon as unknown as ProviderIconComponent,
@@ -42,22 +46,24 @@ function createCatalogIcon(provider: string, iconSvg: string): ProviderIconCompo
return CatalogProviderIcon;
}
-function getCatalogProviderIcon(provider: string): ProviderIconComponent | undefined {
+function getCatalogProviderIcon(provider: string): ProviderIconComponent {
const cached = catalogIconComponents.get(provider);
if (cached) {
return cached;
}
-
- const iconSvg = CATALOG_ICON_SVGS.get(provider);
- if (!iconSvg) {
- return undefined;
- }
-
+ const iconSvg = CATALOG_ICON_SVGS.get(provider) ?? "";
const icon = createCatalogIcon(provider, iconSvg);
catalogIconComponents.set(provider, icon);
return icon;
}
export function getProviderIcon(provider: string): ProviderIconComponent {
- return PROVIDER_ICONS[provider] ?? getCatalogProviderIcon(provider) ?? Bot;
+ const name = resolveProviderIconName(provider);
+ if (name.kind === "builtin") {
+ return BUILTIN_PROVIDER_ICONS[name.id];
+ }
+ if (name.kind === "catalog") {
+ return getCatalogProviderIcon(name.id);
+ }
+ return Bot;
}
diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx
index 7f9d504d5..22fc777e4 100644
--- a/packages/app/src/components/sidebar-workspace-list.tsx
+++ b/packages/app/src/components/sidebar-workspace-list.tsx
@@ -27,7 +27,7 @@ import {
type Ref,
} from "react";
import { router, usePathname, type Href } from "expo-router";
-import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
+import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
import { type GestureType } from "react-native-gesture-handler";
diff --git a/packages/app/src/components/sortable-inline-list.web.test.tsx b/packages/app/src/components/sortable-inline-list.web.test.tsx
deleted file mode 100644
index 5196ea7ae..000000000
--- a/packages/app/src/components/sortable-inline-list.web.test.tsx
+++ /dev/null
@@ -1,123 +0,0 @@
-/**
- * @vitest-environment jsdom
- */
-import React from "react";
-import { act } from "react";
-import { createRoot, type Root } from "react-dom/client";
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { SortableInlineList } from "./sortable-inline-list.web";
-
-interface DndContextProps {
- onDragStart?: (event: { active: { id: string } }) => void;
- onDragCancel?: () => void;
-}
-
-let latestDndContextProps: DndContextProps | null = null;
-
-vi.mock("@dnd-kit/core", () => ({
- DndContext: ({ children, ...props }: React.PropsWithChildren) => {
- latestDndContextProps = props;
- return {children}
;
- },
- closestCenter: vi.fn(),
- KeyboardSensor: vi.fn(),
- PointerSensor: vi.fn(),
- useSensor: vi.fn(() => ({})),
- useSensors: vi.fn(() => []),
-}));
-
-vi.mock("@dnd-kit/sortable", () => ({
- SortableContext: ({ children }: React.PropsWithChildren) => children,
- arrayMove: (items: T[], from: number, to: number) => {
- const next = [...items];
- const [item] = next.splice(from, 1);
- if (item !== undefined) {
- next.splice(to, 0, item);
- }
- return next;
- },
- horizontalListSortingStrategy: {},
- sortableKeyboardCoordinates: vi.fn(),
- useSortable: () => ({
- attributes: {},
- listeners: {},
- setNodeRef: vi.fn(),
- setActivatorNodeRef: vi.fn(),
- transform: null,
- transition: undefined,
- isDragging: false,
- }),
-}));
-
-let root: Root | null = null;
-let container: HTMLElement | null = null;
-
-beforeEach(() => {
- vi.stubGlobal("React", React);
- vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
- latestDndContextProps = null;
- container = document.createElement("div");
- document.body.appendChild(container);
- root = createRoot(container);
-});
-
-afterEach(() => {
- if (root) {
- act(() => {
- root?.unmount();
- });
- }
- root = null;
- container?.remove();
- container = null;
- vi.unstubAllGlobals();
-});
-
-const DATA: string[] = ["alpha", "beta"];
-
-function keyExtractor(item: string): string {
- return item;
-}
-
-function renderItem({ item, isActive }: { item: string; isActive: boolean }) {
- return (
-
- {item}
-
- );
-}
-
-function renderList(): void {
- act(() => {
- root?.render(
- ,
- );
- });
-}
-
-function getItemActiveState(item: string): string | null {
- return (
- container?.querySelector(`[data-testid="item-${item}"]`)?.getAttribute("data-active") ?? null
- );
-}
-
-describe("SortableInlineList web", () => {
- it("clears active drag state when a drag is cancelled", () => {
- renderList();
-
- act(() => {
- latestDndContextProps?.onDragStart?.({ active: { id: "alpha" } });
- });
- expect(getItemActiveState("alpha")).toBe("true");
-
- act(() => {
- latestDndContextProps?.onDragCancel?.();
- });
- expect(getItemActiveState("alpha")).toBe("false");
- });
-});
diff --git a/packages/app/src/components/sortable-inline-list.web.tsx b/packages/app/src/components/sortable-inline-list.web.tsx
index 22c125cbc..133cd408d 100644
--- a/packages/app/src/components/sortable-inline-list.web.tsx
+++ b/packages/app/src/components/sortable-inline-list.web.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useMemo, useState, type ReactElement } from "react";
+import { useCallback, useMemo, type ReactElement } from "react";
import {
DndContext,
closestCenter,
@@ -7,18 +7,16 @@ import {
type Modifier,
useSensor,
useSensors,
- type DragEndEvent,
- type DragStartEvent,
} from "@dnd-kit/core";
import {
SortableContext,
sortableKeyboardCoordinates,
horizontalListSortingStrategy,
useSortable,
- arrayMove,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { DraggableRenderItemInfo } from "./draggable-list.types";
+import { useDragReorderState } from "./drag-reorder";
const restrictToHorizontalAxis: Modifier = ({ transform }) => ({
...transform,
@@ -138,9 +136,19 @@ export function SortableInlineList({
activeId?: string | null;
getItemData?: (item: T, index: number) => Record;
}): ReactElement {
- const [activeId, setActiveId] = useState(null);
- const [dragItems, setDragItems] = useState(null);
- const items = externalDndContext ? data : (dragItems ?? data);
+ const {
+ activeId: internalActiveId,
+ items: managedItems,
+ handlers,
+ } = useDragReorderState({
+ data,
+ keyExtractor,
+ onDragEnd,
+ onDragBegin,
+ disabled,
+ });
+ const items = externalDndContext ? data : managedItems;
+ const activeId = externalDndContext ? externalActiveId : internalActiveId;
const sensors = useSensors(
useSensor(PointerSensor, {
@@ -153,46 +161,6 @@ export function SortableInlineList({
}),
);
- const handleDragStart = useCallback(
- (event: DragStartEvent) => {
- if (disabled) {
- return;
- }
- setDragItems(data);
- setActiveId(String(event.active.id));
- onDragBegin?.();
- },
- [data, disabled, onDragBegin],
- );
-
- const clearDragState = useCallback(() => {
- setActiveId(null);
- setDragItems(null);
- }, []);
-
- const handleDragEnd = useCallback(
- (event: DragEndEvent) => {
- const { active, over } = event;
-
- clearDragState();
-
- if (disabled) {
- return;
- }
-
- if (over && active.id !== over.id) {
- const oldIndex = items.findIndex((item, i) => keyExtractor(item, i) === active.id);
- const newIndex = items.findIndex((item, i) => keyExtractor(item, i) === over.id);
-
- if (oldIndex >= 0 && newIndex >= 0 && oldIndex !== newIndex) {
- const newItems = arrayMove(items, oldIndex, newIndex);
- onDragEnd?.(newItems);
- }
- }
- },
- [clearDragState, disabled, items, keyExtractor, onDragEnd],
- );
-
const ids = useMemo(
() => items.map((item, index) => keyExtractor(item, index)),
[items, keyExtractor],
@@ -209,7 +177,7 @@ export function SortableInlineList({
item={item}
index={index}
renderItem={renderItem}
- activeId={externalDndContext ? externalActiveId : activeId}
+ activeId={activeId}
useDragHandle={useDragHandle}
disabled={disabled}
itemData={getItemData?.(item, index)}
@@ -229,9 +197,9 @@ export function SortableInlineList({
sensors={sensors}
collisionDetection={closestCenter}
modifiers={DND_MODIFIERS}
- onDragStart={handleDragStart}
- onDragCancel={clearDragState}
- onDragEnd={handleDragEnd}
+ onDragStart={handlers.onDragStart}
+ onDragCancel={handlers.onDragCancel}
+ onDragEnd={handlers.onDragEnd}
>
{renderedItems}
diff --git a/packages/app/src/components/terminal-emulator.tsx b/packages/app/src/components/terminal-emulator.tsx
index 22e9d33e2..952619945 100644
--- a/packages/app/src/components/terminal-emulator.tsx
+++ b/packages/app/src/components/terminal-emulator.tsx
@@ -41,6 +41,7 @@ import {
isTerminalFileDrag,
prepareDroppedPathsForTerminal,
} from "../terminal/drop/terminal-file-drop";
+import { getDesktopHost } from "@/desktop/host";
export interface TerminalEmulatorHandle {
writeOutput: (data: TerminalOutputData) => void;
@@ -833,13 +834,14 @@ export default function TerminalEmulator({
event.stopPropagation();
clearTerminalDropActive();
- const paths = extractTerminalDropPaths(event.dataTransfer);
+ const bridge = getDesktopHost();
+ const paths = extractTerminalDropPaths(event.dataTransfer, bridge);
if (paths.length === 0) {
return;
}
runtimeRef.current?.focus();
- mountCallbacksRef.current.onInput?.(prepareDroppedPathsForTerminal(paths));
+ mountCallbacksRef.current.onInput?.(prepareDroppedPathsForTerminal(paths, bridge));
};
root.addEventListener("dragenter", handleDragEnter, { capture: true });
diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal.test.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal.test.tsx
deleted file mode 100644
index 19a3bf034..000000000
--- a/packages/app/src/components/ui/isolated-bottom-sheet-modal.test.tsx
+++ /dev/null
@@ -1,189 +0,0 @@
-// @vitest-environment jsdom
-import { cleanup, render } from "@testing-library/react";
-import React from "react";
-import type { ReactNode } from "react";
-import { afterEach, describe, expect, it, vi } from "vitest";
-import {
- IsolatedBottomSheetModal,
- useIsolatedBottomSheetVisibility,
-} from "./isolated-bottom-sheet-modal";
-
-const SNAP_POINTS_50: (string | number)[] = ["50%"];
-const SNAP_POINTS_60: (string | number)[] = ["60%"];
-const SNAP_POINTS_90: (string | number)[] = ["90%"];
-
-function noop(): void {}
-
-interface CapturedModalProps {
- onChange?: (index: number) => void;
- onDismiss?: () => void;
-}
-
-const { modalMethods, modalProps, shouldExposeModalRef } = vi.hoisted(() => ({
- modalMethods: {
- present: vi.fn(),
- close: vi.fn(),
- snapToIndex: vi.fn(),
- dismiss: vi.fn(),
- },
- modalProps: vi.fn(),
- shouldExposeModalRef: { current: true },
-}));
-
-vi.mock("@gorhom/bottom-sheet", async () => {
- const React = await import("react");
- const MockBottomSheetModal = React.forwardRef(
- (props: Record, ref: React.ForwardedRef) => {
- modalProps(props);
- React.useImperativeHandle(ref, () => (shouldExposeModalRef.current ? modalMethods : null));
- return React.createElement(
- "div",
- { "data-testid": "bottom-sheet" },
- props.children as ReactNode,
- );
- },
- );
-
- return {
- BottomSheetModal: MockBottomSheetModal,
- BottomSheetModalProvider: ({ children }: { children: ReactNode }) =>
- React.createElement("div", { "data-testid": "bottom-sheet-provider" }, children),
- };
-});
-
-vi.mock("@gorhom/portal", async () => {
- const React = await import("react");
- return {
- Portal: ({ children, hostName }: { children: ReactNode; hostName?: string }) =>
- React.createElement("div", { "data-host": hostName, "data-testid": "app-portal" }, children),
- };
-});
-
-function Harness({ visible, onClose }: { visible: boolean; onClose: () => void }) {
- const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
- visible,
- onClose,
- });
-
- return (
-
- Sheet content
-
- );
-}
-
-function latestModalProps(): CapturedModalProps {
- const props = modalProps.mock.lastCall?.[0];
- expect(props).toBeDefined();
- return props as CapturedModalProps;
-}
-
-describe("IsolatedBottomSheetModal", () => {
- afterEach(() => {
- cleanup();
- vi.clearAllMocks();
- shouldExposeModalRef.current = true;
- });
-
- it("forces sheet isolation and uses the modal dismissal lifecycle", () => {
- const onClose = vi.fn();
- const { getByTestId, rerender } = render();
-
- expect(getByTestId("app-portal").getAttribute("data-host")).toBe("root");
- expect(modalProps).toHaveBeenLastCalledWith(
- expect.objectContaining({
- enableDismissOnClose: true,
- stackBehavior: "replace",
- }),
- );
- expect(modalMethods.present).not.toHaveBeenCalled();
-
- rerender();
- expect(modalMethods.present).toHaveBeenCalledTimes(1);
-
- rerender();
- expect(modalMethods.dismiss).toHaveBeenCalledTimes(1);
- expect(modalMethods.close).not.toHaveBeenCalled();
-
- rerender();
- expect(modalMethods.present).toHaveBeenCalledTimes(2);
- expect(modalMethods.snapToIndex).not.toHaveBeenCalled();
- });
-
- it("only reports a user close when the sheet was visible", () => {
- const onClose = vi.fn();
- const { rerender } = render();
-
- latestModalProps().onChange?.(-1);
- expect(onClose).toHaveBeenCalledTimes(1);
-
- rerender();
- latestModalProps().onChange?.(-1);
- expect(onClose).toHaveBeenCalledTimes(1);
- });
-
- it("reports a dismiss while visible as a close request", () => {
- const onClose = vi.fn();
- render();
-
- latestModalProps().onDismiss?.();
-
- expect(onClose).toHaveBeenCalledTimes(1);
- });
-
- it("deduplicates close notifications from change and dismiss callbacks", () => {
- const onClose = vi.fn();
- render();
-
- const props = latestModalProps();
- props.onChange?.(-1);
- props.onDismiss?.();
-
- expect(onClose).toHaveBeenCalledTimes(1);
- });
-
- it("presents when the sheet ref becomes available after opening", () => {
- shouldExposeModalRef.current = false;
- const onClose = vi.fn();
- const { rerender } = render();
-
- expect(modalMethods.present).not.toHaveBeenCalled();
- expect(modalMethods.snapToIndex).not.toHaveBeenCalled();
-
- shouldExposeModalRef.current = true;
- rerender();
-
- expect(modalMethods.present).toHaveBeenCalledTimes(1);
- expect(modalMethods.snapToIndex).not.toHaveBeenCalled();
- });
-
- it("allows nested sheets inside a parent sheet without creating a sibling provider", () => {
- const { getAllByTestId } = render(
-
-
- Nested model picker
-
- ,
- );
-
- expect(getAllByTestId("bottom-sheet-provider")).toHaveLength(1);
- expect(modalProps).toHaveBeenNthCalledWith(
- 1,
- expect.objectContaining({
- stackBehavior: "replace",
- }),
- );
- expect(modalProps).toHaveBeenNthCalledWith(
- 2,
- expect.objectContaining({
- stackBehavior: "push",
- }),
- );
- });
-});
diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx
similarity index 54%
rename from packages/app/src/components/ui/isolated-bottom-sheet-modal.tsx
rename to packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx
index 6236974a0..6b3f20c91 100644
--- a/packages/app/src/components/ui/isolated-bottom-sheet-modal.tsx
+++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx
@@ -5,8 +5,12 @@ import {
} from "@gorhom/bottom-sheet";
import { Portal } from "@gorhom/portal";
import React, { createContext, useContext } from "react";
-import { forwardRef, useCallback, useEffect, useRef } from "react";
+import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react";
import type { ElementRef } from "react";
+import {
+ type BottomSheetController,
+ createBottomSheetVisibilityTracker,
+} from "./visibility-tracker";
type GorhomBottomSheetModalMethods = ElementRef;
@@ -68,88 +72,31 @@ export function useIsolatedBottomSheetVisibility({
isEnabled?: boolean;
onClose: () => void;
}) {
- const sheetRef = useRef(null);
- const visibleRef = useRef(visible);
- const isEnabledRef = useRef(isEnabled);
- const isPresentedRef = useRef(false);
- const hasNotifiedCloseRef = useRef(false);
+ const onCloseRef = useRef(onClose);
+ onCloseRef.current = onClose;
- visibleRef.current = visible;
- isEnabledRef.current = isEnabled;
-
- const presentSheet = useCallback((sheet: IsolatedBottomSheetModalRef) => {
- if (isPresentedRef.current) {
- return;
- }
-
- isPresentedRef.current = true;
- hasNotifiedCloseRef.current = false;
- sheet.present();
- }, []);
-
- const dismissSheet = useCallback((sheet: IsolatedBottomSheetModalRef) => {
- if (!isPresentedRef.current) {
- return;
- }
-
- isPresentedRef.current = false;
- sheet.dismiss();
- }, []);
-
- const notifyClose = useCallback(() => {
- if (hasNotifiedCloseRef.current) {
- return;
- }
-
- hasNotifiedCloseRef.current = true;
- onClose();
- }, [onClose]);
-
- const handleSheetDismiss = useCallback(() => {
- isPresentedRef.current = false;
- if (visibleRef.current) {
- notifyClose();
- return;
- }
- hasNotifiedCloseRef.current = false;
- }, [notifyClose]);
-
- const handleSheetChange = useCallback(
- (index: number) => {
- if (index === -1 && visibleRef.current) {
- notifyClose();
- }
- },
- [notifyClose],
+ const tracker = useMemo(
+ () => createBottomSheetVisibilityTracker({ onClose: () => onCloseRef.current() }),
+ [],
);
const setSheetRef = useCallback(
(instance: IsolatedBottomSheetModalRef | null) => {
- sheetRef.current = instance;
- if (instance && visibleRef.current && isEnabledRef.current !== false) {
- presentSheet(instance);
- }
+ tracker.attachController(instance as BottomSheetController | null);
},
- [presentSheet],
+ [tracker],
);
+ const handleSheetChange = useCallback(
+ (index: number) => tracker.handleSheetIndexChange(index),
+ [tracker],
+ );
+
+ const handleSheetDismiss = useCallback(() => tracker.handleSheetDismiss(), [tracker]);
+
useEffect(() => {
- if (isEnabled === false) return;
-
- const sheet = sheetRef.current;
- if (visible) {
- if (!sheet) {
- return;
- }
-
- presentSheet(sheet);
- return;
- }
-
- if (sheet) {
- dismissSheet(sheet);
- }
- }, [dismissSheet, isEnabled, presentSheet, visible]);
+ tracker.syncDesired({ visible, isEnabled });
+ }, [isEnabled, tracker, visible]);
return {
sheetRef: setSheetRef,
diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.test.ts b/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.test.ts
new file mode 100644
index 000000000..762403a1d
--- /dev/null
+++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.test.ts
@@ -0,0 +1,121 @@
+import { describe, expect, it } from "vitest";
+import {
+ type BottomSheetController,
+ createBottomSheetVisibilityTracker,
+} from "./visibility-tracker";
+
+interface FakeBottomSheetEvent {
+ type: "present" | "dismiss";
+}
+
+class FakeBottomSheet implements BottomSheetController {
+ events: FakeBottomSheetEvent[] = [];
+
+ present(): void {
+ this.events.push({ type: "present" });
+ }
+
+ dismiss(): void {
+ this.events.push({ type: "dismiss" });
+ }
+}
+
+function setup() {
+ const sheet = new FakeBottomSheet();
+ let closeCount = 0;
+ const tracker = createBottomSheetVisibilityTracker({
+ onClose: () => {
+ closeCount += 1;
+ },
+ });
+ return {
+ sheet,
+ tracker,
+ closeCount: () => closeCount,
+ };
+}
+
+describe("bottom sheet visibility tracker", () => {
+ it("presents once when the sheet becomes visible and dismisses when it goes back to hidden", () => {
+ const { sheet, tracker } = setup();
+ tracker.attachController(sheet);
+
+ tracker.syncDesired({ visible: false });
+ expect(sheet.events).toEqual([]);
+
+ tracker.syncDesired({ visible: true });
+ expect(sheet.events).toEqual([{ type: "present" }]);
+
+ tracker.syncDesired({ visible: true });
+ expect(sheet.events).toEqual([{ type: "present" }]);
+
+ tracker.syncDesired({ visible: false });
+ expect(sheet.events).toEqual([{ type: "present" }, { type: "dismiss" }]);
+ });
+
+ it("waits to present until the sheet controller becomes available", () => {
+ const { sheet, tracker } = setup();
+ tracker.syncDesired({ visible: true });
+ expect(sheet.events).toEqual([]);
+
+ tracker.attachController(sheet);
+ expect(sheet.events).toEqual([{ type: "present" }]);
+ });
+
+ it("does not present while disabled", () => {
+ const { sheet, tracker } = setup();
+ tracker.attachController(sheet);
+
+ tracker.syncDesired({ visible: true, isEnabled: false });
+ expect(sheet.events).toEqual([]);
+ });
+
+ it("only reports a user close when the sheet was visible", () => {
+ const { sheet, tracker, closeCount } = setup();
+ tracker.attachController(sheet);
+ tracker.syncDesired({ visible: true });
+
+ tracker.handleSheetIndexChange(-1);
+ expect(closeCount()).toBe(1);
+
+ tracker.syncDesired({ visible: false });
+ tracker.handleSheetIndexChange(-1);
+ expect(closeCount()).toBe(1);
+ });
+
+ it("reports a dismiss while visible as a close request", () => {
+ const { sheet, tracker, closeCount } = setup();
+ tracker.attachController(sheet);
+ tracker.syncDesired({ visible: true });
+
+ tracker.handleSheetDismiss();
+
+ expect(closeCount()).toBe(1);
+ });
+
+ it("deduplicates close notifications from change and dismiss callbacks", () => {
+ const { sheet, tracker, closeCount } = setup();
+ tracker.attachController(sheet);
+ tracker.syncDesired({ visible: true });
+
+ tracker.handleSheetIndexChange(-1);
+ tracker.handleSheetDismiss();
+
+ expect(closeCount()).toBe(1);
+ });
+
+ it("allows a new close notification after re-presenting the sheet", () => {
+ const { sheet, tracker, closeCount } = setup();
+ tracker.attachController(sheet);
+ tracker.syncDesired({ visible: true });
+
+ tracker.handleSheetIndexChange(-1);
+ expect(closeCount()).toBe(1);
+
+ tracker.syncDesired({ visible: false });
+ tracker.syncDesired({ visible: true });
+
+ tracker.handleSheetIndexChange(-1);
+ expect(closeCount()).toBe(2);
+ });
+});
diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.ts b/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.ts
new file mode 100644
index 000000000..a78a34e4a
--- /dev/null
+++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.ts
@@ -0,0 +1,77 @@
+export interface BottomSheetController {
+ present(): void;
+ dismiss(): void;
+}
+
+export interface BottomSheetVisibilityInput {
+ visible: boolean;
+ isEnabled?: boolean;
+}
+
+export interface BottomSheetVisibilityTracker {
+ attachController(controller: BottomSheetController | null): void;
+ syncDesired(input: BottomSheetVisibilityInput): void;
+ handleSheetIndexChange(index: number): void;
+ handleSheetDismiss(): void;
+}
+
+export function createBottomSheetVisibilityTracker(opts: {
+ onClose: () => void;
+}): BottomSheetVisibilityTracker {
+ let controller: BottomSheetController | null = null;
+ let visible = false;
+ let isEnabled: boolean | undefined;
+ let isPresented = false;
+ let hasNotifiedClose = false;
+
+ function present(): void {
+ if (!controller || isPresented) return;
+ isPresented = true;
+ hasNotifiedClose = false;
+ controller.present();
+ }
+
+ function dismiss(): void {
+ if (!controller || !isPresented) return;
+ isPresented = false;
+ controller.dismiss();
+ }
+
+ function notifyClose(): void {
+ if (hasNotifiedClose) return;
+ hasNotifiedClose = true;
+ opts.onClose();
+ }
+
+ return {
+ attachController(next) {
+ controller = next;
+ if (next && visible && isEnabled !== false) {
+ present();
+ }
+ },
+ syncDesired(next) {
+ visible = next.visible;
+ isEnabled = next.isEnabled;
+ if (isEnabled === false) return;
+ if (visible) {
+ present();
+ return;
+ }
+ dismiss();
+ },
+ handleSheetIndexChange(index) {
+ if (index === -1 && visible) {
+ notifyClose();
+ }
+ },
+ handleSheetDismiss() {
+ isPresented = false;
+ if (visible) {
+ notifyClose();
+ return;
+ }
+ hasNotifiedClose = false;
+ },
+ };
+}
diff --git a/packages/app/src/desktop/attachments/desktop-attachment-bridge.ts b/packages/app/src/desktop/attachments/desktop-attachment-bridge.ts
new file mode 100644
index 000000000..63c869a59
--- /dev/null
+++ b/packages/app/src/desktop/attachments/desktop-attachment-bridge.ts
@@ -0,0 +1,59 @@
+import type { AttachmentMetadata } from "@/attachments/types";
+import {
+ copyDesktopAttachmentFile,
+ deleteDesktopAttachmentFile,
+ garbageCollectDesktopAttachmentFiles,
+ writeDesktopAttachmentBase64,
+ writeDesktopAttachmentBytes,
+} from "@/desktop/attachments/desktop-file-commands";
+import {
+ createBrowserObjectUrlMinter,
+ createDesktopFileReader,
+ createDesktopPreviewUrlResolver,
+ readDesktopFileBase64,
+} from "@/desktop/attachments/desktop-preview-url";
+
+export interface DesktopAttachmentFileResult {
+ path: string;
+ byteSize: number;
+}
+
+export interface DesktopAttachmentBridge {
+ copyFile(input: {
+ attachmentId: string;
+ sourcePath: string;
+ extension?: string | null;
+ }): Promise;
+ writeBase64(input: {
+ attachmentId: string;
+ base64: string;
+ extension?: string | null;
+ }): Promise;
+ writeBytes(input: {
+ attachmentId: string;
+ bytes: Uint8Array;
+ extension?: string | null;
+ }): Promise;
+ deleteFile(input: { path: string }): Promise;
+ garbageCollect(input: { referencedIds: readonly string[] }): Promise;
+ readFileBase64(path: string): Promise;
+ resolvePreviewUrl(attachment: AttachmentMetadata): Promise;
+ releasePreviewUrl(input: { url: string }): Promise;
+}
+
+export function createDesktopAttachmentBridge(): DesktopAttachmentBridge {
+ const previewUrls = createDesktopPreviewUrlResolver({
+ reader: createDesktopFileReader(),
+ objectUrls: createBrowserObjectUrlMinter(),
+ });
+ return {
+ copyFile: copyDesktopAttachmentFile,
+ writeBase64: writeDesktopAttachmentBase64,
+ writeBytes: writeDesktopAttachmentBytes,
+ deleteFile: deleteDesktopAttachmentFile,
+ garbageCollect: garbageCollectDesktopAttachmentFiles,
+ readFileBase64: readDesktopFileBase64,
+ resolvePreviewUrl: (attachment) => previewUrls.resolve(attachment),
+ releasePreviewUrl: (input) => previewUrls.release(input),
+ };
+}
diff --git a/packages/app/src/desktop/attachments/desktop-attachment-store.test.ts b/packages/app/src/desktop/attachments/desktop-attachment-store.test.ts
index 4903380e1..aef1099a6 100644
--- a/packages/app/src/desktop/attachments/desktop-attachment-store.test.ts
+++ b/packages/app/src/desktop/attachments/desktop-attachment-store.test.ts
@@ -1,63 +1,12 @@
-import { beforeEach, describe, expect, it, vi } from "vitest";
+import { describe, expect, it } from "vitest";
import { createDesktopAttachmentStore } from "./desktop-attachment-store";
-
-const {
- copyDesktopAttachmentFileMock,
- writeDesktopAttachmentBase64Mock,
- writeDesktopAttachmentBytesMock,
- deleteDesktopAttachmentFileMock,
- garbageCollectDesktopAttachmentFilesMock,
- readDesktopFileBase64Mock,
- resolveDesktopPreviewUrlMock,
- releaseDesktopPreviewUrlMock,
-} = vi.hoisted(() => ({
- copyDesktopAttachmentFileMock: vi.fn(async () => ({
- path: "/managed/att_1.png",
- byteSize: 4,
- })),
- writeDesktopAttachmentBase64Mock: vi.fn(async () => ({
- path: "/managed/att_2.png",
- byteSize: 4,
- })),
- writeDesktopAttachmentBytesMock: vi.fn(async () => ({
- path: "/managed/att_bytes.png",
- byteSize: 4,
- })),
- deleteDesktopAttachmentFileMock: vi.fn(async () => true),
- garbageCollectDesktopAttachmentFilesMock: vi.fn(async () => 0),
- readDesktopFileBase64Mock: vi.fn(async () => "AAECAw=="),
- resolveDesktopPreviewUrlMock: vi.fn(async () => "blob:test"),
- releaseDesktopPreviewUrlMock: vi.fn(async () => {}),
-}));
-
-vi.mock("./desktop-file-commands", () => ({
- copyDesktopAttachmentFile: copyDesktopAttachmentFileMock,
- writeDesktopAttachmentBase64: writeDesktopAttachmentBase64Mock,
- writeDesktopAttachmentBytes: writeDesktopAttachmentBytesMock,
- deleteDesktopAttachmentFile: deleteDesktopAttachmentFileMock,
- garbageCollectDesktopAttachmentFiles: garbageCollectDesktopAttachmentFilesMock,
-}));
-
-vi.mock("./desktop-preview-url", () => ({
- readDesktopFileBase64: readDesktopFileBase64Mock,
- resolveDesktopPreviewUrl: resolveDesktopPreviewUrlMock,
- releaseDesktopPreviewUrl: releaseDesktopPreviewUrlMock,
-}));
+import { createFakeDesktopAttachmentBridge } from "./test-utils/fake-desktop-attachment-bridge";
describe("desktop attachment store", () => {
- beforeEach(() => {
- copyDesktopAttachmentFileMock.mockClear();
- writeDesktopAttachmentBase64Mock.mockClear();
- writeDesktopAttachmentBytesMock.mockClear();
- deleteDesktopAttachmentFileMock.mockClear();
- garbageCollectDesktopAttachmentFilesMock.mockClear();
- readDesktopFileBase64Mock.mockClear();
- resolveDesktopPreviewUrlMock.mockClear();
- releaseDesktopPreviewUrlMock.mockClear();
- });
-
it("saves dropped file paths as desktop-file metadata", async () => {
- const store = createDesktopAttachmentStore();
+ const fake = createFakeDesktopAttachmentBridge();
+ const store = createDesktopAttachmentStore(fake.bridge);
+
const attachment = await store.save({
id: "att_1",
mimeType: "image/png",
@@ -67,17 +16,25 @@ describe("desktop attachment store", () => {
},
});
- expect(copyDesktopAttachmentFileMock).toHaveBeenCalledWith({
- attachmentId: "att_1",
- sourcePath: "/Users/test/Desktop/image.png",
- extension: ".png",
+ expect(fake.savedEntries).toEqual([
+ {
+ attachmentId: "att_1",
+ path: "/managed/att_1.png",
+ byteSize: 4,
+ extension: ".png",
+ source: { kind: "copy", sourcePath: "/Users/test/Desktop/image.png" },
+ },
+ ]);
+ expect(attachment).toMatchObject({
+ storageType: "desktop-file",
+ storageKey: "/managed/att_1.png",
});
- expect(attachment.storageType).toBe("desktop-file");
- expect(attachment.storageKey).toBe("/managed/att_1.png");
});
it("saves blob/data-url sources via desktop filesystem writes", async () => {
- const store = createDesktopAttachmentStore();
+ const fake = createFakeDesktopAttachmentBridge();
+ const store = createDesktopAttachmentStore(fake.bridge);
+
await store.save({
id: "att_2",
source: {
@@ -86,16 +43,22 @@ describe("desktop attachment store", () => {
},
});
- expect(writeDesktopAttachmentBase64Mock).toHaveBeenCalledWith({
- attachmentId: "att_2",
- base64: "AAECAw==",
- extension: ".png",
- });
+ expect(fake.savedEntries).toEqual([
+ {
+ attachmentId: "att_2",
+ path: "/managed/att_2.png",
+ byteSize: 4,
+ extension: ".png",
+ source: { kind: "base64", base64: "AAECAw==" },
+ },
+ ]);
});
it("saves raw byte sources via desktop filesystem writes", async () => {
- const store = createDesktopAttachmentStore();
+ const fake = createFakeDesktopAttachmentBridge();
+ const store = createDesktopAttachmentStore(fake.bridge);
const bytes = new Uint8Array([0, 1, 2, 3]);
+
const attachment = await store.save({
id: "att_bytes",
mimeType: "image/png",
@@ -106,12 +69,15 @@ describe("desktop attachment store", () => {
},
});
- expect(writeDesktopAttachmentBytesMock).toHaveBeenCalledWith({
- attachmentId: "att_bytes",
- bytes,
- extension: ".png",
- });
- expect(writeDesktopAttachmentBase64Mock).not.toHaveBeenCalled();
+ expect(fake.savedEntries).toEqual([
+ {
+ attachmentId: "att_bytes",
+ path: "/managed/att_bytes.png",
+ byteSize: 4,
+ extension: ".png",
+ source: { kind: "bytes", bytes },
+ },
+ ]);
expect(attachment).toMatchObject({
id: "att_bytes",
mimeType: "image/png",
@@ -123,7 +89,8 @@ describe("desktop attachment store", () => {
});
it("delegates encode/preview/delete/gc to desktop command path", async () => {
- const store = createDesktopAttachmentStore();
+ const fake = createFakeDesktopAttachmentBridge();
+ const store = createDesktopAttachmentStore(fake.bridge);
const attachment = {
id: "att_3",
mimeType: "image/jpeg",
@@ -138,14 +105,10 @@ describe("desktop attachment store", () => {
await store.delete({ attachment });
await store.garbageCollect({ referencedIds: new Set(["att_3"]) });
- expect(readDesktopFileBase64Mock).toHaveBeenCalledWith("/managed/att_3.jpg");
- expect(resolveDesktopPreviewUrlMock).toHaveBeenCalledWith(attachment);
- expect(releaseDesktopPreviewUrlMock).toHaveBeenCalledWith({ url: "blob:test" });
- expect(deleteDesktopAttachmentFileMock).toHaveBeenCalledWith({
- path: "/managed/att_3.jpg",
- });
- expect(garbageCollectDesktopAttachmentFilesMock).toHaveBeenCalledWith({
- referencedIds: ["att_3"],
- });
+ expect(fake.readBase64Calls).toEqual(["/managed/att_3.jpg"]);
+ expect(fake.resolvedPreviewUrls).toEqual([attachment]);
+ expect(fake.releasedPreviewUrls).toEqual(["blob:test"]);
+ expect(fake.deletedPaths).toEqual(["/managed/att_3.jpg"]);
+ expect(fake.garbageCollections).toEqual([{ referencedIds: ["att_3"] }]);
});
});
diff --git a/packages/app/src/desktop/attachments/desktop-attachment-store.ts b/packages/app/src/desktop/attachments/desktop-attachment-store.ts
index 579590e75..967f31469 100644
--- a/packages/app/src/desktop/attachments/desktop-attachment-store.ts
+++ b/packages/app/src/desktop/attachments/desktop-attachment-store.ts
@@ -7,18 +7,7 @@ import {
normalizeMimeType,
parseDataUrl,
} from "@/attachments/utils";
-import {
- copyDesktopAttachmentFile,
- deleteDesktopAttachmentFile,
- garbageCollectDesktopAttachmentFiles,
- writeDesktopAttachmentBase64,
- writeDesktopAttachmentBytes,
-} from "@/desktop/attachments/desktop-file-commands";
-import {
- readDesktopFileBase64,
- releaseDesktopPreviewUrl,
- resolveDesktopPreviewUrl,
-} from "@/desktop/attachments/desktop-preview-url";
+import type { DesktopAttachmentBridge } from "./desktop-attachment-bridge";
const IMAGE_EXTENSION_BY_MIME_TYPE: Record = {
"image/png": ".png",
@@ -77,12 +66,15 @@ function toDesktopMetadata(input: {
};
}
-async function saveDesktopAttachmentFromFileUri(input: {
- id: string;
- uri: string;
- mimeType?: string;
- fileName?: string | null;
-}): Promise {
+async function saveDesktopAttachmentFromFileUri(
+ bridge: DesktopAttachmentBridge,
+ input: {
+ id: string;
+ uri: string;
+ mimeType?: string;
+ fileName?: string | null;
+ },
+): Promise {
const sourcePath = fileUriToPath(input.uri);
const fileName = input.fileName ?? inferFileNameFromPath(sourcePath);
const mimeType = normalizeMimeType(input.mimeType);
@@ -92,7 +84,7 @@ async function saveDesktopAttachmentFromFileUri(input: {
mimeType,
});
- const result = await copyDesktopAttachmentFile({
+ const result = await bridge.copyFile({
attachmentId: input.id,
sourcePath,
extension,
@@ -107,18 +99,21 @@ async function saveDesktopAttachmentFromFileUri(input: {
});
}
-async function saveDesktopAttachmentFromBase64(input: {
- id: string;
- base64: string;
- mimeType: string;
- fileName?: string | null;
-}): Promise {
+async function saveDesktopAttachmentFromBase64(
+ bridge: DesktopAttachmentBridge,
+ input: {
+ id: string;
+ base64: string;
+ mimeType: string;
+ fileName?: string | null;
+ },
+): Promise {
const extension = extensionForAttachment({
fileName: input.fileName,
mimeType: input.mimeType,
});
- const result = await writeDesktopAttachmentBase64({
+ const result = await bridge.writeBase64({
attachmentId: input.id,
base64: input.base64,
extension,
@@ -133,18 +128,21 @@ async function saveDesktopAttachmentFromBase64(input: {
});
}
-async function saveDesktopAttachmentFromBytes(input: {
- id: string;
- bytes: Uint8Array;
- mimeType: string;
- fileName?: string | null;
-}): Promise {
+async function saveDesktopAttachmentFromBytes(
+ bridge: DesktopAttachmentBridge,
+ input: {
+ id: string;
+ bytes: Uint8Array;
+ mimeType: string;
+ fileName?: string | null;
+ },
+): Promise {
const extension = extensionForAttachment({
fileName: input.fileName,
mimeType: input.mimeType,
});
- const result = await writeDesktopAttachmentBytes({
+ const result = await bridge.writeBytes({
attachmentId: input.id,
bytes: input.bytes,
extension,
@@ -165,7 +163,7 @@ function assertDesktopAttachment(attachment: AttachmentMetadata): void {
}
}
-export function createDesktopAttachmentStore(): AttachmentStore {
+export function createDesktopAttachmentStore(bridge: DesktopAttachmentBridge): AttachmentStore {
return {
storageType: "desktop-file",
@@ -174,7 +172,7 @@ export function createDesktopAttachmentStore(): AttachmentStore {
const fileName = input.fileName ?? null;
if (input.source.kind === "file_uri") {
- return await saveDesktopAttachmentFromFileUri({
+ return await saveDesktopAttachmentFromFileUri(bridge, {
id,
uri: input.source.uri,
mimeType: input.mimeType,
@@ -185,7 +183,7 @@ export function createDesktopAttachmentStore(): AttachmentStore {
if (input.source.kind === "data_url") {
const parsed = parseDataUrl(input.source.dataUrl);
const mimeType = normalizeMimeType(input.mimeType ?? parsed.mimeType);
- return await saveDesktopAttachmentFromBase64({
+ return await saveDesktopAttachmentFromBase64(bridge, {
id,
base64: parsed.base64,
mimeType,
@@ -195,7 +193,7 @@ export function createDesktopAttachmentStore(): AttachmentStore {
if (input.source.kind === "bytes") {
const mimeType = normalizeMimeType(input.mimeType);
- return await saveDesktopAttachmentFromBytes({
+ return await saveDesktopAttachmentFromBytes(bridge, {
id,
bytes: input.source.bytes,
mimeType,
@@ -205,7 +203,7 @@ export function createDesktopAttachmentStore(): AttachmentStore {
const mimeType = normalizeMimeType(input.mimeType ?? input.source.blob.type);
const base64 = await blobToBase64(input.source.blob);
- return await saveDesktopAttachmentFromBase64({
+ return await saveDesktopAttachmentFromBase64(bridge, {
id,
base64,
mimeType,
@@ -215,26 +213,26 @@ export function createDesktopAttachmentStore(): AttachmentStore {
async encodeBase64({ attachment }): Promise {
assertDesktopAttachment(attachment);
- return await readDesktopFileBase64(attachment.storageKey);
+ return await bridge.readFileBase64(attachment.storageKey);
},
async resolvePreviewUrl({ attachment }): Promise {
assertDesktopAttachment(attachment);
- return await resolveDesktopPreviewUrl(attachment);
+ return await bridge.resolvePreviewUrl(attachment);
},
async releasePreviewUrl({ attachment, url }): Promise {
assertDesktopAttachment(attachment);
- await releaseDesktopPreviewUrl({ url });
+ await bridge.releasePreviewUrl({ url });
},
async delete({ attachment }): Promise {
assertDesktopAttachment(attachment);
- await deleteDesktopAttachmentFile({ path: attachment.storageKey });
+ await bridge.deleteFile({ path: attachment.storageKey });
},
async garbageCollect({ referencedIds }): Promise {
- await garbageCollectDesktopAttachmentFiles({
+ await bridge.garbageCollect({
referencedIds: Array.from(referencedIds),
});
},
diff --git a/packages/app/src/desktop/attachments/desktop-preview-url.test.ts b/packages/app/src/desktop/attachments/desktop-preview-url.test.ts
index a18cd4ca7..6c0c4352a 100644
--- a/packages/app/src/desktop/attachments/desktop-preview-url.test.ts
+++ b/packages/app/src/desktop/attachments/desktop-preview-url.test.ts
@@ -1,66 +1,119 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-
-const { invokeDesktopCommandMock } = vi.hoisted(() => ({
- invokeDesktopCommandMock: vi.fn(async () => "AAECAw=="),
-}));
-
-vi.mock("@/desktop/electron/invoke", () => ({
- invokeDesktopCommand: invokeDesktopCommandMock,
-}));
-
+import { describe, expect, it } from "vitest";
+import type { AttachmentMetadata } from "@/attachments/types";
import {
- __desktopPreviewUrlTestUtils,
- releaseDesktopPreviewUrl,
- resolveDesktopPreviewUrl,
+ createDesktopPreviewUrlResolver,
+ type DesktopFileReader,
+ type ObjectUrlMinter,
} from "./desktop-preview-url";
+class FakeDesktopReader implements DesktopFileReader {
+ readonly reads: string[] = [];
+ constructor(private readonly files: Record) {}
+
+ async readFileBase64(storageKey: string): Promise {
+ this.reads.push(storageKey);
+ const base64 = this.files[storageKey];
+ if (base64 === undefined) {
+ throw new Error(`FakeDesktopReader: no file registered for ${storageKey}`);
+ }
+ return base64;
+ }
+}
+
+interface FakeMintedUrl {
+ url: string;
+ mimeType: string;
+ base64: string;
+}
+
+class FakeObjectUrls implements ObjectUrlMinter {
+ readonly minted: FakeMintedUrl[] = [];
+ readonly revoked: string[] = [];
+ private nextId = 1;
+ private readonly supportsCreate: boolean;
+
+ constructor(options: { supportsCreate?: boolean } = {}) {
+ this.supportsCreate = options.supportsCreate ?? true;
+ }
+
+ tryCreate(input: { mimeType: string; base64: string }): string | null {
+ if (!this.supportsCreate) {
+ return null;
+ }
+ const url = `blob:fake-${this.nextId++}`;
+ this.minted.push({ url, ...input });
+ return url;
+ }
+
+ revoke(url: string): void {
+ this.revoked.push(url);
+ }
+}
+
+function attachment(overrides: Partial = {}): AttachmentMetadata {
+ return {
+ id: "att-1",
+ mimeType: "image/png",
+ storageType: "desktop-file",
+ storageKey: "/tmp/att-1.png",
+ fileName: null,
+ byteSize: null,
+ createdAt: 0,
+ ...overrides,
+ };
+}
+
describe("desktop preview URLs", () => {
- const createObjectURL = vi.fn(() => "blob:desktop-preview-1");
- const revokeObjectURL = vi.fn();
+ it("mints an object URL from the desktop file's base64 bytes", async () => {
+ const reader = new FakeDesktopReader({ "/tmp/att-1.png": "AAECAw==" });
+ const objectUrls = new FakeObjectUrls();
+ const resolver = createDesktopPreviewUrlResolver({ reader, objectUrls });
- beforeEach(() => {
- vi.stubGlobal("URL", {
- createObjectURL,
- revokeObjectURL,
- });
+ const url = await resolver.resolve(attachment());
+
+ expect(url).toBe("blob:fake-1");
+ expect(reader.reads).toEqual(["/tmp/att-1.png"]);
+ expect(objectUrls.minted).toEqual([
+ { url: "blob:fake-1", mimeType: "image/png", base64: "AAECAw==" },
+ ]);
});
- afterEach(() => {
- createObjectURL.mockClear();
- revokeObjectURL.mockClear();
- invokeDesktopCommandMock.mockClear();
- __desktopPreviewUrlTestUtils.clearActiveObjectUrls();
- vi.unstubAllGlobals();
+ it("falls back to a data URL when the host cannot mint object URLs", async () => {
+ const reader = new FakeDesktopReader({ "/tmp/att-2.jpg": "AAECAw==" });
+ const objectUrls = new FakeObjectUrls({ supportsCreate: false });
+ const resolver = createDesktopPreviewUrlResolver({ reader, objectUrls });
+
+ const url = await resolver.resolve(
+ attachment({ id: "att-2", mimeType: "image/jpeg", storageKey: "/tmp/att-2.jpg" }),
+ );
+
+ expect(url).toBe("data:image/jpeg;base64,AAECAw==");
+ expect(objectUrls.revoked).toEqual([]);
});
- it("resolves renderer-safe blob URLs for desktop attachments", async () => {
- const url = await resolveDesktopPreviewUrl({
- id: "att-1",
- mimeType: "image/png",
- storageType: "desktop-file",
- storageKey: "/tmp/att-1.png",
- createdAt: Date.now(),
- });
+ it("revokes only object URLs it minted", async () => {
+ const reader = new FakeDesktopReader({ "/tmp/att-3.jpg": "AAECAw==" });
+ const objectUrls = new FakeObjectUrls();
+ const resolver = createDesktopPreviewUrlResolver({ reader, objectUrls });
- expect(invokeDesktopCommandMock).toHaveBeenCalledWith("read_file_base64", {
- path: "/tmp/att-1.png",
- });
- expect(url).toBe("blob:desktop-preview-1");
- expect(url.startsWith("asset://")).toBe(false);
- expect(createObjectURL).toHaveBeenCalledTimes(1);
+ const url = await resolver.resolve(
+ attachment({ id: "att-3", mimeType: "image/jpeg", storageKey: "/tmp/att-3.jpg" }),
+ );
+ await resolver.release({ url });
+ await resolver.release({ url: "blob:never-minted" });
+
+ expect(objectUrls.revoked).toEqual([url]);
});
- it("releases blob URLs via URL.revokeObjectURL", async () => {
- const url = await resolveDesktopPreviewUrl({
- id: "att-2",
- mimeType: "image/jpeg",
- storageType: "desktop-file",
- storageKey: "/tmp/att-2.jpg",
- createdAt: Date.now(),
- });
+ it("only revokes a minted URL once across repeated release calls", async () => {
+ const reader = new FakeDesktopReader({ "/tmp/att-4.png": "AAECAw==" });
+ const objectUrls = new FakeObjectUrls();
+ const resolver = createDesktopPreviewUrlResolver({ reader, objectUrls });
- await releaseDesktopPreviewUrl({ url });
+ const url = await resolver.resolve(attachment({ id: "att-4", storageKey: "/tmp/att-4.png" }));
+ await resolver.release({ url });
+ await resolver.release({ url });
- expect(revokeObjectURL).toHaveBeenCalledWith("blob:desktop-preview-1");
+ expect(objectUrls.revoked).toEqual([url]);
});
});
diff --git a/packages/app/src/desktop/attachments/desktop-preview-url.ts b/packages/app/src/desktop/attachments/desktop-preview-url.ts
index d3efb3122..942b8033b 100644
--- a/packages/app/src/desktop/attachments/desktop-preview-url.ts
+++ b/packages/app/src/desktop/attachments/desktop-preview-url.ts
@@ -3,11 +3,19 @@ import { fileUriToPath } from "@/attachments/utils";
import { invokeDesktopCommand } from "@/desktop/electron/invoke";
import { Buffer } from "buffer";
-function base64ToUint8Array(base64: string): Uint8Array {
- return new Uint8Array(Buffer.from(base64, "base64"));
+export interface DesktopFileReader {
+ readFileBase64(storageKey: string): Promise;
}
-const activeObjectUrls = new Set();
+export interface ObjectUrlMinter {
+ tryCreate(input: { mimeType: string; base64: string }): string | null;
+ revoke(url: string): void;
+}
+
+export interface DesktopPreviewUrlResolver {
+ resolve(attachment: AttachmentMetadata): Promise;
+ release(input: { url: string }): Promise;
+}
export async function readDesktopFileBase64(pathOrUri: string): Promise {
return await invokeDesktopCommand("read_file_base64", {
@@ -15,36 +23,55 @@ export async function readDesktopFileBase64(pathOrUri: string): Promise
});
}
-export async function resolveDesktopPreviewUrl(attachment: AttachmentMetadata): Promise {
- const base64 = await readDesktopFileBase64(attachment.storageKey);
-
- if (typeof URL !== "undefined" && typeof URL.createObjectURL === "function") {
- const bytes = base64ToUint8Array(base64);
- const buffer = new ArrayBuffer(bytes.byteLength);
- new Uint8Array(buffer).set(bytes);
- const blob = new Blob([buffer], { type: attachment.mimeType });
- const objectUrl = URL.createObjectURL(blob);
- activeObjectUrls.add(objectUrl);
- return objectUrl;
- }
-
- return `data:${attachment.mimeType};base64,${base64}`;
+export function createDesktopFileReader(): DesktopFileReader {
+ return { readFileBase64: readDesktopFileBase64 };
}
-export async function releaseDesktopPreviewUrl(input: { url: string }): Promise {
- if (!activeObjectUrls.has(input.url)) {
- return;
- }
- activeObjectUrls.delete(input.url);
-
- if (typeof URL !== "undefined" && typeof URL.revokeObjectURL === "function") {
- URL.revokeObjectURL(input.url);
- }
+export function createBrowserObjectUrlMinter(): ObjectUrlMinter {
+ return {
+ tryCreate({ mimeType, base64 }) {
+ if (typeof URL === "undefined" || typeof URL.createObjectURL !== "function") {
+ return null;
+ }
+ const bytes = base64ToUint8Array(base64);
+ const buffer = new ArrayBuffer(bytes.byteLength);
+ new Uint8Array(buffer).set(bytes);
+ const blob = new Blob([buffer], { type: mimeType });
+ return URL.createObjectURL(blob);
+ },
+ revoke(url) {
+ if (typeof URL !== "undefined" && typeof URL.revokeObjectURL === "function") {
+ URL.revokeObjectURL(url);
+ }
+ },
+ };
}
-export const __desktopPreviewUrlTestUtils = {
- base64ToUint8Array,
- clearActiveObjectUrls: () => {
- activeObjectUrls.clear();
- },
-};
+export function createDesktopPreviewUrlResolver(deps: {
+ reader: DesktopFileReader;
+ objectUrls: ObjectUrlMinter;
+}): DesktopPreviewUrlResolver {
+ const tracked = new Set();
+ return {
+ async resolve(attachment) {
+ const base64 = await deps.reader.readFileBase64(attachment.storageKey);
+ const url = deps.objectUrls.tryCreate({ mimeType: attachment.mimeType, base64 });
+ if (url === null) {
+ return `data:${attachment.mimeType};base64,${base64}`;
+ }
+ tracked.add(url);
+ return url;
+ },
+ async release({ url }) {
+ if (!tracked.has(url)) {
+ return;
+ }
+ tracked.delete(url);
+ deps.objectUrls.revoke(url);
+ },
+ };
+}
+
+function base64ToUint8Array(base64: string): Uint8Array {
+ return new Uint8Array(Buffer.from(base64, "base64"));
+}
diff --git a/packages/app/src/desktop/attachments/test-utils/fake-desktop-attachment-bridge.ts b/packages/app/src/desktop/attachments/test-utils/fake-desktop-attachment-bridge.ts
new file mode 100644
index 000000000..447424008
--- /dev/null
+++ b/packages/app/src/desktop/attachments/test-utils/fake-desktop-attachment-bridge.ts
@@ -0,0 +1,103 @@
+import type { AttachmentMetadata } from "@/attachments/types";
+import type {
+ DesktopAttachmentBridge,
+ DesktopAttachmentFileResult,
+} from "@/desktop/attachments/desktop-attachment-bridge";
+
+export interface FakeDesktopAttachmentEntry {
+ attachmentId: string;
+ path: string;
+ byteSize: number;
+ extension: string | null;
+ source:
+ | { kind: "copy"; sourcePath: string }
+ | { kind: "base64"; base64: string }
+ | { kind: "bytes"; bytes: Uint8Array };
+}
+
+export interface FakeDesktopAttachmentBridge {
+ bridge: DesktopAttachmentBridge;
+ savedEntries: FakeDesktopAttachmentEntry[];
+ deletedPaths: string[];
+ garbageCollections: Array<{ referencedIds: readonly string[] }>;
+ readBase64Calls: string[];
+ resolvedPreviewUrls: AttachmentMetadata[];
+ releasedPreviewUrls: string[];
+}
+
+export function createFakeDesktopAttachmentBridge(): FakeDesktopAttachmentBridge {
+ const savedEntries: FakeDesktopAttachmentEntry[] = [];
+ const deletedPaths: string[] = [];
+ const garbageCollections: Array<{ referencedIds: readonly string[] }> = [];
+ const readBase64Calls: string[] = [];
+ const resolvedPreviewUrls: AttachmentMetadata[] = [];
+ const releasedPreviewUrls: string[] = [];
+
+ function buildPath(attachmentId: string, extension: string | null | undefined): string {
+ return `/managed/${attachmentId}${extension ?? ""}`;
+ }
+
+ function record(entry: FakeDesktopAttachmentEntry): DesktopAttachmentFileResult {
+ savedEntries.push(entry);
+ return { path: entry.path, byteSize: entry.byteSize };
+ }
+
+ const bridge: DesktopAttachmentBridge = {
+ async copyFile({ attachmentId, sourcePath, extension }) {
+ return record({
+ attachmentId,
+ path: buildPath(attachmentId, extension),
+ byteSize: 4,
+ extension: extension ?? null,
+ source: { kind: "copy", sourcePath },
+ });
+ },
+ async writeBase64({ attachmentId, base64, extension }) {
+ return record({
+ attachmentId,
+ path: buildPath(attachmentId, extension),
+ byteSize: 4,
+ extension: extension ?? null,
+ source: { kind: "base64", base64 },
+ });
+ },
+ async writeBytes({ attachmentId, bytes, extension }) {
+ return record({
+ attachmentId,
+ path: buildPath(attachmentId, extension),
+ byteSize: bytes.byteLength,
+ extension: extension ?? null,
+ source: { kind: "bytes", bytes },
+ });
+ },
+ async deleteFile({ path }) {
+ deletedPaths.push(path);
+ return true;
+ },
+ async garbageCollect({ referencedIds }) {
+ garbageCollections.push({ referencedIds });
+ return 0;
+ },
+ async readFileBase64(path) {
+ readBase64Calls.push(path);
+ return "AAECAw==";
+ },
+ async resolvePreviewUrl(attachment) {
+ resolvedPreviewUrls.push(attachment);
+ return "blob:test";
+ },
+ async releasePreviewUrl({ url }) {
+ releasedPreviewUrls.push(url);
+ },
+ };
+
+ return {
+ bridge,
+ savedEntries,
+ deletedPaths,
+ garbageCollections,
+ readBase64Calls,
+ resolvedPreviewUrls,
+ releasedPreviewUrls,
+ };
+}
diff --git a/packages/app/src/desktop/daemon/desktop-daemon-transport.test.ts b/packages/app/src/desktop/daemon/desktop-daemon-transport.test.ts
index f999a1a13..a9fe2497c 100644
--- a/packages/app/src/desktop/daemon/desktop-daemon-transport.test.ts
+++ b/packages/app/src/desktop/daemon/desktop-daemon-transport.test.ts
@@ -1,135 +1,46 @@
-import { describe, expect, it, vi, beforeEach } from "vitest";
+import { describe, expect, it, vi } from "vitest";
+import { createDesktopLocalDaemonTransportFactory } from "./desktop-daemon-transport";
+import { createFakeLocalDaemonTransportRpc } from "./test-local-daemon-transport-rpc";
-const desktopDaemonMock = vi.hoisted(() => {
- let eventHandler:
- | ((payload: {
- sessionId: string;
- kind: "open" | "message" | "close" | "error";
- text?: string | null;
- binaryBase64?: string | null;
- code?: number | null;
- reason?: string | null;
- error?: string | null;
- }) => void)
- | null = null;
-
- const openLocalTransportSession = vi.fn<(...args: unknown[]) => Promise>();
- const listenToLocalTransportEvents = vi.fn(
- async (
- handler: typeof eventHandler extends ((...args: infer A) => unknown) | null
- ? (...args: A) => void
- : never,
- ) => {
- eventHandler = handler;
- return () => {
- eventHandler = null;
- };
- },
- );
- const sendLocalTransportMessage = vi.fn(async () => undefined);
- const closeLocalTransportSession = vi.fn(async () => undefined);
-
- return {
- openLocalTransportSession,
- listenToLocalTransportEvents,
- sendLocalTransportMessage,
- closeLocalTransportSession,
- emitEvent(payload: {
- sessionId: string;
- kind: "open" | "message" | "close" | "error";
- text?: string | null;
- binaryBase64?: string | null;
- code?: number | null;
- reason?: string | null;
- error?: string | null;
- }) {
- eventHandler?.(payload);
- },
- };
-});
-
-vi.mock("./desktop-daemon", () => ({
- openLocalTransportSession: desktopDaemonMock.openLocalTransportSession,
- listenToLocalTransportEvents: desktopDaemonMock.listenToLocalTransportEvents,
- sendLocalTransportMessage: desktopDaemonMock.sendLocalTransportMessage,
- closeLocalTransportSession: desktopDaemonMock.closeLocalTransportSession,
-}));
+const LOCAL_URL = "paseo+local://socket?path=%2Ftmp%2Fpaseo.sock";
describe("desktop-daemon-transport", () => {
- beforeEach(() => {
- desktopDaemonMock.openLocalTransportSession.mockReset();
- desktopDaemonMock.listenToLocalTransportEvents.mockClear();
- desktopDaemonMock.sendLocalTransportMessage.mockClear();
- desktopDaemonMock.closeLocalTransportSession.mockClear();
- });
-
it("emits open after the session resolves even if the rust open event raced earlier", async () => {
- let resolveSession!: (sessionId: string) => void;
- desktopDaemonMock.openLocalTransportSession.mockImplementation(
- () =>
- new Promise((resolve) => {
- resolveSession = resolve;
- }),
- );
-
- const mod = await import("./desktop-daemon-transport");
- const transportFactory = mod.createDesktopLocalDaemonTransportFactory();
+ const rpc = createFakeLocalDaemonTransportRpc();
+ const transportFactory = createDesktopLocalDaemonTransportFactory(rpc);
expect(transportFactory).not.toBeNull();
- const transport = transportFactory!({
- url: "paseo+local://socket?path=%2Ftmp%2Fpaseo.sock",
- });
+ const transport = transportFactory!({ url: LOCAL_URL });
const onOpen = vi.fn();
transport.onOpen(onOpen);
- desktopDaemonMock.emitEvent({
- sessionId: "local-session-1",
- kind: "open",
- });
-
+ rpc.emitEvent({ sessionId: "local-session-1", kind: "open" });
expect(onOpen).not.toHaveBeenCalled();
- resolveSession("local-session-1");
+ rpc.resolveOpen("local-session-1");
await Promise.resolve();
expect(onOpen).toHaveBeenCalledTimes(1);
});
it("cleans up late async setup after the transport is closed", async () => {
- let resolveSession!: (sessionId: string) => void;
- let resolveListen!: (cleanup: () => void) => void;
+ const rpc = createFakeLocalDaemonTransportRpc();
const cleanup = vi.fn();
- desktopDaemonMock.openLocalTransportSession.mockImplementation(
- () =>
- new Promise((resolve) => {
- resolveSession = resolve;
- }),
- );
- desktopDaemonMock.listenToLocalTransportEvents.mockImplementation(
- () =>
- new Promise<() => void>((resolve) => {
- resolveListen = resolve;
- }),
- );
-
- const mod = await import("./desktop-daemon-transport");
- const transportFactory = mod.createDesktopLocalDaemonTransportFactory();
+ const transportFactory = createDesktopLocalDaemonTransportFactory(rpc);
expect(transportFactory).not.toBeNull();
- const transport = transportFactory!({
- url: "paseo+local://socket?path=%2Ftmp%2Fpaseo.sock",
- });
+ const transport = transportFactory!({ url: LOCAL_URL });
transport.close();
- resolveSession("local-session-2");
- resolveListen(cleanup);
+ rpc.resolveOpen("local-session-2");
+ rpc.resolveListen(cleanup);
await Promise.resolve();
await Promise.resolve();
- expect(desktopDaemonMock.closeLocalTransportSession).toHaveBeenCalledWith("local-session-2");
+ expect(rpc.closedSessions).toEqual(["local-session-2"]);
expect(cleanup).toHaveBeenCalledTimes(1);
});
});
diff --git a/packages/app/src/desktop/daemon/desktop-daemon-transport.ts b/packages/app/src/desktop/daemon/desktop-daemon-transport.ts
index b17ee6cc2..1d7894821 100644
--- a/packages/app/src/desktop/daemon/desktop-daemon-transport.ts
+++ b/packages/app/src/desktop/daemon/desktop-daemon-transport.ts
@@ -2,13 +2,11 @@ import type {
DaemonTransport,
DaemonTransportFactory,
} from "@getpaseo/client/internal/daemon-client";
+import type { LocalTransportTarget } from "./desktop-daemon";
import {
- closeLocalTransportSession,
- listenToLocalTransportEvents,
- openLocalTransportSession,
- sendLocalTransportMessage,
- type LocalTransportTarget,
-} from "./desktop-daemon";
+ defaultLocalDaemonTransportRpc,
+ type LocalDaemonTransportRpc,
+} from "./local-daemon-transport-rpc";
const LOCAL_TRANSPORT_SCHEME = "paseo+local:";
@@ -52,7 +50,9 @@ function parseLocalDaemonTransportUrl(url: string): LocalTransportTarget {
};
}
-export function createDesktopLocalDaemonTransportFactory(): DaemonTransportFactory | null {
+export function createDesktopLocalDaemonTransportFactory(
+ rpc: LocalDaemonTransportRpc = defaultLocalDaemonTransportRpc,
+): DaemonTransportFactory | null {
return ({ url }) => {
const target = parseLocalDaemonTransportUrl(url);
let sessionId: string | null = null;
@@ -90,30 +90,31 @@ export function createDesktopLocalDaemonTransportFactory(): DaemonTransportFacto
}
};
- void listenToLocalTransportEvents((payload) => {
- if (disposed || !sessionId || payload.sessionId !== sessionId) {
- return;
- }
- if (payload.kind === "open") {
- emitOpen();
- return;
- }
- if (payload.kind === "message") {
- if (payload.text) {
- emitMessage({ data: payload.text });
+ void rpc
+ .listenToEvents((payload) => {
+ if (disposed || !sessionId || payload.sessionId !== sessionId) {
return;
}
- if (payload.binaryBase64) {
- emitMessage({ data: decodeBase64ToBytes(payload.binaryBase64) });
+ if (payload.kind === "open") {
+ emitOpen();
+ return;
}
- return;
- }
- if (payload.kind === "close") {
- emitClose(payload);
- return;
- }
- emitError(payload.error ?? "Local daemon transport error");
- })
+ if (payload.kind === "message") {
+ if (payload.text) {
+ emitMessage({ data: payload.text });
+ return;
+ }
+ if (payload.binaryBase64) {
+ emitMessage({ data: decodeBase64ToBytes(payload.binaryBase64) });
+ }
+ return;
+ }
+ if (payload.kind === "close") {
+ emitClose(payload);
+ return;
+ }
+ emitError(payload.error ?? "Local daemon transport error");
+ })
.then((cleanup) => {
if (disposed) {
cleanup();
@@ -126,10 +127,11 @@ export function createDesktopLocalDaemonTransportFactory(): DaemonTransportFacto
emitError(error);
});
- void openLocalTransportSession(target)
+ void rpc
+ .openSession(target)
.then((id) => {
if (disposed) {
- void closeLocalTransportSession(id).catch((error) => emitError(error));
+ void rpc.closeSession(id).catch((error) => emitError(error));
return;
}
sessionId = id;
@@ -146,24 +148,20 @@ export function createDesktopLocalDaemonTransportFactory(): DaemonTransportFacto
return;
}
if (typeof data === "string") {
- void sendLocalTransportMessage({ sessionId, text: data }).catch((error) =>
- emitError(error),
- );
+ void rpc.sendMessage({ sessionId, text: data }).catch((error) => emitError(error));
return;
}
const binaryBase64 = encodeBinaryToBase64(
data instanceof ArrayBuffer ? data : new Uint8Array(data),
);
- void sendLocalTransportMessage({ sessionId, binaryBase64 }).catch((error) =>
- emitError(error),
- );
+ void rpc.sendMessage({ sessionId, binaryBase64 }).catch((error) => emitError(error));
},
close: () => {
disposed = true;
const currentSessionId = sessionId;
sessionId = null;
if (currentSessionId) {
- void closeLocalTransportSession(currentSessionId).catch((error) => emitError(error));
+ void rpc.closeSession(currentSessionId).catch((error) => emitError(error));
}
unlisten?.();
unlisten = null;
diff --git a/packages/app/src/desktop/daemon/local-daemon-transport-rpc.ts b/packages/app/src/desktop/daemon/local-daemon-transport-rpc.ts
new file mode 100644
index 000000000..54961107e
--- /dev/null
+++ b/packages/app/src/desktop/daemon/local-daemon-transport-rpc.ts
@@ -0,0 +1,31 @@
+import {
+ closeLocalTransportSession,
+ listenToLocalTransportEvents,
+ openLocalTransportSession,
+ sendLocalTransportMessage,
+ type LocalTransportTarget,
+} from "./desktop-daemon";
+
+export interface LocalDaemonTransportEvent {
+ sessionId: string;
+ kind: "open" | "message" | "close" | "error";
+ text?: string | null;
+ binaryBase64?: string | null;
+ code?: number | null;
+ reason?: string | null;
+ error?: string | null;
+}
+
+export interface LocalDaemonTransportRpc {
+ openSession(target: LocalTransportTarget): Promise;
+ listenToEvents(handler: (event: LocalDaemonTransportEvent) => void): Promise<() => void>;
+ sendMessage(input: { sessionId: string; text?: string; binaryBase64?: string }): Promise;
+ closeSession(sessionId: string): Promise;
+}
+
+export const defaultLocalDaemonTransportRpc: LocalDaemonTransportRpc = {
+ openSession: openLocalTransportSession,
+ listenToEvents: listenToLocalTransportEvents,
+ sendMessage: sendLocalTransportMessage,
+ closeSession: closeLocalTransportSession,
+};
diff --git a/packages/app/src/desktop/daemon/test-local-daemon-transport-rpc.ts b/packages/app/src/desktop/daemon/test-local-daemon-transport-rpc.ts
new file mode 100644
index 000000000..8f2214b03
--- /dev/null
+++ b/packages/app/src/desktop/daemon/test-local-daemon-transport-rpc.ts
@@ -0,0 +1,86 @@
+import type { LocalTransportTarget } from "./desktop-daemon";
+import type {
+ LocalDaemonTransportEvent,
+ LocalDaemonTransportRpc,
+} from "./local-daemon-transport-rpc";
+
+export interface RecordedSend {
+ sessionId: string;
+ text?: string;
+ binaryBase64?: string;
+}
+
+export interface FakeLocalDaemonTransportRpc extends LocalDaemonTransportRpc {
+ readonly openCalls: LocalTransportTarget[];
+ readonly recordedSends: RecordedSend[];
+ readonly closedSessions: string[];
+ resolveOpen(sessionId: string): void;
+ rejectOpen(error: Error): void;
+ resolveListen(cleanup: () => void): void;
+ rejectListen(error: Error): void;
+ emitEvent(event: LocalDaemonTransportEvent): void;
+}
+
+export function createFakeLocalDaemonTransportRpc(): FakeLocalDaemonTransportRpc {
+ const openCalls: LocalTransportTarget[] = [];
+ const recordedSends: RecordedSend[] = [];
+ const closedSessions: string[] = [];
+ let eventHandler: ((event: LocalDaemonTransportEvent) => void) | null = null;
+ let resolveOpenSession: ((sessionId: string) => void) | null = null;
+ let rejectOpenSession: ((error: Error) => void) | null = null;
+ let resolveListenPromise: ((cleanup: () => void) => void) | null = null;
+ let rejectListenPromise: ((error: Error) => void) | null = null;
+
+ return {
+ openCalls,
+ recordedSends,
+ closedSessions,
+ openSession(target) {
+ openCalls.push(target);
+ return new Promise((resolve, reject) => {
+ resolveOpenSession = resolve;
+ rejectOpenSession = reject;
+ });
+ },
+ listenToEvents(handler) {
+ eventHandler = handler;
+ return new Promise<() => void>((resolve, reject) => {
+ resolveListenPromise = resolve;
+ rejectListenPromise = reject;
+ });
+ },
+ async sendMessage(input) {
+ recordedSends.push(input);
+ },
+ async closeSession(sessionId) {
+ closedSessions.push(sessionId);
+ },
+ resolveOpen(sessionId) {
+ if (!resolveOpenSession) {
+ throw new Error("openSession was not called");
+ }
+ resolveOpenSession(sessionId);
+ },
+ rejectOpen(error) {
+ if (!rejectOpenSession) {
+ throw new Error("openSession was not called");
+ }
+ rejectOpenSession(error);
+ },
+ resolveListen(cleanup) {
+ if (!resolveListenPromise) {
+ throw new Error("listenToEvents was not called");
+ }
+ resolveListenPromise(cleanup);
+ },
+ rejectListen(error) {
+ if (!rejectListenPromise) {
+ throw new Error("listenToEvents was not called");
+ }
+ rejectListenPromise(error);
+ },
+ emitEvent(event) {
+ eventHandler?.(event);
+ },
+ };
+}
diff --git a/packages/app/src/desktop/electron/idle.test.ts b/packages/app/src/desktop/electron/idle.test.ts
index b3721d758..9308fca69 100644
--- a/packages/app/src/desktop/electron/idle.test.ts
+++ b/packages/app/src/desktop/electron/idle.test.ts
@@ -1,79 +1,54 @@
-import { afterEach, describe, expect, it, vi } from "vitest";
+import { describe, expect, it } from "vitest";
+import { readDesktopSystemIdleTimeMs, type DesktopIpcInvoker } from "./idle";
-const { invokeDesktopCommandMock } = vi.hoisted(() => ({
- invokeDesktopCommandMock: vi.fn<() => Promise>(async () => 12_000),
-}));
+function fakeInvoker(result: () => Promise): DesktopIpcInvoker {
+ return () => result() as Promise;
+}
-vi.mock("@/desktop/electron/invoke", () => ({
- invokeDesktopCommand: invokeDesktopCommandMock,
-}));
+describe("readDesktopSystemIdleTimeMs", () => {
+ it("returns the millisecond value reported by the desktop idle command", async () => {
+ const invokedCommands: string[] = [];
+ const invoke: DesktopIpcInvoker = async (command: string) => {
+ invokedCommands.push(command);
+ return 4_200 as T;
+ };
-import { getDesktopSystemIdleTimeMs } from "./idle";
+ const idleTimeMs = await readDesktopSystemIdleTimeMs(invoke);
-describe("getDesktopSystemIdleTimeMs", () => {
- afterEach(() => {
- invokeDesktopCommandMock.mockReset();
- vi.restoreAllMocks();
- });
-
- it("invokes the desktop idle command and returns the millisecond value", async () => {
- invokeDesktopCommandMock.mockResolvedValueOnce(4_200);
-
- const idleTimeMs = await getDesktopSystemIdleTimeMs();
-
- expect(invokeDesktopCommandMock).toHaveBeenCalledWith("desktop_get_system_idle_time");
expect(idleTimeMs).toBe(4_200);
+ expect(invokedCommands).toEqual(["desktop_get_system_idle_time"]);
});
- it("returns null and logs once when IPC rejects", async () => {
- const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
- const error = new Error("ipc failed");
- invokeDesktopCommandMock.mockRejectedValueOnce(error);
-
- const idleTimeMs = await getDesktopSystemIdleTimeMs();
+ it("returns null when the desktop IPC rejects", async () => {
+ const idleTimeMs = await readDesktopSystemIdleTimeMs(
+ fakeInvoker(async () => {
+ throw new Error("ipc failed");
+ }),
+ );
expect(idleTimeMs).toBeNull();
- expect(warn).toHaveBeenCalledTimes(1);
- expect(warn).toHaveBeenCalledWith("[DesktopIdle] Failed to read system idle time", error);
});
- it("returns null and logs once when IPC returns null", async () => {
- const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
- invokeDesktopCommandMock.mockResolvedValueOnce(null);
-
- const idleTimeMs = await getDesktopSystemIdleTimeMs();
+ it("returns null when the desktop IPC returns null", async () => {
+ const idleTimeMs = await readDesktopSystemIdleTimeMs(fakeInvoker(async () => null));
expect(idleTimeMs).toBeNull();
- expect(warn).toHaveBeenCalledTimes(1);
- expect(warn).toHaveBeenCalledWith("[DesktopIdle] Invalid system idle time", null);
});
- it("returns null and logs once when IPC returns NaN", async () => {
- const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
- invokeDesktopCommandMock.mockResolvedValueOnce(Number.NaN);
-
- const idleTimeMs = await getDesktopSystemIdleTimeMs();
+ it("returns null when the desktop IPC returns NaN", async () => {
+ const idleTimeMs = await readDesktopSystemIdleTimeMs(fakeInvoker(async () => Number.NaN));
expect(idleTimeMs).toBeNull();
- expect(warn).toHaveBeenCalledTimes(1);
- expect(warn).toHaveBeenCalledWith("[DesktopIdle] Invalid system idle time", Number.NaN);
});
- it("returns null and logs once when IPC returns a negative value", async () => {
- const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
- invokeDesktopCommandMock.mockResolvedValueOnce(-1);
-
- const idleTimeMs = await getDesktopSystemIdleTimeMs();
+ it("returns null when the desktop IPC returns a negative value", async () => {
+ const idleTimeMs = await readDesktopSystemIdleTimeMs(fakeInvoker(async () => -1));
expect(idleTimeMs).toBeNull();
- expect(warn).toHaveBeenCalledTimes(1);
- expect(warn).toHaveBeenCalledWith("[DesktopIdle] Invalid system idle time", -1);
});
- it("returns 0 when IPC returns zero", async () => {
- invokeDesktopCommandMock.mockResolvedValueOnce(0);
-
- const idleTimeMs = await getDesktopSystemIdleTimeMs();
+ it("returns zero when the desktop IPC returns zero", async () => {
+ const idleTimeMs = await readDesktopSystemIdleTimeMs(fakeInvoker(async () => 0));
expect(idleTimeMs).toBe(0);
});
diff --git a/packages/app/src/desktop/electron/idle.ts b/packages/app/src/desktop/electron/idle.ts
index a3386f268..3582b033a 100644
--- a/packages/app/src/desktop/electron/idle.ts
+++ b/packages/app/src/desktop/electron/idle.ts
@@ -1,14 +1,16 @@
-import { invokeDesktopCommand } from "@/desktop/electron/invoke";
-
const DESKTOP_SYSTEM_IDLE_COMMAND = "desktop_get_system_idle_time";
+export type DesktopIpcInvoker = (command: string) => Promise;
+
function isValidIdleTimeMs(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value) && value >= 0;
}
-export async function getDesktopSystemIdleTimeMs(): Promise {
+export async function readDesktopSystemIdleTimeMs(
+ invoke: DesktopIpcInvoker,
+): Promise {
try {
- const idleTimeMs = await invokeDesktopCommand(DESKTOP_SYSTEM_IDLE_COMMAND);
+ const idleTimeMs = await invoke(DESKTOP_SYSTEM_IDLE_COMMAND);
if (!isValidIdleTimeMs(idleTimeMs)) {
console.warn("[DesktopIdle] Invalid system idle time", idleTimeMs);
return null;
diff --git a/packages/app/src/desktop/permissions/desktop-permissions.test.ts b/packages/app/src/desktop/permissions/desktop-permissions.test.ts
index 08fd2de29..ebdc2985b 100644
--- a/packages/app/src/desktop/permissions/desktop-permissions.test.ts
+++ b/packages/app/src/desktop/permissions/desktop-permissions.test.ts
@@ -1,104 +1,65 @@
-import { afterEach, describe, expect, it, vi } from "vitest";
+import { describe, expect, it, vi } from "vitest";
+import type { DesktopHostBridge } from "@/desktop/host";
+import {
+ createDesktopPermissions,
+ type DesktopPermissionEnvironment,
+ type NavigatorLike,
+ type NotificationConstructorLike,
+} from "./desktop-permissions";
-type MockPlatform = "web" | "ios" | "android";
-
-interface GlobalSnapshot {
- Notification: unknown;
- navigatorDescriptor?: PropertyDescriptor;
- windowDescriptor?: PropertyDescriptor;
- paseoDesktop: unknown;
+interface FakeEnvironmentInput {
+ isWeb?: boolean;
+ desktopHost?: DesktopHostBridge | null;
+ notification?: NotificationConstructorLike | null;
+ navigator?: NavigatorLike | null;
}
-const originalGlobals: GlobalSnapshot = {
- Notification: (globalThis as { Notification?: unknown }).Notification,
- navigatorDescriptor: Object.getOwnPropertyDescriptor(globalThis, "navigator"),
- windowDescriptor: Object.getOwnPropertyDescriptor(globalThis, "window"),
- paseoDesktop:
- typeof globalThis.window === "undefined"
- ? undefined
- : (globalThis.window as { paseoDesktop?: unknown }).paseoDesktop,
-};
-
-function ensureWindow(): { paseoDesktop?: unknown } {
- const existingWindow = (globalThis as { window?: { paseoDesktop?: unknown } }).window;
- if (existingWindow) {
- return existingWindow;
- }
-
- const nextWindow: { paseoDesktop?: unknown } = {};
- Object.defineProperty(globalThis, "window", {
- configurable: true,
- writable: true,
- value: nextWindow,
- });
- return nextWindow;
-}
-
-function setNavigator(value: unknown): void {
- Object.defineProperty(globalThis, "navigator", {
- configurable: true,
- writable: true,
- value,
- });
-}
-
-function restoreGlobals(): void {
- (globalThis as { Notification?: unknown }).Notification = originalGlobals.Notification;
-
- if (originalGlobals.navigatorDescriptor) {
- Object.defineProperty(globalThis, "navigator", originalGlobals.navigatorDescriptor);
- } else {
- delete (globalThis as { navigator?: unknown }).navigator;
- }
-
- if (originalGlobals.windowDescriptor) {
- Object.defineProperty(globalThis, "window", originalGlobals.windowDescriptor);
- } else {
- delete (globalThis as { window?: unknown }).window;
- }
-
- if (typeof globalThis.window !== "undefined") {
- (globalThis.window as { paseoDesktop?: unknown }).paseoDesktop = originalGlobals.paseoDesktop;
- }
-}
-
-async function loadModuleForPlatform(platform: MockPlatform) {
- vi.resetModules();
- vi.doMock("react-native", () => ({ Platform: { OS: platform } }));
- return import("./desktop-permissions");
+function fakeEnvironment(input: FakeEnvironmentInput = {}): DesktopPermissionEnvironment {
+ return {
+ isWeb: input.isWeb ?? true,
+ getDesktopHost: () => input.desktopHost ?? null,
+ getNotification: () => input.notification ?? null,
+ getNavigator: () => input.navigator ?? null,
+ };
}
describe("desktop-permissions", () => {
- afterEach(() => {
- vi.doUnmock("react-native");
- vi.restoreAllMocks();
- vi.resetModules();
- restoreGlobals();
- });
+ it("shows section only in desktop web runtime", () => {
+ expect(
+ createDesktopPermissions(
+ fakeEnvironment({ isWeb: false }),
+ ).shouldShowDesktopPermissionSection(),
+ ).toBe(false);
- it("shows section only in desktop web runtime", async () => {
- const { shouldShowDesktopPermissionSection } = await loadModuleForPlatform("web");
+ expect(
+ createDesktopPermissions(
+ fakeEnvironment({ isWeb: true, desktopHost: null }),
+ ).shouldShowDesktopPermissionSection(),
+ ).toBe(false);
- expect(shouldShowDesktopPermissionSection()).toBe(false);
-
- ensureWindow().paseoDesktop = {};
- expect(shouldShowDesktopPermissionSection()).toBe(true);
+ expect(
+ createDesktopPermissions(
+ fakeEnvironment({ isWeb: true, desktopHost: {} as DesktopHostBridge }),
+ ).shouldShowDesktopPermissionSection(),
+ ).toBe(true);
});
it("reads notification and microphone status", async () => {
- const MockNotification = { permission: "default" };
- (globalThis as { Notification?: unknown }).Notification = MockNotification;
- setNavigator({
- permissions: {
- query: vi.fn(async () => ({ state: "granted" })),
- },
- mediaDevices: {
- getUserMedia: vi.fn(),
- },
- });
+ const permissions = createDesktopPermissions(
+ fakeEnvironment({
+ notification: { permission: "default" },
+ navigator: {
+ permissions: {
+ query: vi.fn(async () => ({ state: "granted" })),
+ },
+ mediaDevices: {
+ getUserMedia: vi.fn(),
+ },
+ },
+ }),
+ );
- const { getDesktopPermissionSnapshot } = await loadModuleForPlatform("web");
- const snapshot = await getDesktopPermissionSnapshot();
+ const snapshot = await permissions.getDesktopPermissionSnapshot();
expect(snapshot.notifications.state).toBe("prompt");
expect(snapshot.microphone.state).toBe("granted");
@@ -106,42 +67,44 @@ describe("desktop-permissions", () => {
});
it("queries microphone permission with correct Permissions instance binding", async () => {
- const permissions = {
+ const permissionsApi = {
query(this: unknown, _descriptor: { name: string }) {
- if (this !== permissions) {
+ if (this !== permissionsApi) {
throw new TypeError("Can only call Permissions.query on instances of Permissions");
}
return Promise.resolve({ state: "granted" as const });
},
};
- setNavigator({
- permissions,
- mediaDevices: {
- getUserMedia: vi.fn(),
- },
- });
+ const permissions = createDesktopPermissions(
+ fakeEnvironment({
+ navigator: {
+ permissions: permissionsApi,
+ mediaDevices: { getUserMedia: vi.fn() },
+ },
+ }),
+ );
- const { getDesktopPermissionSnapshot } = await loadModuleForPlatform("web");
- const snapshot = await getDesktopPermissionSnapshot();
+ const snapshot = await permissions.getDesktopPermissionSnapshot();
expect(snapshot.microphone.state).toBe("granted");
});
it("returns a fallback message when runtime blocks Permissions.query", async () => {
- setNavigator({
- permissions: {
- query: vi.fn(async () => {
- throw new TypeError("Can only call Permissions.query on instances of Permissions");
- }),
- },
- mediaDevices: {
- getUserMedia: vi.fn(),
- },
- });
+ const permissions = createDesktopPermissions(
+ fakeEnvironment({
+ navigator: {
+ permissions: {
+ query: vi.fn(async () => {
+ throw new TypeError("Can only call Permissions.query on instances of Permissions");
+ }),
+ },
+ mediaDevices: { getUserMedia: vi.fn() },
+ },
+ }),
+ );
- const { getDesktopPermissionSnapshot } = await loadModuleForPlatform("web");
- const snapshot = await getDesktopPermissionSnapshot();
+ const snapshot = await permissions.getDesktopPermissionSnapshot();
expect(snapshot.microphone.state).toBe("unknown");
expect(snapshot.microphone.detail).toContain(
@@ -150,26 +113,29 @@ describe("desktop-permissions", () => {
});
it("requests notification permission via the browser Notification API", async () => {
- const MockNotification = {
+ const fakeNotification: NotificationConstructorLike = {
permission: "default",
requestPermission: vi.fn(async () => "granted"),
};
- (globalThis as { Notification?: unknown }).Notification = MockNotification;
- const { requestDesktopPermission } = await loadModuleForPlatform("web");
- const result = await requestDesktopPermission({ kind: "notifications" });
+ const permissions = createDesktopPermissions(
+ fakeEnvironment({ notification: fakeNotification }),
+ );
+ const result = await permissions.requestDesktopPermission({ kind: "notifications" });
expect(result.state).toBe("granted");
- expect(MockNotification.requestPermission).toHaveBeenCalledTimes(1);
+ expect(fakeNotification.requestPermission).toHaveBeenCalledTimes(1);
});
it("reads browser Notification permission when available", async () => {
- const MockNotification = { permission: "denied" };
- (globalThis as { Notification?: unknown }).Notification = MockNotification;
- setNavigator({});
+ const permissions = createDesktopPermissions(
+ fakeEnvironment({
+ notification: { permission: "denied" },
+ navigator: {},
+ }),
+ );
- const { getDesktopPermissionSnapshot } = await loadModuleForPlatform("web");
- const snapshot = await getDesktopPermissionSnapshot();
+ const snapshot = await permissions.getDesktopPermissionSnapshot();
expect(snapshot.notifications.state).toBe("denied");
});
@@ -179,17 +145,18 @@ describe("desktop-permissions", () => {
const getUserMedia = vi.fn(async () => ({
getTracks: () => [{ stop }],
}));
- setNavigator({
- permissions: {
- query: vi.fn(async () => ({ state: "granted" })),
- },
- mediaDevices: {
- getUserMedia,
- },
- });
+ const permissions = createDesktopPermissions(
+ fakeEnvironment({
+ navigator: {
+ permissions: {
+ query: vi.fn(async () => ({ state: "granted" })),
+ },
+ mediaDevices: { getUserMedia },
+ },
+ }),
+ );
- const { requestDesktopPermission } = await loadModuleForPlatform("web");
- const result = await requestDesktopPermission({ kind: "microphone" });
+ const result = await permissions.requestDesktopPermission({ kind: "microphone" });
expect(result.state).toBe("granted");
expect(getUserMedia).toHaveBeenCalledWith({ audio: true });
@@ -197,16 +164,19 @@ describe("desktop-permissions", () => {
});
it("maps microphone request denial to denied status", async () => {
- setNavigator({
- mediaDevices: {
- getUserMedia: vi.fn(async () => {
- throw { name: "NotAllowedError", message: "denied" };
- }),
- },
- });
+ const permissions = createDesktopPermissions(
+ fakeEnvironment({
+ navigator: {
+ mediaDevices: {
+ getUserMedia: vi.fn(async () => {
+ throw { name: "NotAllowedError", message: "denied" };
+ }),
+ },
+ },
+ }),
+ );
- const { requestDesktopPermission } = await loadModuleForPlatform("web");
- const result = await requestDesktopPermission({ kind: "microphone" });
+ const result = await permissions.requestDesktopPermission({ kind: "microphone" });
expect(result.state).toBe("denied");
});
diff --git a/packages/app/src/desktop/permissions/desktop-permissions.ts b/packages/app/src/desktop/permissions/desktop-permissions.ts
index 12964e1ea..6aab5494e 100644
--- a/packages/app/src/desktop/permissions/desktop-permissions.ts
+++ b/packages/app/src/desktop/permissions/desktop-permissions.ts
@@ -1,5 +1,5 @@
-import { getDesktopHost } from "@/desktop/host";
-import { isWeb, isNative } from "@/constants/platform";
+import { type DesktopHostBridge, getDesktopHost } from "@/desktop/host";
+import { isNative, isWeb } from "@/constants/platform";
export type DesktopPermissionKind = "notifications" | "microphone";
@@ -22,7 +22,7 @@ export interface DesktopPermissionSnapshot {
microphone: DesktopPermissionStatus;
}
-interface NotificationConstructorLike {
+export interface NotificationConstructorLike {
permission?: string;
requestPermission?: () => Promise;
}
@@ -35,7 +35,7 @@ interface MediaStreamLike {
getTracks?: () => MediaStreamTrackLike[];
}
-interface NavigatorLike {
+export interface NavigatorLike {
mediaDevices?: {
getUserMedia?: (constraints: { audio: boolean }) => Promise;
};
@@ -44,8 +44,19 @@ interface NavigatorLike {
};
}
-export function shouldShowDesktopPermissionSection(): boolean {
- return isWeb && getDesktopHost() !== null;
+export interface DesktopPermissionEnvironment {
+ isWeb: boolean;
+ getDesktopHost: () => DesktopHostBridge | null;
+ getNotification: () => NotificationConstructorLike | null;
+ getNavigator: () => NavigatorLike | null;
+}
+
+export interface DesktopPermissions {
+ shouldShowDesktopPermissionSection: () => boolean;
+ getDesktopPermissionSnapshot: () => Promise;
+ requestDesktopPermission: (input: {
+ kind: DesktopPermissionKind;
+ }) => Promise;
}
function status(input: DesktopPermissionStatus): DesktopPermissionStatus {
@@ -82,31 +93,6 @@ function isPermissionsQueryRuntimeUnsupported(error: unknown): boolean {
return false;
}
-function getWebNotificationConstructor(): NotificationConstructorLike | null {
- if (isNative) {
- return null;
- }
- const NotificationConstructor = (globalThis as { Notification?: unknown }).Notification;
- if (
- NotificationConstructor == null ||
- (typeof NotificationConstructor !== "function" && typeof NotificationConstructor !== "object")
- ) {
- return null;
- }
- return NotificationConstructor as NotificationConstructorLike;
-}
-
-function getNavigatorLike(): NavigatorLike | null {
- if (isNative) {
- return null;
- }
- const webNavigator = (globalThis as { navigator?: unknown }).navigator;
- if (!isObject(webNavigator)) {
- return null;
- }
- return webNavigator as NavigatorLike;
-}
-
function mapNotificationPermissionString(permission: string): DesktopPermissionStatus {
if (permission === "granted") {
return status({
@@ -132,201 +118,253 @@ function mapNotificationPermissionString(permission: string): DesktopPermissionS
});
}
-async function getNotificationPermissionStatus(): Promise {
- if (isNative) {
- return status({
- state: "unavailable",
- detail: "Desktop notification status is only available on web runtime.",
- });
+export function createDesktopPermissions(env: DesktopPermissionEnvironment): DesktopPermissions {
+ function shouldShowDesktopPermissionSection(): boolean {
+ return env.isWeb && env.getDesktopHost() !== null;
}
- const desktopHost = getDesktopHost();
- if (desktopHost && typeof desktopHost.notification?.isSupported === "function") {
- try {
- const supported = await desktopHost.notification.isSupported();
- return status({
- state: supported ? "granted" : "unavailable",
- detail: supported
- ? "Desktop notifications are supported."
- : "Desktop notifications are not supported on this platform.",
- });
- } catch {
- // Fall through to web API check
- }
- }
-
- const NotificationConstructor = getWebNotificationConstructor();
- if (NotificationConstructor && typeof NotificationConstructor.permission === "string") {
- return mapNotificationPermissionString(NotificationConstructor.permission);
- }
-
- return status({
- state: "unavailable",
- detail: "Web Notification API is unavailable in this environment.",
- });
-}
-
-async function getMicrophonePermissionStatus(): Promise {
- if (isNative) {
- return status({
- state: "unavailable",
- detail: "Desktop microphone status is only available on web runtime.",
- });
- }
-
- const webNavigator = getNavigatorLike();
- if (!webNavigator) {
- return status({
- state: "unavailable",
- detail: "Navigator is unavailable in this environment.",
- });
- }
-
- const permissionsApi = webNavigator.permissions;
- if (permissionsApi && typeof permissionsApi.query === "function") {
- try {
- const result = await permissionsApi.query({ name: "microphone" });
- if (result?.state === "granted") {
- return status({
- state: "granted",
- detail: "Microphone access is granted.",
- });
- }
- if (result?.state === "denied") {
- return status({
- state: "denied",
- detail: "Microphone access is denied in system settings.",
- });
- }
- if (result?.state === "prompt") {
- return status({
- state: "prompt",
- detail: "Microphone permission has not been granted yet.",
- });
- }
- return status({
- state: "unknown",
- detail: `Unexpected microphone permission state: ${result?.state ?? "unknown"}`,
- });
- } catch (error) {
- if (isPermissionsQueryRuntimeUnsupported(error)) {
- return status({
- state: "unknown",
- detail:
- "Microphone status API is unavailable in this runtime. Use Request to check access.",
- });
- }
- return status({
- state: "unknown",
- detail: `Failed to query microphone status: ${getErrorMessage(error)}`,
- });
- }
- }
-
- if (typeof webNavigator.mediaDevices?.getUserMedia !== "function") {
- return status({
- state: "unavailable",
- detail: "Microphone capture is unavailable in this environment.",
- });
- }
-
- return status({
- state: "unknown",
- detail: "Permission status API is unavailable. Use Request to check access.",
- });
-}
-
-async function requestNotificationPermissionStatus(): Promise {
- if (isNative) {
- return status({
- state: "unavailable",
- detail: "Desktop notification requests are only available on web runtime.",
- });
- }
-
- const NotificationConstructor = getWebNotificationConstructor();
- if (NotificationConstructor && typeof NotificationConstructor.requestPermission === "function") {
- try {
- const permission = await NotificationConstructor.requestPermission();
- return mapNotificationPermissionString(permission);
- } catch (error) {
- return status({
- state: "unknown",
- detail: `Failed to request notification permission: ${getErrorMessage(error)}`,
- });
- }
- }
-
- return status({
- state: "unavailable",
- detail: "Web Notification API requestPermission() is unavailable.",
- });
-}
-
-async function requestMicrophonePermissionStatus(): Promise {
- if (isNative) {
- return status({
- state: "unavailable",
- detail: "Desktop microphone requests are only available on web runtime.",
- });
- }
-
- const webNavigator = getNavigatorLike();
- if (!webNavigator || typeof webNavigator.mediaDevices?.getUserMedia !== "function") {
- return status({
- state: "unavailable",
- detail: "Microphone capture API is unavailable in this environment.",
- });
- }
-
- try {
- const stream = await webNavigator.mediaDevices.getUserMedia({ audio: true });
- const tracks = stream && typeof stream.getTracks === "function" ? stream.getTracks() : [];
- tracks.forEach((track) => {
- if (typeof track.stop === "function") {
- track.stop();
- }
- });
- return await getMicrophonePermissionStatus();
- } catch (error) {
- const errorName = getErrorName(error);
- if (errorName === "NotAllowedError" || errorName === "PermissionDeniedError") {
- return status({
- state: "denied",
- detail: "Microphone permission was denied by the user or system.",
- });
- }
- if (errorName === "NotFoundError" || errorName === "DevicesNotFoundError") {
+ async function getNotificationPermissionStatus(): Promise {
+ if (!env.isWeb) {
return status({
state: "unavailable",
- detail: "No microphone device was found.",
+ detail: "Desktop notification status is only available on web runtime.",
});
}
+
+ const desktopHost = env.getDesktopHost();
+ if (desktopHost && typeof desktopHost.notification?.isSupported === "function") {
+ try {
+ const supported = await desktopHost.notification.isSupported();
+ return status({
+ state: supported ? "granted" : "unavailable",
+ detail: supported
+ ? "Desktop notifications are supported."
+ : "Desktop notifications are not supported on this platform.",
+ });
+ } catch {
+ // Fall through to web API check
+ }
+ }
+
+ const NotificationConstructor = env.getNotification();
+ if (NotificationConstructor && typeof NotificationConstructor.permission === "string") {
+ return mapNotificationPermissionString(NotificationConstructor.permission);
+ }
+
return status({
- state: "unknown",
- detail: `Failed to request microphone permission: ${getErrorMessage(error)}`,
+ state: "unavailable",
+ detail: "Web Notification API is unavailable in this environment.",
});
}
-}
-export async function requestDesktopPermission(input: {
- kind: DesktopPermissionKind;
-}): Promise {
- if (input.kind === "notifications") {
- return await requestNotificationPermissionStatus();
+ async function getMicrophonePermissionStatus(): Promise {
+ if (!env.isWeb) {
+ return status({
+ state: "unavailable",
+ detail: "Desktop microphone status is only available on web runtime.",
+ });
+ }
+
+ const webNavigator = env.getNavigator();
+ if (!webNavigator) {
+ return status({
+ state: "unavailable",
+ detail: "Navigator is unavailable in this environment.",
+ });
+ }
+
+ const permissionsApi = webNavigator.permissions;
+ if (permissionsApi && typeof permissionsApi.query === "function") {
+ try {
+ const result = await permissionsApi.query({ name: "microphone" });
+ if (result?.state === "granted") {
+ return status({
+ state: "granted",
+ detail: "Microphone access is granted.",
+ });
+ }
+ if (result?.state === "denied") {
+ return status({
+ state: "denied",
+ detail: "Microphone access is denied in system settings.",
+ });
+ }
+ if (result?.state === "prompt") {
+ return status({
+ state: "prompt",
+ detail: "Microphone permission has not been granted yet.",
+ });
+ }
+ return status({
+ state: "unknown",
+ detail: `Unexpected microphone permission state: ${result?.state ?? "unknown"}`,
+ });
+ } catch (error) {
+ if (isPermissionsQueryRuntimeUnsupported(error)) {
+ return status({
+ state: "unknown",
+ detail:
+ "Microphone status API is unavailable in this runtime. Use Request to check access.",
+ });
+ }
+ return status({
+ state: "unknown",
+ detail: `Failed to query microphone status: ${getErrorMessage(error)}`,
+ });
+ }
+ }
+
+ if (typeof webNavigator.mediaDevices?.getUserMedia !== "function") {
+ return status({
+ state: "unavailable",
+ detail: "Microphone capture is unavailable in this environment.",
+ });
+ }
+
+ return status({
+ state: "unknown",
+ detail: "Permission status API is unavailable. Use Request to check access.",
+ });
}
- return await requestMicrophonePermissionStatus();
-}
-export async function getDesktopPermissionSnapshot(): Promise {
- const [notifications, microphone] = await Promise.all([
- getNotificationPermissionStatus(),
- getMicrophonePermissionStatus(),
- ]);
+ async function requestNotificationPermissionStatus(): Promise {
+ if (!env.isWeb) {
+ return status({
+ state: "unavailable",
+ detail: "Desktop notification requests are only available on web runtime.",
+ });
+ }
+
+ const NotificationConstructor = env.getNotification();
+ if (
+ NotificationConstructor &&
+ typeof NotificationConstructor.requestPermission === "function"
+ ) {
+ try {
+ const permission = await NotificationConstructor.requestPermission();
+ return mapNotificationPermissionString(permission);
+ } catch (error) {
+ return status({
+ state: "unknown",
+ detail: `Failed to request notification permission: ${getErrorMessage(error)}`,
+ });
+ }
+ }
+
+ return status({
+ state: "unavailable",
+ detail: "Web Notification API requestPermission() is unavailable.",
+ });
+ }
+
+ async function requestMicrophonePermissionStatus(): Promise {
+ if (!env.isWeb) {
+ return status({
+ state: "unavailable",
+ detail: "Desktop microphone requests are only available on web runtime.",
+ });
+ }
+
+ const webNavigator = env.getNavigator();
+ if (!webNavigator || typeof webNavigator.mediaDevices?.getUserMedia !== "function") {
+ return status({
+ state: "unavailable",
+ detail: "Microphone capture API is unavailable in this environment.",
+ });
+ }
+
+ try {
+ const stream = await webNavigator.mediaDevices.getUserMedia({ audio: true });
+ const tracks = stream && typeof stream.getTracks === "function" ? stream.getTracks() : [];
+ tracks.forEach((track) => {
+ if (typeof track.stop === "function") {
+ track.stop();
+ }
+ });
+ return await getMicrophonePermissionStatus();
+ } catch (error) {
+ const errorName = getErrorName(error);
+ if (errorName === "NotAllowedError" || errorName === "PermissionDeniedError") {
+ return status({
+ state: "denied",
+ detail: "Microphone permission was denied by the user or system.",
+ });
+ }
+ if (errorName === "NotFoundError" || errorName === "DevicesNotFoundError") {
+ return status({
+ state: "unavailable",
+ detail: "No microphone device was found.",
+ });
+ }
+ return status({
+ state: "unknown",
+ detail: `Failed to request microphone permission: ${getErrorMessage(error)}`,
+ });
+ }
+ }
+
+ async function requestDesktopPermission(input: {
+ kind: DesktopPermissionKind;
+ }): Promise {
+ if (input.kind === "notifications") {
+ return await requestNotificationPermissionStatus();
+ }
+ return await requestMicrophonePermissionStatus();
+ }
+
+ async function getDesktopPermissionSnapshot(): Promise {
+ const [notifications, microphone] = await Promise.all([
+ getNotificationPermissionStatus(),
+ getMicrophonePermissionStatus(),
+ ]);
+
+ return {
+ checkedAt: Date.now(),
+ notifications,
+ microphone,
+ };
+ }
return {
- checkedAt: Date.now(),
- notifications,
- microphone,
+ shouldShowDesktopPermissionSection,
+ getDesktopPermissionSnapshot,
+ requestDesktopPermission,
};
}
+
+function getRealNotification(): NotificationConstructorLike | null {
+ if (isNative) {
+ return null;
+ }
+ const NotificationConstructor = (globalThis as { Notification?: unknown }).Notification;
+ if (
+ NotificationConstructor == null ||
+ (typeof NotificationConstructor !== "function" && typeof NotificationConstructor !== "object")
+ ) {
+ return null;
+ }
+ return NotificationConstructor as NotificationConstructorLike;
+}
+
+function getRealNavigator(): NavigatorLike | null {
+ if (isNative) {
+ return null;
+ }
+ const webNavigator = (globalThis as { navigator?: unknown }).navigator;
+ if (!isObject(webNavigator)) {
+ return null;
+ }
+ return webNavigator as NavigatorLike;
+}
+
+const realDesktopPermissions = createDesktopPermissions({
+ isWeb,
+ getDesktopHost,
+ getNotification: getRealNotification,
+ getNavigator: getRealNavigator,
+});
+
+export const shouldShowDesktopPermissionSection =
+ realDesktopPermissions.shouldShowDesktopPermissionSection;
+export const getDesktopPermissionSnapshot = realDesktopPermissions.getDesktopPermissionSnapshot;
+export const requestDesktopPermission = realDesktopPermissions.requestDesktopPermission;
diff --git a/packages/app/src/desktop/updates/desktop-app-updater.test.ts b/packages/app/src/desktop/updates/desktop-app-updater.test.ts
new file mode 100644
index 000000000..3c1c120fd
--- /dev/null
+++ b/packages/app/src/desktop/updates/desktop-app-updater.test.ts
@@ -0,0 +1,262 @@
+import { describe, expect, it } from "vitest";
+import {
+ createDesktopAppUpdater,
+ formatStatusText,
+ type DesktopAppUpdater,
+ type DesktopAppUpdaterErrorReport,
+} from "./desktop-app-updater";
+import {
+ buildFakeCheckResult,
+ buildFakeInstallResult,
+ createFakeDesktopAppUpdaterPort,
+ type FakeDesktopAppUpdaterPort,
+} from "./test-utils/fake-desktop-app-updater-port";
+
+function createUpdater(
+ overrides: {
+ port?: FakeDesktopAppUpdaterPort;
+ now?: () => number;
+ reportInstallError?: (report: DesktopAppUpdaterErrorReport) => void;
+ } = {},
+): {
+ updater: DesktopAppUpdater;
+ port: FakeDesktopAppUpdaterPort;
+ reportedInstallErrors: DesktopAppUpdaterErrorReport[];
+} {
+ const port = overrides.port ?? createFakeDesktopAppUpdaterPort();
+ const reportedInstallErrors: DesktopAppUpdaterErrorReport[] = [];
+ const updater = createDesktopAppUpdater({
+ port,
+ now: overrides.now ?? (() => 1_700_000_000_000),
+ reportInstallError:
+ overrides.reportInstallError ??
+ ((report) => {
+ reportedInstallErrors.push(report);
+ }),
+ });
+ return { updater, port, reportedInstallErrors };
+}
+
+describe("desktop app updater — check", () => {
+ it("forwards the requested release channel to the port", async () => {
+ const { updater, port } = createUpdater();
+ port.nextCheckResult(buildFakeCheckResult());
+
+ await updater.checkForUpdates({ releaseChannel: "beta" });
+
+ expect(port.recordedChecks).toEqual([{ releaseChannel: "beta" }]);
+ });
+
+ it("moves to 'checking' during a non-silent check", async () => {
+ const { updater, port } = createUpdater();
+ const deferred = port.deferNextCheck();
+
+ const pending = updater.checkForUpdates({ releaseChannel: "stable" });
+ expect(updater.getSnapshot().status).toBe("checking");
+ expect(updater.getSnapshot().isChecking).toBe(true);
+
+ deferred.resolve(buildFakeCheckResult());
+ await pending;
+ });
+
+ it("stays on the current status during a silent check", async () => {
+ const { updater, port } = createUpdater();
+ port.nextCheckResult(buildFakeCheckResult({ hasUpdate: true, readyToInstall: true }));
+ await updater.checkForUpdates({ releaseChannel: "stable" });
+ expect(updater.getSnapshot().status).toBe("available");
+
+ const deferred = port.deferNextCheck();
+ const pending = updater.checkForUpdates({ releaseChannel: "stable", silent: true });
+ expect(updater.getSnapshot().status).toBe("available");
+
+ deferred.resolve(buildFakeCheckResult({ hasUpdate: true, readyToInstall: true }));
+ await pending;
+ });
+
+ it("reports 'available' when the check resolves with a downloaded update", async () => {
+ const { updater, port } = createUpdater({ now: () => 42 });
+ port.nextCheckResult(
+ buildFakeCheckResult({ hasUpdate: true, readyToInstall: true, latestVersion: "1.2.3" }),
+ );
+
+ await updater.checkForUpdates({ releaseChannel: "stable" });
+
+ expect(updater.getSnapshot()).toMatchObject({
+ status: "available",
+ availableUpdate: { latestVersion: "1.2.3" },
+ lastCheckedAt: 42,
+ });
+ });
+
+ it("reports 'pending' when the check resolves with an update that is not yet downloaded", async () => {
+ const { updater, port } = createUpdater();
+ port.nextCheckResult(buildFakeCheckResult({ hasUpdate: true, readyToInstall: false }));
+
+ await updater.checkForUpdates({ releaseChannel: "stable" });
+
+ expect(updater.getSnapshot()).toMatchObject({
+ status: "pending",
+ availableUpdate: null,
+ });
+ });
+
+ it("reports 'up-to-date' when the check resolves with no update", async () => {
+ const { updater, port } = createUpdater();
+ port.nextCheckResult(buildFakeCheckResult({ hasUpdate: false, readyToInstall: false }));
+
+ await updater.checkForUpdates({ releaseChannel: "stable" });
+
+ expect(updater.getSnapshot().status).toBe("up-to-date");
+ });
+
+ it("reports 'error' when a non-silent check throws", async () => {
+ const { updater, port } = createUpdater();
+ port.failNextCheck(new Error("network down"));
+
+ await updater.checkForUpdates({ releaseChannel: "stable" });
+
+ expect(updater.getSnapshot()).toMatchObject({
+ status: "error",
+ errorMessage: "network down",
+ });
+ });
+
+ it("does not move to 'error' when a silent check throws", async () => {
+ const { updater, port } = createUpdater();
+ port.nextCheckResult(buildFakeCheckResult({ hasUpdate: true, readyToInstall: true }));
+ await updater.checkForUpdates({ releaseChannel: "stable" });
+ const statusBeforeSilent = updater.getSnapshot().status;
+
+ port.failNextCheck(new Error("boom"));
+ await updater.checkForUpdates({ releaseChannel: "stable", silent: true });
+
+ expect(updater.getSnapshot().status).toBe(statusBeforeSilent);
+ });
+
+ it("ignores the older result when a newer check supersedes it mid-flight", async () => {
+ const { updater, port } = createUpdater();
+ const firstDeferred = port.deferNextCheck();
+ port.nextCheckResult(buildFakeCheckResult({ hasUpdate: true, readyToInstall: true }));
+
+ const firstPending = updater.checkForUpdates({ releaseChannel: "stable" });
+ const secondPending = updater.checkForUpdates({ releaseChannel: "stable" });
+ await secondPending;
+ expect(updater.getSnapshot().status).toBe("available");
+
+ firstDeferred.resolve(buildFakeCheckResult({ hasUpdate: false, readyToInstall: false }));
+ await firstPending;
+
+ expect(updater.getSnapshot().status).toBe("available");
+ });
+});
+
+describe("desktop app updater — install", () => {
+ it("forwards the requested release channel to the port", async () => {
+ const { updater, port } = createUpdater();
+ port.nextInstallResult(buildFakeInstallResult({ installed: true }));
+
+ await updater.installUpdate({ releaseChannel: "beta" });
+
+ expect(port.recordedInstalls).toEqual([{ releaseChannel: "beta" }]);
+ });
+
+ it("moves to 'installed' when the install reports installation succeeded", async () => {
+ const { updater, port } = createUpdater();
+ port.nextInstallResult(
+ buildFakeInstallResult({ installed: true, message: "Restart to finish" }),
+ );
+
+ await updater.installUpdate({ releaseChannel: "stable" });
+
+ expect(updater.getSnapshot()).toMatchObject({
+ status: "installed",
+ installMessage: "Restart to finish",
+ isInstalling: false,
+ });
+ });
+
+ it("moves to 'up-to-date' when the install reports nothing to install", async () => {
+ const { updater, port } = createUpdater();
+ port.nextInstallResult(buildFakeInstallResult({ installed: false }));
+
+ await updater.installUpdate({ releaseChannel: "stable" });
+
+ expect(updater.getSnapshot().status).toBe("up-to-date");
+ });
+
+ it("reports the install error and moves to 'error' when the install throws", async () => {
+ const { updater, port, reportedInstallErrors } = createUpdater();
+ const error = new Error("install failed");
+ port.failNextInstall(error);
+
+ await updater.installUpdate({ releaseChannel: "stable" });
+
+ expect(updater.getSnapshot()).toMatchObject({
+ status: "error",
+ errorMessage: "install failed",
+ isInstalling: false,
+ });
+ expect(reportedInstallErrors).toEqual([
+ {
+ error,
+ message: "Unable to install the desktop app update.",
+ logLabel: "[DesktopUpdater] Failed to install app update",
+ },
+ ]);
+ });
+});
+
+describe("desktop app updater — subscribe", () => {
+ it("notifies subscribers when the status changes", async () => {
+ const { updater, port } = createUpdater();
+ port.nextCheckResult(buildFakeCheckResult({ hasUpdate: true, readyToInstall: true }));
+
+ const notifications: string[] = [];
+ const unsubscribe = updater.subscribe(() => {
+ notifications.push(updater.getSnapshot().status);
+ });
+
+ await updater.checkForUpdates({ releaseChannel: "stable" });
+ unsubscribe();
+
+ expect(notifications).toEqual(["checking", "available"]);
+ });
+});
+
+describe("formatStatusText", () => {
+ const formatVersion = (version: string | null | undefined) =>
+ version ? `v${version.replace(/^v/i, "")}` : "\u2014";
+
+ it("uses the latest version in the 'available' message when present", () => {
+ expect(
+ formatStatusText({
+ status: "available",
+ availableUpdate: buildFakeCheckResult({ latestVersion: "1.2.3" }),
+ installMessage: null,
+ formatVersion,
+ }),
+ ).toBe("Update ready: v1.2.3");
+ });
+
+ it("falls back to a generic 'available' message when no version is reported", () => {
+ expect(
+ formatStatusText({
+ status: "available",
+ availableUpdate: null,
+ installMessage: null,
+ formatVersion,
+ }),
+ ).toBe("An app update is ready to install.");
+ });
+
+ it("uses the install message in the 'installed' state when present", () => {
+ expect(
+ formatStatusText({
+ status: "installed",
+ availableUpdate: null,
+ installMessage: "Restart now",
+ formatVersion,
+ }),
+ ).toBe("Restart now");
+ });
+});
diff --git a/packages/app/src/desktop/updates/desktop-app-updater.ts b/packages/app/src/desktop/updates/desktop-app-updater.ts
new file mode 100644
index 000000000..1bcb7b5f6
--- /dev/null
+++ b/packages/app/src/desktop/updates/desktop-app-updater.ts
@@ -0,0 +1,275 @@
+import type {
+ DesktopAppUpdateCheckResult,
+ DesktopAppUpdateInstallResult,
+ DesktopReleaseChannel,
+} from "@/desktop/updates/desktop-updates";
+
+export type DesktopAppUpdateStatus =
+ | "idle"
+ | "checking"
+ | "pending"
+ | "up-to-date"
+ | "available"
+ | "installing"
+ | "installed"
+ | "error";
+
+export const PENDING_RECHECK_MS = 10_000;
+
+export interface DesktopAppUpdaterSnapshot {
+ status: DesktopAppUpdateStatus;
+ availableUpdate: DesktopAppUpdateCheckResult | null;
+ errorMessage: string | null;
+ installMessage: string | null;
+ lastCheckedAt: number | null;
+ isChecking: boolean;
+ isInstalling: boolean;
+}
+
+export interface DesktopAppUpdaterPort {
+ checkDesktopAppUpdate(input: {
+ releaseChannel: DesktopReleaseChannel;
+ }): Promise;
+ installDesktopAppUpdate(input: {
+ releaseChannel: DesktopReleaseChannel;
+ }): Promise;
+}
+
+export interface DesktopAppUpdaterErrorReport {
+ error: unknown;
+ message: string;
+ logLabel: string;
+}
+
+export interface DesktopAppUpdaterDeps {
+ port: DesktopAppUpdaterPort;
+ now(): number;
+ reportInstallError?(report: DesktopAppUpdaterErrorReport): void;
+}
+
+export interface DesktopAppUpdater {
+ getSnapshot(): DesktopAppUpdaterSnapshot;
+ subscribe(listener: () => void): () => void;
+ checkForUpdates(options?: {
+ releaseChannel: DesktopReleaseChannel;
+ silent?: boolean;
+ }): Promise;
+ installUpdate(options: {
+ releaseChannel: DesktopReleaseChannel;
+ }): Promise;
+}
+
+interface InternalState {
+ status: DesktopAppUpdateStatus;
+ availableUpdate: DesktopAppUpdateCheckResult | null;
+ errorMessage: string | null;
+ installMessage: string | null;
+ lastCheckedAt: number | null;
+ isInstalling: boolean;
+ requestVersion: number;
+}
+
+const INITIAL_STATE: InternalState = {
+ status: "idle",
+ availableUpdate: null,
+ errorMessage: null,
+ installMessage: null,
+ lastCheckedAt: null,
+ isInstalling: false,
+ requestVersion: 0,
+};
+
+function buildSnapshot(state: InternalState): DesktopAppUpdaterSnapshot {
+ return {
+ status: state.status,
+ availableUpdate: state.availableUpdate,
+ errorMessage: state.errorMessage,
+ installMessage: state.installMessage,
+ lastCheckedAt: state.lastCheckedAt,
+ isChecking: state.status === "checking",
+ isInstalling: state.status === "installing" || state.isInstalling,
+ };
+}
+
+function getErrorMessage(error: unknown): string {
+ if (error instanceof Error && typeof error.message === "string") {
+ return error.message;
+ }
+ return String(error);
+}
+
+export function formatStatusText(input: {
+ status: DesktopAppUpdateStatus;
+ availableUpdate: DesktopAppUpdateCheckResult | null;
+ installMessage: string | null;
+ formatVersion: (version: string | null | undefined) => string;
+}): string {
+ const { status, availableUpdate, installMessage, formatVersion } = input;
+
+ if (status === "checking") {
+ return "Checking for app updates...";
+ }
+
+ if (status === "installing") {
+ return "Installing app update...";
+ }
+
+ if (status === "up-to-date") {
+ return "App is up to date.";
+ }
+
+ if (status === "pending") {
+ return "We'll let you know when the update is ready.";
+ }
+
+ if (status === "available") {
+ if (availableUpdate?.latestVersion) {
+ return `Update ready: ${formatVersion(availableUpdate.latestVersion)}`;
+ }
+ return "An app update is ready to install.";
+ }
+
+ if (status === "installed") {
+ return installMessage ?? "App update installed. Restart required.";
+ }
+
+ if (status === "error") {
+ return "Failed to update app.";
+ }
+
+ return "Update status has not been checked yet.";
+}
+
+export function createDesktopAppUpdater(deps: DesktopAppUpdaterDeps): DesktopAppUpdater {
+ let state: InternalState = { ...INITIAL_STATE };
+ let cachedSnapshot: DesktopAppUpdaterSnapshot = buildSnapshot(state);
+ const listeners = new Set<() => void>();
+
+ function commit(next: InternalState): void {
+ state = next;
+ cachedSnapshot = buildSnapshot(state);
+ for (const listener of listeners) {
+ listener();
+ }
+ }
+
+ async function checkForUpdates(options?: {
+ releaseChannel: DesktopReleaseChannel;
+ silent?: boolean;
+ }): Promise {
+ if (!options) {
+ return null;
+ }
+ const { releaseChannel, silent = false } = options;
+ const requestVersion = state.requestVersion + 1;
+
+ commit({
+ ...state,
+ requestVersion,
+ status: silent ? state.status : "checking",
+ errorMessage: null,
+ });
+
+ try {
+ const result = await deps.port.checkDesktopAppUpdate({ releaseChannel });
+ if (requestVersion !== state.requestVersion) {
+ return result;
+ }
+
+ const nextLastCheckedAt = deps.now();
+ let nextStatus: DesktopAppUpdateStatus;
+ let nextAvailable: DesktopAppUpdateCheckResult | null;
+
+ if (result.readyToInstall) {
+ nextStatus = "available";
+ nextAvailable = result;
+ } else if (result.hasUpdate) {
+ nextStatus = "pending";
+ nextAvailable = null;
+ } else {
+ nextStatus = "up-to-date";
+ nextAvailable = null;
+ }
+
+ commit({
+ ...state,
+ status: nextStatus,
+ availableUpdate: nextAvailable,
+ installMessage: null,
+ lastCheckedAt: nextLastCheckedAt,
+ });
+
+ return result;
+ } catch (error) {
+ if (requestVersion !== state.requestVersion) {
+ return null;
+ }
+
+ const message = getErrorMessage(error);
+ if (silent) {
+ console.warn("[DesktopUpdater] Silent update check failed", message);
+ commit({ ...state });
+ } else {
+ commit({
+ ...state,
+ status: "error",
+ errorMessage: message,
+ });
+ }
+ return null;
+ }
+ }
+
+ async function installUpdate(options: {
+ releaseChannel: DesktopReleaseChannel;
+ }): Promise {
+ commit({
+ ...state,
+ status: "installing",
+ errorMessage: null,
+ isInstalling: true,
+ });
+
+ try {
+ const result = await deps.port.installDesktopAppUpdate({
+ releaseChannel: options.releaseChannel,
+ });
+ const nextLastCheckedAt = deps.now();
+ commit({
+ ...state,
+ status: result.installed ? "installed" : "up-to-date",
+ availableUpdate: null,
+ installMessage: result.message,
+ lastCheckedAt: nextLastCheckedAt,
+ isInstalling: false,
+ });
+ return result;
+ } catch (error) {
+ const message = getErrorMessage(error);
+ deps.reportInstallError?.({
+ error,
+ message: "Unable to install the desktop app update.",
+ logLabel: "[DesktopUpdater] Failed to install app update",
+ });
+ commit({
+ ...state,
+ status: "error",
+ errorMessage: message,
+ isInstalling: false,
+ });
+ return null;
+ }
+ }
+
+ return {
+ getSnapshot: () => cachedSnapshot,
+ subscribe(listener) {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+ },
+ checkForUpdates,
+ installUpdate,
+ };
+}
diff --git a/packages/app/src/desktop/updates/resolve-update-callout.test.ts b/packages/app/src/desktop/updates/resolve-update-callout.test.ts
new file mode 100644
index 000000000..4dfdc6cbb
--- /dev/null
+++ b/packages/app/src/desktop/updates/resolve-update-callout.test.ts
@@ -0,0 +1,107 @@
+import { describe, expect, it } from "vitest";
+import {
+ resolveUpdateCalloutDescriptor,
+ type ResolveUpdateCalloutInput,
+} from "./resolve-update-callout";
+
+function input(overrides: Partial = {}): ResolveUpdateCalloutInput {
+ return {
+ isDesktopApp: true,
+ status: "available",
+ isInstalling: false,
+ availableUpdate: { latestVersion: "1.2.3" },
+ errorMessage: null,
+ ...overrides,
+ };
+}
+
+describe("resolveUpdateCalloutDescriptor", () => {
+ it("returns null when not running as a desktop app", () => {
+ expect(resolveUpdateCalloutDescriptor(input({ isDesktopApp: false }))).toBeNull();
+ });
+
+ it("returns null for idle / checking / up-to-date / pending statuses", () => {
+ for (const status of ["idle", "checking", "up-to-date", "pending"] as const) {
+ expect(resolveUpdateCalloutDescriptor(input({ status }))).toBeNull();
+ }
+ });
+
+ it("builds an update-available descriptor with changelog + install actions", () => {
+ const descriptor = resolveUpdateCalloutDescriptor(input());
+
+ expect(descriptor).not.toBeNull();
+ expect(descriptor?.id).toBe("desktop-update");
+ expect(descriptor?.priority).toBe(200);
+ expect(descriptor?.testID).toBe("update-callout");
+ expect(descriptor?.title).toBe("Update available");
+ expect(descriptor?.variant).toBe("default");
+ expect(descriptor?.showGiftIcon).toBe(true);
+ expect(descriptor?.body).toEqual({ kind: "available", versionLabel: "v1.2.3" });
+ expect(descriptor?.actions).toEqual([
+ { role: "changelog", label: "What's new" },
+ { role: "install", label: "Install & restart", variant: "primary", disabled: false },
+ ]);
+ expect(descriptor?.dismissalKey).toBe("desktop-update:available:1.2.3");
+ });
+
+ it("normalizes a leading v in the latest version", () => {
+ const descriptor = resolveUpdateCalloutDescriptor(
+ input({ availableUpdate: { latestVersion: "v2.0.0" } }),
+ );
+ expect(descriptor?.body).toEqual({ kind: "available", versionLabel: "v2.0.0" });
+ });
+
+ it("omits the version label when no latest version is known", () => {
+ const descriptor = resolveUpdateCalloutDescriptor(
+ input({ availableUpdate: { latestVersion: null } }),
+ );
+ expect(descriptor?.body).toEqual({ kind: "available", versionLabel: null });
+ expect(descriptor?.dismissalKey).toBe("desktop-update:available:unknown");
+ });
+
+ it("disables the install action and labels it Installing... while installing", () => {
+ const descriptor = resolveUpdateCalloutDescriptor(
+ input({ status: "installing", isInstalling: true }),
+ );
+
+ expect(descriptor?.title).toBe("Installing update");
+ expect(descriptor?.body).toEqual({ kind: "installing" });
+ expect(descriptor?.showGiftIcon).toBe(false);
+ expect(descriptor?.variant).toBe("default");
+ expect(descriptor?.actions).toEqual([
+ { role: "changelog", label: "What's new" },
+ { role: "install", label: "Installing...", variant: "primary", disabled: true },
+ ]);
+ expect(descriptor?.dismissalKey).toBe("desktop-update:installing:1.2.3");
+ });
+
+ it("shows a retry action and surfaces the error message on error", () => {
+ const descriptor = resolveUpdateCalloutDescriptor(
+ input({ status: "error", errorMessage: "Download failed", availableUpdate: null }),
+ );
+
+ expect(descriptor?.title).toBe("Update failed");
+ expect(descriptor?.body).toEqual({ kind: "error", message: "Download failed" });
+ expect(descriptor?.variant).toBe("error");
+ expect(descriptor?.showGiftIcon).toBe(false);
+ expect(descriptor?.actions).toEqual([
+ { role: "changelog", label: "What's new" },
+ { role: "retry", label: "Retry", variant: "primary" },
+ ]);
+ expect(descriptor?.dismissalKey).toBe("desktop-update:error:unknown");
+ });
+
+ it("falls back to a generic error message when none is provided", () => {
+ const descriptor = resolveUpdateCalloutDescriptor(
+ input({ status: "error", errorMessage: null, availableUpdate: null }),
+ );
+ expect(descriptor?.body).toEqual({ kind: "error", message: "Something went wrong." });
+ });
+
+ it("encodes status and version into the dismissal key", () => {
+ expect(
+ resolveUpdateCalloutDescriptor(input({ availableUpdate: { latestVersion: "1.2.4" } }))
+ ?.dismissalKey,
+ ).toBe("desktop-update:available:1.2.4");
+ });
+});
diff --git a/packages/app/src/desktop/updates/resolve-update-callout.ts b/packages/app/src/desktop/updates/resolve-update-callout.ts
new file mode 100644
index 000000000..b87e83efe
--- /dev/null
+++ b/packages/app/src/desktop/updates/resolve-update-callout.ts
@@ -0,0 +1,93 @@
+import type { DesktopAppUpdateStatus } from "@/desktop/updates/use-desktop-app-updater";
+
+export type UpdateCalloutBody =
+ | { kind: "available"; versionLabel: string | null }
+ | { kind: "installing" }
+ | { kind: "error"; message: string };
+
+export type UpdateCalloutActionRole = "changelog" | "install" | "retry";
+
+export interface UpdateCalloutActionDescriptor {
+ role: UpdateCalloutActionRole;
+ label: string;
+ variant?: "primary" | "secondary";
+ disabled?: boolean;
+}
+
+export interface UpdateCalloutDescriptor {
+ id: "desktop-update";
+ dismissalKey: string;
+ priority: number;
+ title: string;
+ body: UpdateCalloutBody;
+ showGiftIcon: boolean;
+ variant: "default" | "error";
+ actions: UpdateCalloutActionDescriptor[];
+ testID: "update-callout";
+}
+
+export interface ResolveUpdateCalloutInput {
+ isDesktopApp: boolean;
+ status: DesktopAppUpdateStatus;
+ isInstalling: boolean;
+ availableUpdate: { latestVersion?: string | null } | null;
+ errorMessage: string | null;
+}
+
+function formatVersionLabel(latestVersion: string | null | undefined): string | null {
+ if (!latestVersion) return null;
+ return `v${latestVersion.replace(/^v/i, "")}`;
+}
+
+export function resolveUpdateCalloutDescriptor(
+ input: ResolveUpdateCalloutInput,
+): UpdateCalloutDescriptor | null {
+ if (!input.isDesktopApp) return null;
+ if (input.status !== "available" && input.status !== "installing" && input.status !== "error") {
+ return null;
+ }
+
+ const isError = input.status === "error";
+ const isInstalling = input.isInstalling;
+ const isAvailable = !isInstalling && !isError;
+
+ const latestVersion = input.availableUpdate?.latestVersion ?? null;
+ const dismissalKey = `desktop-update:${input.status}:${latestVersion ?? "unknown"}`;
+
+ let title: string;
+ let body: UpdateCalloutBody;
+ if (isInstalling) {
+ title = "Installing update";
+ body = { kind: "installing" };
+ } else if (isError) {
+ title = "Update failed";
+ body = { kind: "error", message: input.errorMessage ?? "Something went wrong." };
+ } else {
+ title = "Update available";
+ body = { kind: "available", versionLabel: formatVersionLabel(latestVersion) };
+ }
+
+ const actions: UpdateCalloutActionDescriptor[] = [{ role: "changelog", label: "What's new" }];
+ if (isError) {
+ actions.push({ role: "retry", label: "Retry", variant: "primary" });
+ } else {
+ actions.push({
+ role: "install",
+ label: isInstalling ? "Installing..." : "Install & restart",
+ variant: "primary",
+ disabled: isInstalling,
+ });
+ }
+
+ return {
+ id: "desktop-update",
+ dismissalKey,
+ priority: 200,
+ title,
+ body,
+ showGiftIcon: isAvailable,
+ variant: isError ? "error" : "default",
+ actions,
+ testID: "update-callout",
+ };
+}
diff --git a/packages/app/src/desktop/updates/test-utils/fake-desktop-app-updater-port.ts b/packages/app/src/desktop/updates/test-utils/fake-desktop-app-updater-port.ts
new file mode 100644
index 000000000..7fa08b08b
--- /dev/null
+++ b/packages/app/src/desktop/updates/test-utils/fake-desktop-app-updater-port.ts
@@ -0,0 +1,124 @@
+import type {
+ DesktopAppUpdateCheckResult,
+ DesktopAppUpdateInstallResult,
+ DesktopReleaseChannel,
+} from "@/desktop/updates/desktop-updates";
+import type { DesktopAppUpdaterPort } from "@/desktop/updates/desktop-app-updater";
+
+export interface FakeDesktopAppUpdaterPort extends DesktopAppUpdaterPort {
+ readonly recordedChecks: Array<{ releaseChannel: DesktopReleaseChannel }>;
+ readonly recordedInstalls: Array<{ releaseChannel: DesktopReleaseChannel }>;
+ nextCheckResult(result: DesktopAppUpdateCheckResult): void;
+ deferNextCheck(): {
+ resolve(result: DesktopAppUpdateCheckResult): void;
+ reject(error: unknown): void;
+ };
+ failNextCheck(error: unknown): void;
+ nextInstallResult(result: DesktopAppUpdateInstallResult): void;
+ failNextInstall(error: unknown): void;
+}
+
+type CheckOutcome =
+ | { kind: "result"; result: DesktopAppUpdateCheckResult }
+ | { kind: "error"; error: unknown }
+ | { kind: "deferred"; promise: Promise };
+
+type InstallOutcome =
+ | { kind: "result"; result: DesktopAppUpdateInstallResult }
+ | { kind: "error"; error: unknown };
+
+function buildCheckResult(
+ overrides: Partial = {},
+): DesktopAppUpdateCheckResult {
+ return {
+ hasUpdate: false,
+ readyToInstall: false,
+ currentVersion: null,
+ latestVersion: null,
+ body: null,
+ date: null,
+ ...overrides,
+ };
+}
+
+function buildInstallResult(
+ overrides: Partial = {},
+): DesktopAppUpdateInstallResult {
+ return {
+ installed: false,
+ version: null,
+ message: "Update completed.",
+ ...overrides,
+ };
+}
+
+export function createFakeDesktopAppUpdaterPort(): FakeDesktopAppUpdaterPort {
+ const recordedChecks: Array<{ releaseChannel: DesktopReleaseChannel }> = [];
+ const recordedInstalls: Array<{ releaseChannel: DesktopReleaseChannel }> = [];
+ const checkOutcomes: CheckOutcome[] = [];
+ const installOutcomes: InstallOutcome[] = [];
+
+ return {
+ recordedChecks,
+ recordedInstalls,
+ nextCheckResult(result) {
+ checkOutcomes.push({ kind: "result", result });
+ },
+ deferNextCheck() {
+ let resolve!: (value: DesktopAppUpdateCheckResult) => void;
+ let reject!: (error: unknown) => void;
+ const promise = new Promise((res, rej) => {
+ resolve = res;
+ reject = rej;
+ });
+ checkOutcomes.push({ kind: "deferred", promise });
+ return { resolve, reject };
+ },
+ failNextCheck(error) {
+ checkOutcomes.push({ kind: "error", error });
+ },
+ nextInstallResult(result) {
+ installOutcomes.push({ kind: "result", result });
+ },
+ failNextInstall(error) {
+ installOutcomes.push({ kind: "error", error });
+ },
+ async checkDesktopAppUpdate(input) {
+ recordedChecks.push(input);
+ const outcome = checkOutcomes.shift();
+ if (!outcome) {
+ return buildCheckResult();
+ }
+ if (outcome.kind === "result") {
+ return outcome.result;
+ }
+ if (outcome.kind === "error") {
+ throw outcome.error;
+ }
+ return outcome.promise;
+ },
+ async installDesktopAppUpdate(input) {
+ recordedInstalls.push(input);
+ const outcome = installOutcomes.shift();
+ if (!outcome) {
+ return buildInstallResult();
+ }
+ if (outcome.kind === "result") {
+ return outcome.result;
+ }
+ throw outcome.error;
+ },
+ };
+}
+
+export function buildFakeCheckResult(
+ overrides: Partial = {},
+): DesktopAppUpdateCheckResult {
+ return buildCheckResult(overrides);
+}
+
+export function buildFakeInstallResult(
+ overrides: Partial = {},
+): DesktopAppUpdateInstallResult {
+ return buildInstallResult(overrides);
+}
diff --git a/packages/app/src/desktop/updates/update-callout-source.test.tsx b/packages/app/src/desktop/updates/update-callout-source.test.tsx
deleted file mode 100644
index 207b32302..000000000
--- a/packages/app/src/desktop/updates/update-callout-source.test.tsx
+++ /dev/null
@@ -1,259 +0,0 @@
-/**
- * @vitest-environment jsdom
- */
-import React, { act } from "react";
-import { createRoot, type Root } from "react-dom/client";
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { SidebarCalloutSlot } from "@/components/sidebar-callout-slot";
-import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context";
-
-const { theme } = vi.hoisted(() => ({
- theme: {
- spacing: { 0: 0, 1: 4, 2: 8, 3: 12, 4: 16 },
- iconSize: { sm: 14, md: 18 },
- borderWidth: { 1: 1 },
- borderRadius: { md: 6 },
- fontSize: { xs: 11, sm: 13 },
- fontWeight: { medium: "500", semibold: "600" },
- shadow: { md: {} },
- colors: {
- surface0: "#000",
- foreground: "#fff",
- foregroundMuted: "#aaa",
- border: "#555",
- destructive: "#ff4444",
- },
- },
-}));
-
-const asyncStorage = vi.hoisted(() => ({
- values: new Map(),
- getItem: vi.fn(async (key: string) => asyncStorage.values.get(key) ?? null),
- setItem: vi.fn(async (key: string, value: string) => {
- asyncStorage.values.set(key, value);
- }),
-}));
-
-vi.mock("@react-native-async-storage/async-storage", () => ({
- default: asyncStorage,
-}));
-
-vi.mock("react-native-unistyles", () => ({
- StyleSheet: {
- create: (factory: unknown) =>
- typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory,
- },
- useUnistyles: () => ({ theme }),
-}));
-
-vi.mock("lucide-react-native", () => {
- const X = (props: Record) => React.createElement("span", props);
- const Gift = (props: Record) =>
- React.createElement("span", { ...props, "data-icon": "Gift" });
- return { Gift, X };
-});
-
-vi.mock("@/utils/open-external-url", () => ({
- openExternalUrl: vi.fn(async () => {}),
-}));
-
-const updaterState = vi.hoisted(() => ({
- value: {
- isDesktopApp: true,
- status: "available" as
- | "idle"
- | "checking"
- | "pending"
- | "up-to-date"
- | "available"
- | "installing"
- | "installed"
- | "error",
- availableUpdate: {
- hasUpdate: true,
- readyToInstall: true,
- currentVersion: "1.2.2",
- latestVersion: "1.2.3",
- body: null,
- date: null,
- } as unknown,
- errorMessage: null as string | null,
- lastCheckedAt: null as number | null,
- isChecking: false,
- isInstalling: false,
- statusText: "",
- checkForUpdates: vi.fn(async () => null),
- installUpdate: vi.fn(async () => null),
- },
-}));
-
-vi.mock("@/desktop/updates/use-desktop-app-updater", () => ({
- useDesktopAppUpdater: () => updaterState.value,
-}));
-
-vi.stubGlobal("React", React);
-vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
-
-import { UpdateCalloutSource } from "./update-callout-source";
-
-function resetUpdaterState(): void {
- updaterState.value = {
- isDesktopApp: true,
- status: "available",
- availableUpdate: {
- hasUpdate: true,
- readyToInstall: true,
- currentVersion: "1.2.2",
- latestVersion: "1.2.3",
- body: null,
- date: null,
- },
- errorMessage: null,
- lastCheckedAt: null,
- isChecking: false,
- isInstalling: false,
- statusText: "",
- checkForUpdates: vi.fn(async () => null),
- installUpdate: vi.fn(async () => null),
- };
-}
-
-function UpdateCalloutHarness({ withSlot = true }: { withSlot?: boolean }) {
- return (
-
-
- {withSlot ? : null}
-
- );
-}
-
-async function renderHarness(root: Root, withSlot = true): Promise {
- await act(async () => {
- root.render();
- await Promise.resolve();
- await Promise.resolve();
- });
-}
-
-describe("UpdateCalloutSource", () => {
- let root: Root | null = null;
- let container: HTMLElement | null = null;
-
- beforeEach(() => {
- resetUpdaterState();
- asyncStorage.values.clear();
- asyncStorage.getItem.mockClear();
- asyncStorage.setItem.mockClear();
- container = document.createElement("div");
- document.body.appendChild(container);
- root = createRoot(container);
- });
-
- afterEach(() => {
- if (root) {
- act(() => {
- root?.unmount();
- });
- }
- root = null;
- container?.remove();
- container = null;
- });
-
- it("registers an update-available sidebar callout with changelog + install actions", async () => {
- await renderHarness(root!);
-
- expect(container?.querySelector('[data-testid="update-callout"]')).not.toBeNull();
- expect(container?.textContent).toContain("Update available");
- expect(container?.textContent).toContain("1.2.3");
- expect(container?.textContent).toContain(
- "Upgrading the app will stop running agents and close terminal sessions.",
- );
- expect(container?.querySelector('[data-icon="Gift"]')).not.toBeNull();
- expect(
- container?.querySelector('[data-testid="update-callout-action-0"]')?.textContent,
- ).toContain("What's new");
- expect(
- container?.querySelector('[data-testid="update-callout-action-1"]')?.textContent,
- ).toContain("Install & restart");
- });
-
- it("renders only through the sidebar slot", async () => {
- await renderHarness(root!, false);
-
- expect(container?.querySelector('[data-testid="update-callout"]')).toBeNull();
- });
-
- it("disables the install action and shows Installing... while installing", async () => {
- updaterState.value = {
- ...updaterState.value,
- status: "installing",
- isInstalling: true,
- };
- await renderHarness(root!);
-
- expect(
- container?.querySelector('[data-testid="update-callout-action-1"]')?.textContent,
- ).toContain("Installing");
- });
-
- it("shows a retry action on error and surfaces the error message", async () => {
- updaterState.value = {
- ...updaterState.value,
- status: "error",
- errorMessage: "Download failed",
- availableUpdate: null,
- };
- await renderHarness(root!);
-
- expect(container?.textContent).toContain("Update failed");
- expect(container?.textContent).toContain("Download failed");
- expect(
- container?.querySelector('[data-testid="update-callout-action-1"]')?.textContent,
- ).toContain("Retry");
- });
-
- it("renders nothing when not running as a desktop app", async () => {
- updaterState.value = { ...updaterState.value, isDesktopApp: false };
- await renderHarness(root!);
-
- expect(container?.querySelector('[data-testid="update-callout"]')).toBeNull();
- });
-
- it("renders nothing when status is idle / checking / up-to-date / pending", async () => {
- for (const status of ["idle", "checking", "up-to-date", "pending"] as const) {
- resetUpdaterState();
- updaterState.value = { ...updaterState.value, status };
- const innerContainer = document.createElement("div");
- document.body.appendChild(innerContainer);
- const innerRoot = createRoot(innerContainer);
- await act(async () => {
- innerRoot.render();
- await Promise.resolve();
- await Promise.resolve();
- });
- expect(innerContainer.querySelector('[data-testid="update-callout"]')).toBeNull();
- act(() => {
- innerRoot.unmount();
- });
- innerContainer.remove();
- }
- });
-
- it("dismisses the registered update callout", async () => {
- await renderHarness(root!);
-
- const dismiss = container?.querySelector(
- '[data-testid="update-callout-dismiss"]',
- ) as HTMLElement | null;
- expect(dismiss).not.toBeNull();
- act(() => {
- dismiss?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
- });
- expect(container?.querySelector('[data-testid="update-callout"]')).toBeNull();
- expect(asyncStorage.setItem).toHaveBeenCalledWith(
- "@paseo:sidebar-callout-dismissals",
- JSON.stringify(["desktop-update:available:1.2.3"]),
- );
- });
-});
diff --git a/packages/app/src/desktop/updates/update-callout-source.tsx b/packages/app/src/desktop/updates/update-callout-source.tsx
index 1c4143ada..e3329a3dd 100644
--- a/packages/app/src/desktop/updates/update-callout-source.tsx
+++ b/packages/app/src/desktop/updates/update-callout-source.tsx
@@ -6,6 +6,11 @@ import {
SidebarCalloutDescriptionText,
} from "@/components/sidebar-callout";
import { useSidebarCallouts } from "@/contexts/sidebar-callout-context";
+import {
+ resolveUpdateCalloutDescriptor,
+ type UpdateCalloutActionDescriptor,
+ type UpdateCalloutBody,
+} from "@/desktop/updates/resolve-update-callout";
import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater";
import { useStableEvent } from "@/hooks/use-stable-event";
import { openExternalUrl } from "@/utils/open-external-url";
@@ -13,47 +18,22 @@ import { openExternalUrl } from "@/utils/open-external-url";
const CHECK_INTERVAL_MS = 30 * 60 * 1000;
const CHANGELOG_URL = "https://paseo.sh/changelog";
-function resolveUpdateCalloutTitle(args: { isInstalling: boolean; isError: boolean }): string {
- if (args.isInstalling) return "Installing update";
- if (args.isError) return "Update failed";
- return "Update available";
+function renderBody(body: UpdateCalloutBody): ReactNode {
+ if (body.kind === "installing") return "Installing and restarting...";
+ if (body.kind === "error") return body.message;
+ return ;
}
-function resolveUpdateCalloutDescription(args: {
- isInstalling: boolean;
- isError: boolean;
- errorMessage: string | null;
- latestVersion: string | undefined;
-}): ReactNode {
- if (args.isInstalling) return "Installing and restarting...";
- if (args.isError) return args.errorMessage ?? "Something went wrong.";
- if (args.latestVersion) {
- return (
-
- );
- }
- return ;
-}
-
-function buildUpdateCalloutActions(args: {
- isInstalling: boolean;
- isError: boolean;
- openChangelog: () => void;
- retry: () => void;
- install: () => void;
-}): SidebarCalloutAction[] {
- const actions: SidebarCalloutAction[] = [{ label: "What's new", onPress: args.openChangelog }];
- if (args.isError) {
- actions.push({ label: "Retry", onPress: args.retry, variant: "primary" });
- } else {
- actions.push({
- label: args.isInstalling ? "Installing..." : "Install & restart",
- onPress: args.install,
- variant: "primary",
- disabled: args.isInstalling,
- });
- }
- return actions;
+function materializeActions(
+ actions: readonly UpdateCalloutActionDescriptor[],
+ handlers: { changelog: () => void; install: () => void; retry: () => void },
+): SidebarCalloutAction[] {
+ return actions.map((action) => ({
+ label: action.label,
+ onPress: handlers[action.role],
+ variant: action.variant,
+ disabled: action.disabled,
+ }));
}
export function UpdateCalloutSource() {
@@ -96,46 +76,34 @@ export function UpdateCalloutSource() {
}, [isDesktopApp, checkForUpdates]);
useEffect(() => {
- if (!isDesktopApp) {
- return;
- }
- if (status !== "available" && status !== "installing" && status !== "error") {
- return;
- }
-
- const isError = status === "error";
- const isAvailable = !isInstalling && !isError;
-
- const title = resolveUpdateCalloutTitle({ isInstalling, isError });
- const description = resolveUpdateCalloutDescription({
+ const descriptor = resolveUpdateCalloutDescriptor({
+ isDesktopApp,
+ status,
isInstalling,
- isError,
+ availableUpdate,
errorMessage,
- latestVersion: availableUpdate?.latestVersion ?? undefined,
- });
- const actions = buildUpdateCalloutActions({
- isInstalling,
- isError,
- openChangelog,
- retry,
- install,
});
+ if (!descriptor) return;
return callouts.show({
- id: "desktop-update",
- dismissalKey: `desktop-update:${status}:${availableUpdate?.latestVersion ?? "unknown"}`,
- priority: 200,
- title,
- description,
- icon: isAvailable ? (
+ id: descriptor.id,
+ dismissalKey: descriptor.dismissalKey,
+ priority: descriptor.priority,
+ title: descriptor.title,
+ description: renderBody(descriptor.body),
+ icon: descriptor.showGiftIcon ? (
) : undefined,
- variant: isError ? "error" : "default",
- actions,
- testID: "update-callout",
+ variant: descriptor.variant,
+ actions: materializeActions(descriptor.actions, {
+ changelog: openChangelog,
+ install,
+ retry,
+ }),
+ testID: descriptor.testID,
});
}, [
- availableUpdate?.latestVersion,
+ availableUpdate,
callouts,
errorMessage,
install,
diff --git a/packages/app/src/desktop/updates/use-desktop-app-updater.test.tsx b/packages/app/src/desktop/updates/use-desktop-app-updater.test.tsx
deleted file mode 100644
index 5efbbe980..000000000
--- a/packages/app/src/desktop/updates/use-desktop-app-updater.test.tsx
+++ /dev/null
@@ -1,94 +0,0 @@
-/**
- * @vitest-environment jsdom
- */
-import React from "react";
-import { act, renderHook, waitFor } from "@testing-library/react";
-import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import { beforeEach, describe, expect, it, vi } from "vitest";
-import { useDesktopAppUpdater } from "./use-desktop-app-updater";
-
-const desktopUpdatesMock = vi.hoisted(() => ({
- shouldShowDesktopUpdateSection: vi.fn(() => true),
- checkDesktopAppUpdate: vi.fn(async () => ({
- hasUpdate: false,
- readyToInstall: false,
- latestVersion: null,
- })),
- installDesktopAppUpdate: vi.fn(async () => ({
- installed: false,
- message: null,
- })),
- formatVersionWithPrefix: vi.fn((version: string | null) => version ?? "\u2014"),
-}));
-
-const settingsState = vi.hoisted(() => ({
- releaseChannel: "stable" as "stable" | "beta",
-}));
-
-vi.mock("@/desktop/updates/desktop-updates", () => desktopUpdatesMock);
-
-vi.mock("@/desktop/settings/desktop-settings", () => ({
- useDesktopSettings: () => ({
- settings: {
- releaseChannel: settingsState.releaseChannel,
- daemon: { manageBuiltInDaemon: true, keepRunningAfterQuit: true },
- },
- isLoading: false,
- error: null,
- updateSettings: vi.fn(async () => {}),
- }),
-}));
-
-vi.mock("@/contexts/toast-context", () => ({
- useToast: () => ({ error: vi.fn(), show: vi.fn(), copied: vi.fn() }),
-}));
-
-function createQueryClient(): QueryClient {
- return new QueryClient({
- defaultOptions: {
- queries: { retry: false },
- mutations: { retry: false },
- },
- });
-}
-
-function renderUpdaterHook() {
- const queryClient = createQueryClient();
- const wrapper = ({ children }: { children: React.ReactNode }) =>
- React.createElement(QueryClientProvider, { client: queryClient }, children);
-
- return renderHook(() => useDesktopAppUpdater(), { wrapper });
-}
-
-describe("useDesktopAppUpdater", () => {
- beforeEach(() => {
- settingsState.releaseChannel = "stable";
- desktopUpdatesMock.checkDesktopAppUpdate.mockClear();
- desktopUpdatesMock.installDesktopAppUpdate.mockClear();
- });
-
- it("uses the effective desktop release channel when checking for updates", async () => {
- settingsState.releaseChannel = "beta";
-
- renderUpdaterHook();
-
- await waitFor(() => {
- expect(desktopUpdatesMock.checkDesktopAppUpdate).toHaveBeenCalledWith({
- releaseChannel: "beta",
- });
- });
- });
-
- it("uses the effective desktop release channel when installing updates", async () => {
- settingsState.releaseChannel = "beta";
- const { result } = renderUpdaterHook();
-
- await act(async () => {
- await result.current.installUpdate();
- });
-
- expect(desktopUpdatesMock.installDesktopAppUpdate).toHaveBeenCalledWith({
- releaseChannel: "beta",
- });
- });
-});
diff --git a/packages/app/src/desktop/updates/use-desktop-app-updater.ts b/packages/app/src/desktop/updates/use-desktop-app-updater.ts
index c67f146ee..5522213f1 100644
--- a/packages/app/src/desktop/updates/use-desktop-app-updater.ts
+++ b/packages/app/src/desktop/updates/use-desktop-app-updater.ts
@@ -1,5 +1,4 @@
-import { useCallback, useEffect, useRef, useState } from "react";
-import { useMutation } from "@tanstack/react-query";
+import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
import {
checkDesktopAppUpdate,
formatVersionWithPrefix,
@@ -10,18 +9,14 @@ import {
} from "@/desktop/updates/desktop-updates";
import { useDesktopSettings } from "@/desktop/settings/desktop-settings";
import { useDesktopIpcErrorReporter } from "@/desktop/hooks/desktop-ipc-error";
+import {
+ PENDING_RECHECK_MS,
+ createDesktopAppUpdater,
+ formatStatusText,
+ type DesktopAppUpdateStatus,
+} from "@/desktop/updates/desktop-app-updater";
-export type DesktopAppUpdateStatus =
- | "idle"
- | "checking"
- | "pending"
- | "up-to-date"
- | "available"
- | "installing"
- | "installed"
- | "error";
-
-const PENDING_RECHECK_MS = 10_000;
+export type { DesktopAppUpdateStatus };
export interface UseDesktopAppUpdaterReturn {
isDesktopApp: boolean;
@@ -36,142 +31,57 @@ export interface UseDesktopAppUpdaterReturn {
installUpdate: () => Promise;
}
-function getErrorMessage(error: unknown): string {
- if (error instanceof Error && typeof error.message === "string") {
- return error.message;
- }
- return String(error);
-}
-
-function formatStatusText(input: {
- status: DesktopAppUpdateStatus;
- availableUpdate: DesktopAppUpdateCheckResult | null;
- installMessage: string | null;
-}): string {
- const { status, availableUpdate, installMessage } = input;
-
- if (status === "checking") {
- return "Checking for app updates...";
- }
-
- if (status === "installing") {
- return "Installing app update...";
- }
-
- if (status === "up-to-date") {
- return "App is up to date.";
- }
-
- if (status === "pending") {
- return "We'll let you know when the update is ready.";
- }
-
- if (status === "available") {
- if (availableUpdate?.latestVersion) {
- return `Update ready: ${formatVersionWithPrefix(availableUpdate.latestVersion)}`;
- }
- return "An app update is ready to install.";
- }
-
- if (status === "installed") {
- return installMessage ?? "App update installed. Restart required.";
- }
-
- if (status === "error") {
- return "Failed to update app.";
- }
-
- return "Update status has not been checked yet.";
-}
-
export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
const isDesktopApp = shouldShowDesktopUpdateSection();
const { settings: desktopSettings } = useDesktopSettings();
const releaseChannel = desktopSettings.releaseChannel;
const reportError = useDesktopIpcErrorReporter();
- const requestVersionRef = useRef(0);
- const [status, setStatus] = useState("idle");
- const [availableUpdate, setAvailableUpdate] = useState(null);
- const [errorMessage, setErrorMessage] = useState(null);
- const [installMessage, setInstallMessage] = useState(null);
- const [lastCheckedAt, setLastCheckedAt] = useState(null);
- const { mutateAsync: installAppUpdate, isPending: isInstallingAppUpdate } = useMutation<
- DesktopAppUpdateInstallResult,
- Error
- >({
- mutationFn: () => installDesktopAppUpdate({ releaseChannel }),
- onError: (error) => {
- reportError({
- error,
- message: "Unable to install the desktop app update.",
- logLabel: "[DesktopUpdater] Failed to install app update",
- });
- },
- });
+
+ const updater = useMemo(
+ () =>
+ createDesktopAppUpdater({
+ port: {
+ checkDesktopAppUpdate,
+ installDesktopAppUpdate,
+ },
+ now: () => Date.now(),
+ reportInstallError: reportError,
+ }),
+ [reportError],
+ );
+
+ const snapshot = useSyncExternalStore(
+ updater.subscribe,
+ updater.getSnapshot,
+ updater.getSnapshot,
+ );
const checkForUpdates = useCallback(
async (options: { silent?: boolean } = {}) => {
if (!isDesktopApp) {
return null;
}
-
- const requestVersion = requestVersionRef.current + 1;
- requestVersionRef.current = requestVersion;
-
- if (!options.silent) {
- setStatus("checking");
- }
- setErrorMessage(null);
-
- try {
- const result = await checkDesktopAppUpdate({ releaseChannel });
- if (requestVersion !== requestVersionRef.current) {
- return result;
- }
-
- setInstallMessage(null);
- setLastCheckedAt(Date.now());
-
- if (result.readyToInstall) {
- setAvailableUpdate(result);
- setStatus("available");
- } else if (result.hasUpdate) {
- setAvailableUpdate(null);
- setStatus("pending");
- } else {
- setAvailableUpdate(null);
- setStatus("up-to-date");
- }
-
- return result;
- } catch (error) {
- if (requestVersion !== requestVersionRef.current) {
- return null;
- }
-
- const message = getErrorMessage(error);
- if (options.silent) {
- console.warn("[DesktopUpdater] Silent update check failed", message);
- } else {
- setStatus("error");
- setErrorMessage(message);
- }
- return null;
- }
+ return updater.checkForUpdates({ releaseChannel, silent: options.silent });
},
- [isDesktopApp, releaseChannel],
+ [isDesktopApp, releaseChannel, updater],
);
+ const installUpdate = useCallback(async () => {
+ if (!isDesktopApp) {
+ return null;
+ }
+ return updater.installUpdate({ releaseChannel });
+ }, [isDesktopApp, releaseChannel, updater]);
+
useEffect(() => {
if (!isDesktopApp) {
return;
}
-
void checkForUpdates({ silent: true });
}, [checkForUpdates, isDesktopApp]);
useEffect(() => {
- if (!isDesktopApp || status !== "pending") {
+ if (!isDesktopApp || snapshot.status !== "pending") {
return undefined;
}
@@ -182,52 +92,22 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
return () => {
clearInterval(intervalId);
};
- }, [checkForUpdates, isDesktopApp, status]);
-
- const installUpdate = useCallback(async () => {
- if (!isDesktopApp) {
- return null;
- }
-
- setStatus("installing");
- setErrorMessage(null);
-
- try {
- const result = await installAppUpdate();
- setLastCheckedAt(Date.now());
-
- if (result.installed) {
- setAvailableUpdate(null);
- setInstallMessage(result.message);
- setStatus("installed");
- } else {
- setAvailableUpdate(null);
- setInstallMessage(result.message);
- setStatus("up-to-date");
- }
-
- return result;
- } catch (error) {
- const message = getErrorMessage(error);
- setStatus("error");
- setErrorMessage(message);
- return null;
- }
- }, [installAppUpdate, isDesktopApp]);
+ }, [checkForUpdates, isDesktopApp, snapshot.status]);
return {
isDesktopApp,
- status,
+ status: snapshot.status,
statusText: formatStatusText({
- status,
- availableUpdate,
- installMessage,
+ status: snapshot.status,
+ availableUpdate: snapshot.availableUpdate,
+ installMessage: snapshot.installMessage,
+ formatVersion: formatVersionWithPrefix,
}),
- availableUpdate,
- errorMessage,
- lastCheckedAt,
- isChecking: status === "checking",
- isInstalling: status === "installing" || isInstallingAppUpdate,
+ availableUpdate: snapshot.availableUpdate,
+ errorMessage: snapshot.errorMessage,
+ lastCheckedAt: snapshot.lastCheckedAt,
+ isChecking: snapshot.isChecking,
+ isInstalling: snapshot.isInstalling,
checkForUpdates,
installUpdate,
};
diff --git a/packages/app/src/git/checkout-status-cache.test.ts b/packages/app/src/git/checkout-status-cache.test.ts
new file mode 100644
index 000000000..334d67a4e
--- /dev/null
+++ b/packages/app/src/git/checkout-status-cache.test.ts
@@ -0,0 +1,124 @@
+import { QueryClient } from "@tanstack/react-query";
+import { describe, expect, it, vi } from "vitest";
+import type { CheckoutStatusUpdate } from "@getpaseo/protocol/messages";
+import { checkoutStatusQueryKey } from "@/git/query-keys";
+import {
+ applyCheckoutStatusUpdate,
+ type CheckoutStatusPayload,
+ peekOrFetchCheckoutStatus,
+} from "./checkout-status-cache";
+
+const serverId = "server-1";
+const cwd = "/repo";
+
+function checkoutStatus(overrides: Partial = {}): CheckoutStatusPayload {
+ return {
+ cwd,
+ error: null,
+ requestId: "checkout-status-1",
+ isGit: true,
+ isPaseoOwnedWorktree: false,
+ repoRoot: cwd,
+ currentBranch: "main",
+ isDirty: false,
+ baseRef: "origin/main",
+ aheadBehind: { ahead: 0, behind: 0 },
+ aheadOfOrigin: 0,
+ behindOfOrigin: 0,
+ hasRemote: true,
+ remoteUrl: "git@github.com:getpaseo/paseo.git",
+ ...overrides,
+ } as CheckoutStatusPayload;
+}
+
+function checkoutStatusUpdate(payload: CheckoutStatusPayload): CheckoutStatusUpdate {
+ return { type: "checkout_status_update", payload };
+}
+
+function createQueryClient(): QueryClient {
+ return new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+}
+
+describe("peekOrFetchCheckoutStatus", () => {
+ it("fetches from the client and writes the result to the cache on a cold read", async () => {
+ const queryClient = createQueryClient();
+ const fetched = checkoutStatus({ requestId: "cold-read" });
+ const client = { getCheckoutStatus: vi.fn(async () => fetched) };
+
+ const result = await peekOrFetchCheckoutStatus({ queryClient, client, serverId, cwd });
+
+ expect(result).toEqual(fetched);
+ expect(client.getCheckoutStatus).toHaveBeenCalledExactlyOnceWith(cwd);
+ expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, cwd))).toEqual(fetched);
+ });
+
+ it("returns the cached snapshot without calling the client when the cache already has data", async () => {
+ const queryClient = createQueryClient();
+ const cached = checkoutStatus({ requestId: "cached" });
+ queryClient.setQueryData(checkoutStatusQueryKey(serverId, cwd), cached);
+ const client = {
+ getCheckoutStatus: vi.fn(async () => checkoutStatus({ requestId: "uncached" })),
+ };
+
+ const result = await peekOrFetchCheckoutStatus({ queryClient, client, serverId, cwd });
+
+ expect(result).toEqual(cached);
+ expect(client.getCheckoutStatus).not.toHaveBeenCalled();
+ });
+});
+
+describe("applyCheckoutStatusUpdate", () => {
+ it("writes the pushed payload into the cache key for the matching cwd", () => {
+ const queryClient = createQueryClient();
+ const pushed = checkoutStatus({
+ requestId: "server-push",
+ currentBranch: "pushed-branch",
+ isDirty: true,
+ aheadBehind: { ahead: 2, behind: 1 },
+ aheadOfOrigin: 2,
+ behindOfOrigin: 1,
+ });
+
+ applyCheckoutStatusUpdate({
+ queryClient,
+ serverId,
+ cwd,
+ message: checkoutStatusUpdate(pushed),
+ });
+
+ expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, cwd))).toEqual(pushed);
+ });
+
+ it("ignores updates whose payload cwd does not match the subscribed cwd", () => {
+ const queryClient = createQueryClient();
+ const otherCwd = "/other-repo";
+ const otherCached = checkoutStatus({
+ cwd: otherCwd,
+ repoRoot: otherCwd,
+ requestId: "other-cached",
+ currentBranch: "other-main",
+ });
+ queryClient.setQueryData(checkoutStatusQueryKey(serverId, otherCwd), otherCached);
+
+ applyCheckoutStatusUpdate({
+ queryClient,
+ serverId,
+ cwd,
+ message: checkoutStatusUpdate(
+ checkoutStatus({
+ cwd: otherCwd,
+ repoRoot: otherCwd,
+ requestId: "server-push",
+ currentBranch: "pushed-branch",
+ }),
+ ),
+ });
+
+ expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, cwd))).toBeUndefined();
+ expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, otherCwd))).toEqual(
+ otherCached,
+ );
+ });
+});
diff --git a/packages/app/src/git/checkout-status-cache.ts b/packages/app/src/git/checkout-status-cache.ts
new file mode 100644
index 000000000..471db1e4b
--- /dev/null
+++ b/packages/app/src/git/checkout-status-cache.ts
@@ -0,0 +1,48 @@
+import type { QueryClient } from "@tanstack/react-query";
+import type { CheckoutStatusResponse, CheckoutStatusUpdate } from "@getpaseo/protocol/messages";
+import { checkoutStatusQueryKey } from "@/git/query-keys";
+
+export type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
+
+export interface CheckoutStatusClient {
+ getCheckoutStatus: (cwd: string) => Promise;
+}
+
+export async function peekOrFetchCheckoutStatus({
+ queryClient,
+ client,
+ serverId,
+ cwd,
+}: {
+ queryClient: QueryClient;
+ client: CheckoutStatusClient;
+ serverId: string;
+ cwd: string;
+}): Promise {
+ const queryKey = checkoutStatusQueryKey(serverId, cwd);
+ const cached = queryClient.getQueryData(queryKey);
+ if (cached) {
+ return cached;
+ }
+
+ const snapshot = await client.getCheckoutStatus(cwd);
+ queryClient.setQueryData(queryKey, snapshot);
+ return snapshot;
+}
+
+export function applyCheckoutStatusUpdate({
+ queryClient,
+ serverId,
+ cwd,
+ message,
+}: {
+ queryClient: QueryClient;
+ serverId: string;
+ cwd: string;
+ message: CheckoutStatusUpdate;
+}): void {
+ if (message.payload.cwd !== cwd) {
+ return;
+ }
+ queryClient.setQueryData(checkoutStatusQueryKey(serverId, cwd), message.payload);
+}
diff --git a/packages/app/src/git/use-status-query.test.ts b/packages/app/src/git/use-status-query.test.ts
deleted file mode 100644
index a2f90fa1d..000000000
--- a/packages/app/src/git/use-status-query.test.ts
+++ /dev/null
@@ -1,318 +0,0 @@
-import React, { act } from "react";
-import { createRoot } from "react-dom/client";
-import { JSDOM } from "jsdom";
-import {
- QueryClient,
- QueryClientProvider,
- focusManager,
- onlineManager,
-} from "@tanstack/react-query";
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import type { CheckoutStatusResponse } from "@getpaseo/protocol/messages";
-import { checkoutStatusQueryKey } from "@/git/query-keys";
-import { useCheckoutStatusQuery } from "./use-status-query";
-
-type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
-
-const { mockRuntime, mockClient, checkoutStatusUpdateHandlers } = vi.hoisted(() => {
- const hoistedHandlers = new Set<(message: unknown) => void>();
- const hoistedClient = {
- getCheckoutStatus: vi.fn(),
- on: vi.fn((type: string, handler: (message: unknown) => void) => {
- if (type !== "checkout_status_update") {
- return () => {};
- }
- hoistedHandlers.add(handler);
- return () => {
- hoistedHandlers.delete(handler);
- };
- }),
- };
-
- return {
- mockClient: hoistedClient,
- checkoutStatusUpdateHandlers: hoistedHandlers,
- mockRuntime: {
- client: hoistedClient,
- isConnected: true,
- },
- };
-});
-
-vi.mock("@/runtime/host-runtime", () => ({
- useHostRuntimeClient: () => mockRuntime.client,
- useHostRuntimeIsConnected: () => mockRuntime.isConnected,
-}));
-
-const serverId = "server-1";
-const cwd = "/repo";
-
-function checkoutStatus(overrides: Partial = {}): CheckoutStatusPayload {
- return {
- cwd,
- error: null,
- requestId: "checkout-status-1",
- isGit: true,
- isPaseoOwnedWorktree: false,
- repoRoot: cwd,
- currentBranch: "main",
- isDirty: false,
- baseRef: "origin/main",
- aheadBehind: { ahead: 0, behind: 0 },
- aheadOfOrigin: 0,
- behindOfOrigin: 0,
- hasRemote: true,
- remoteUrl: "git@github.com:getpaseo/paseo.git",
- ...overrides,
- } as CheckoutStatusPayload;
-}
-
-function createTestQueryClient(): QueryClient {
- return new QueryClient({
- defaultOptions: {
- queries: {
- retry: false,
- },
- },
- });
-}
-
-function renderCheckoutStatusHook({
- queryClient = createTestQueryClient(),
- hookCwd = cwd,
-}: {
- queryClient?: QueryClient;
- hookCwd?: string;
-} = {}) {
- let latest: ReturnType | null = null;
-
- function Probe() {
- latest = useCheckoutStatusQuery({ serverId, cwd: hookCwd });
- return null;
- }
-
- const container = document.getElementById("root");
- if (!container) {
- throw new Error("Missing root container");
- }
-
- const root = createRoot(container);
-
- return {
- get latest() {
- if (!latest) {
- throw new Error("Expected hook result");
- }
- return latest;
- },
- async mount() {
- await act(async () => {
- root.render(
- React.createElement(
- QueryClientProvider,
- { client: queryClient },
- React.createElement(Probe),
- ),
- );
- });
- },
- async unmount() {
- await act(async () => {
- root.unmount();
- });
- },
- };
-}
-
-async function waitForExpectation(assertion: () => void): Promise {
- let lastError: unknown;
-
- for (let attempt = 0; attempt < 60; attempt += 1) {
- try {
- assertion();
- return;
- } catch (error) {
- lastError = error;
- await act(async () => {
- vi.advanceTimersByTime(10);
- await Promise.resolve();
- });
- }
- }
-
- throw lastError;
-}
-
-async function emitCheckoutStatusUpdate(payload: CheckoutStatusPayload): Promise {
- await act(async () => {
- for (const handler of checkoutStatusUpdateHandlers) {
- handler({
- type: "checkout_status_update",
- payload,
- });
- }
- await Promise.resolve();
- });
-}
-
-describe("useCheckoutStatusQuery", () => {
- beforeEach(() => {
- vi.useFakeTimers();
-
- const dom = new JSDOM("", {
- url: "http://localhost",
- });
-
- Object.defineProperty(globalThis, "document", {
- value: dom.window.document,
- configurable: true,
- });
- Object.defineProperty(globalThis, "window", {
- value: dom.window,
- configurable: true,
- });
- Object.defineProperty(globalThis, "navigator", {
- value: dom.window.navigator,
- configurable: true,
- });
- Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
- value: true,
- configurable: true,
- });
-
- mockRuntime.client = mockClient;
- mockRuntime.isConnected = true;
- mockClient.getCheckoutStatus.mockReset();
- mockClient.on.mockClear();
- checkoutStatusUpdateHandlers.clear();
- });
-
- afterEach(() => {
- focusManager.setFocused(undefined);
- onlineManager.setOnline(true);
- vi.useRealTimers();
- });
-
- it("does not schedule polling after the cold read", async () => {
- mockClient.getCheckoutStatus.mockResolvedValue(checkoutStatus());
- const hook = renderCheckoutStatusHook();
-
- await hook.mount();
- await waitForExpectation(() => {
- expect(hook.latest.status?.currentBranch).toBe("main");
- });
-
- await act(async () => {
- vi.advanceTimersByTime(60_000);
- });
-
- expect(mockClient.getCheckoutStatus).toHaveBeenCalledTimes(1);
- });
-
- it("returns the cached snapshot without calling the daemon", async () => {
- const queryClient = createTestQueryClient();
- const cached = checkoutStatus({ requestId: "cached" });
- queryClient.setQueryData(checkoutStatusQueryKey(serverId, cwd), cached);
- mockClient.getCheckoutStatus.mockResolvedValue(checkoutStatus({ requestId: "uncached" }));
- const hook = renderCheckoutStatusHook({ queryClient });
-
- await hook.mount();
-
- expect(hook.latest.status).toEqual(cached);
- expect(mockClient.getCheckoutStatus).not.toHaveBeenCalled();
- });
-
- it("fetches once on a cold read and uses cache on remount", async () => {
- const queryClient = createTestQueryClient();
- const fetched = checkoutStatus({ requestId: "cold-read" });
- mockClient.getCheckoutStatus.mockResolvedValue(fetched);
- const firstHook = renderCheckoutStatusHook({ queryClient });
-
- await firstHook.mount();
- await waitForExpectation(() => {
- expect(firstHook.latest.status).toEqual(fetched);
- });
- await firstHook.unmount();
-
- const secondHook = renderCheckoutStatusHook({ queryClient });
- await secondHook.mount();
-
- expect(secondHook.latest.status).toEqual(fetched);
- expect(mockClient.getCheckoutStatus).toHaveBeenCalledTimes(1);
- });
-
- it("does not refetch checkout status on focus or reconnect", async () => {
- mockClient.getCheckoutStatus.mockResolvedValue(checkoutStatus());
- const hook = renderCheckoutStatusHook();
-
- await hook.mount();
- await waitForExpectation(() => {
- expect(hook.latest.status?.currentBranch).toBe("main");
- });
-
- await act(async () => {
- focusManager.setFocused(false);
- focusManager.setFocused(true);
- onlineManager.setOnline(false);
- onlineManager.setOnline(true);
- await Promise.resolve();
- });
-
- expect(mockClient.getCheckoutStatus).toHaveBeenCalledTimes(1);
- });
-
- it("server-pushed checkout status update writes into the React Query cache at checkoutStatusQueryKey(serverId, cwd)", async () => {
- const queryClient = createTestQueryClient();
- mockClient.getCheckoutStatus.mockResolvedValue(checkoutStatus({ requestId: "initial" }));
- const hook = renderCheckoutStatusHook({ queryClient });
-
- await hook.mount();
- await waitForExpectation(() => {
- expect(hook.latest.status?.requestId).toBe("initial");
- });
-
- mockClient.getCheckoutStatus.mockClear();
- const pushed = checkoutStatus({
- requestId: "server-push",
- currentBranch: "pushed-branch",
- isDirty: true,
- aheadBehind: { ahead: 2, behind: 1 },
- aheadOfOrigin: 2,
- behindOfOrigin: 1,
- });
- await emitCheckoutStatusUpdate(pushed);
-
- expect(mockClient.getCheckoutStatus).not.toHaveBeenCalled();
- expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, cwd))).toEqual(pushed);
- });
-
- it("server push only updates the matching cwd's cache key, not other workspaces", async () => {
- const queryClient = createTestQueryClient();
- const otherCwd = "/other-repo";
- const otherCached = checkoutStatus({
- cwd: otherCwd,
- repoRoot: otherCwd,
- requestId: "other-cached",
- currentBranch: "other-main",
- });
- queryClient.setQueryData(checkoutStatusQueryKey(serverId, otherCwd), otherCached);
- mockClient.getCheckoutStatus.mockResolvedValue(checkoutStatus({ requestId: "initial" }));
- const hook = renderCheckoutStatusHook({ queryClient });
-
- await hook.mount();
- await waitForExpectation(() => {
- expect(hook.latest.status?.requestId).toBe("initial");
- });
-
- const pushed = checkoutStatus({
- requestId: "server-push",
- currentBranch: "pushed-branch",
- });
- await emitCheckoutStatusUpdate(pushed);
-
- expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, cwd))).toEqual(pushed);
- expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, otherCwd))).toEqual(
- otherCached,
- );
- });
-});
diff --git a/packages/app/src/git/use-status-query.ts b/packages/app/src/git/use-status-query.ts
index 7f463cc07..115260a51 100644
--- a/packages/app/src/git/use-status-query.ts
+++ b/packages/app/src/git/use-status-query.ts
@@ -1,8 +1,15 @@
-import { type QueryClient, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect } from "react";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
-import type { CheckoutStatusResponse } from "@getpaseo/protocol/messages";
import { checkoutStatusQueryKey } from "@/git/query-keys";
+import {
+ applyCheckoutStatusUpdate,
+ type CheckoutStatusClient,
+ type CheckoutStatusPayload,
+ peekOrFetchCheckoutStatus,
+} from "./checkout-status-cache";
+
+export type { CheckoutStatusPayload } from "./checkout-status-cache";
export const CHECKOUT_STATUS_STALE_TIME = 15_000;
@@ -11,12 +18,6 @@ interface UseCheckoutStatusQueryOptions {
cwd: string;
}
-export type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
-
-interface CheckoutStatusClient {
- getCheckoutStatus: (cwd: string) => Promise;
-}
-
function fetchCheckoutStatus(
client: CheckoutStatusClient,
cwd: string,
@@ -24,28 +25,6 @@ function fetchCheckoutStatus(
return client.getCheckoutStatus(cwd);
}
-async function peekOrFetchSnapshot({
- queryClient,
- client,
- serverId,
- cwd,
-}: {
- queryClient: QueryClient;
- client: CheckoutStatusClient;
- serverId: string;
- cwd: string;
-}): Promise {
- const queryKey = checkoutStatusQueryKey(serverId, cwd);
- const cached = queryClient.getQueryData(queryKey);
- if (cached) {
- return cached;
- }
-
- const snapshot = await fetchCheckoutStatus(client, cwd);
- queryClient.setQueryData(queryKey, snapshot);
- return snapshot;
-}
-
export function useCheckoutStatusQuery({ serverId, cwd }: UseCheckoutStatusQueryOptions) {
const queryClient = useQueryClient();
const client = useHostRuntimeClient(serverId);
@@ -57,10 +36,7 @@ export function useCheckoutStatusQuery({ serverId, cwd }: UseCheckoutStatusQuery
}
return client.on("checkout_status_update", (message) => {
- if (message.payload.cwd !== cwd) {
- return;
- }
- queryClient.setQueryData(checkoutStatusQueryKey(serverId, cwd), message.payload);
+ applyCheckoutStatusUpdate({ queryClient, serverId, cwd, message });
});
}, [client, isConnected, cwd, queryClient, serverId]);
@@ -70,7 +46,7 @@ export function useCheckoutStatusQuery({ serverId, cwd }: UseCheckoutStatusQuery
if (!client) {
throw new Error("Daemon client not available");
}
- return await peekOrFetchSnapshot({ queryClient, client, serverId, cwd });
+ return await peekOrFetchCheckoutStatus({ queryClient, client, serverId, cwd });
},
enabled: !!client && isConnected && !!cwd,
staleTime: Infinity,
diff --git a/packages/app/src/hooks/client-activity-tracker.test.ts b/packages/app/src/hooks/client-activity-tracker.test.ts
new file mode 100644
index 000000000..b4506f569
--- /dev/null
+++ b/packages/app/src/hooks/client-activity-tracker.test.ts
@@ -0,0 +1,241 @@
+import { describe, expect, it } from "vitest";
+import {
+ ACTIVITY_HEARTBEAT_THROTTLE_MS,
+ createClientActivityTracker,
+ type ClientActivityTracker,
+ type ClientActivityTrackerInput,
+ type HeartbeatClient,
+ type HeartbeatPayload,
+} from "./client-activity-tracker";
+
+const START_MS = new Date("2026-04-19T10:00:00.000Z").getTime();
+
+function createTestClock(initial = START_MS) {
+ let t = initial;
+ return {
+ now: () => t,
+ set(ms: number) {
+ t = ms;
+ },
+ advance(ms: number) {
+ t += ms;
+ },
+ };
+}
+
+interface FakeHeartbeatClient extends HeartbeatClient {
+ isConnected: boolean;
+ recordedHeartbeats: HeartbeatPayload[];
+ latest(): HeartbeatPayload;
+ reset(): void;
+}
+
+function createFakeHeartbeatClient(): FakeHeartbeatClient {
+ const recorded: HeartbeatPayload[] = [];
+ return {
+ isConnected: true,
+ recordedHeartbeats: recorded,
+ sendHeartbeat(payload) {
+ recorded.push(payload);
+ },
+ latest() {
+ const last = recorded.at(-1);
+ if (!last) throw new Error("Expected a heartbeat");
+ return last;
+ },
+ reset() {
+ recorded.length = 0;
+ },
+ };
+}
+
+function buildTracker(
+ overrides: Partial & {
+ client?: FakeHeartbeatClient;
+ clock?: ReturnType;
+ } = {},
+): {
+ tracker: ClientActivityTracker;
+ client: FakeHeartbeatClient;
+ clock: ReturnType;
+} {
+ const client = overrides.client ?? createFakeHeartbeatClient();
+ const clock = overrides.clock ?? createTestClock();
+ const tracker = createClientActivityTracker({
+ client,
+ deviceType: overrides.deviceType ?? "web",
+ initialFocusedAgentId: overrides.initialFocusedAgentId ?? "agent-1",
+ initialAppVisible: overrides.initialAppVisible ?? true,
+ now: clock.now,
+ onAppResumed: overrides.onAppResumed,
+ });
+ return { tracker, client, clock };
+}
+
+describe("client activity tracker", () => {
+ it("includes the latest user-activity time in the next heartbeat", () => {
+ const { tracker, client, clock } = buildTracker();
+
+ clock.advance(5_250);
+ tracker.recordUserActivity();
+ tracker.maybeSendImmediateHeartbeat();
+
+ expect(client.latest()).toMatchObject({
+ deviceType: "web",
+ focusedAgentId: "agent-1",
+ appVisible: true,
+ lastActivityAt: new Date(START_MS + 5_250).toISOString(),
+ });
+ });
+
+ it("throttles repeated immediate heartbeats within the throttle window", () => {
+ const { tracker, client, clock } = buildTracker();
+
+ tracker.recordUserActivity();
+ tracker.maybeSendImmediateHeartbeat();
+ expect(client.recordedHeartbeats).toHaveLength(1);
+
+ clock.advance(ACTIVITY_HEARTBEAT_THROTTLE_MS - 1);
+ tracker.recordUserActivity();
+ tracker.maybeSendImmediateHeartbeat();
+ expect(client.recordedHeartbeats).toHaveLength(1);
+
+ clock.advance(1);
+ tracker.recordUserActivity();
+ tracker.maybeSendImmediateHeartbeat();
+ expect(client.recordedHeartbeats).toHaveLength(2);
+ });
+
+ it("sends one immediate heartbeat when the focused agent changes", () => {
+ const { tracker, client, clock } = buildTracker();
+ tracker.sendHeartbeat(); // simulate the on-connect heartbeat
+ expect(client.recordedHeartbeats).toHaveLength(1);
+ client.reset();
+
+ clock.advance(5_000);
+ tracker.setFocusedAgentId("agent-2");
+
+ expect(client.recordedHeartbeats).toHaveLength(1);
+ expect(client.latest()).toMatchObject({
+ focusedAgentId: "agent-2",
+ lastActivityAt: new Date(START_MS + 5_000).toISOString(),
+ });
+ });
+
+ it("ignores focused-agent updates that do not change the value", () => {
+ const { tracker, client } = buildTracker({ initialFocusedAgentId: "agent-1" });
+
+ tracker.setFocusedAgentId("agent-1");
+
+ expect(client.recordedHeartbeats).toHaveLength(0);
+ });
+
+ it("drives lastActivityAt forward from system idle polling", () => {
+ const { tracker, client, clock } = buildTracker();
+
+ clock.advance(5_000);
+ tracker.notifySystemIdleMs(0);
+ clock.advance(5_000);
+ tracker.notifySystemIdleMs(0);
+ clock.advance(5_000);
+ tracker.notifySystemIdleMs(0);
+ tracker.sendHeartbeat();
+
+ expect(client.latest().lastActivityAt).toBe(new Date(START_MS + 15_000).toISOString());
+ });
+
+ it("sets lastActivityAt to now minus the system idle time", () => {
+ const { tracker, client, clock } = buildTracker();
+
+ clock.advance(15_000);
+ tracker.notifySystemIdleMs(2_000);
+ tracker.sendHeartbeat();
+
+ expect(client.latest().lastActivityAt).toBe(new Date(START_MS + 13_000).toISOString());
+ });
+
+ it("ignores failed system idle polls", () => {
+ const { tracker, client, clock } = buildTracker();
+ tracker.sendHeartbeat();
+ const before = client.latest().lastActivityAt;
+
+ clock.advance(15_000);
+ tracker.notifySystemIdleMs(null);
+ tracker.sendHeartbeat();
+
+ expect(client.latest().lastActivityAt).toBe(before);
+ });
+
+ it("never moves lastActivityAt backward from a system idle poll", () => {
+ const { tracker, client, clock } = buildTracker();
+
+ clock.advance(5_000);
+ tracker.recordUserActivity();
+ const userActivityAt = new Date(clock.now()).toISOString();
+
+ clock.advance(10_000);
+ tracker.notifySystemIdleMs(20_000); // would imply activity 5_000 ms before start
+ tracker.sendHeartbeat();
+
+ expect(client.latest().lastActivityAt).toBe(userActivityAt);
+ });
+
+ it("emits an appVisibilityChangedAt and runs onAppResumed when becoming visible after backgrounding", () => {
+ let resumed: number | null = null;
+ const { tracker, client, clock } = buildTracker({
+ initialAppVisible: true,
+ onAppResumed: (awayMs) => {
+ resumed = awayMs;
+ },
+ });
+
+ clock.advance(2_000);
+ const hideTransition = tracker.notifyAppVisibility(false);
+ expect(hideTransition.changed).toBe(true);
+
+ clock.advance(8_000);
+ const showTransition = tracker.notifyAppVisibility(true);
+ expect(showTransition.changed).toBe(true);
+ expect(resumed).toBe(8_000);
+
+ tracker.sendHeartbeat();
+ expect(client.latest()).toMatchObject({
+ appVisible: true,
+ appVisibilityChangedAt: new Date(START_MS + 10_000).toISOString(),
+ lastActivityAt: new Date(START_MS + 10_000).toISOString(),
+ });
+ });
+
+ it("treats no-op visibility transitions as no change", () => {
+ const { tracker } = buildTracker({ initialAppVisible: true });
+ const result = tracker.notifyAppVisibility(true);
+ expect(result.changed).toBe(false);
+ });
+
+ it("does not call onAppResumed when transitioning visible→visible or from initial-visible to hidden", () => {
+ let resumed: number | null = null;
+ const { tracker } = buildTracker({
+ initialAppVisible: true,
+ onAppResumed: (awayMs) => {
+ resumed = awayMs;
+ },
+ });
+
+ tracker.notifyAppVisibility(false);
+ expect(resumed).toBeNull();
+ });
+
+ it("skips heartbeats while the client is disconnected", () => {
+ const client = createFakeHeartbeatClient();
+ client.isConnected = false;
+ const { tracker, clock } = buildTracker({ client });
+
+ tracker.sendHeartbeat();
+ tracker.recordUserActivity();
+ tracker.maybeSendImmediateHeartbeat();
+ clock.advance(10_000);
+ tracker.setFocusedAgentId("agent-2");
+
+ expect(client.recordedHeartbeats).toHaveLength(0);
+ });
+});
diff --git a/packages/app/src/hooks/client-activity-tracker.ts b/packages/app/src/hooks/client-activity-tracker.ts
new file mode 100644
index 000000000..594a75486
--- /dev/null
+++ b/packages/app/src/hooks/client-activity-tracker.ts
@@ -0,0 +1,104 @@
+export const HEARTBEAT_INTERVAL_MS = 15_000;
+export const ACTIVITY_HEARTBEAT_THROTTLE_MS = 5_000;
+export const DESKTOP_IDLE_POLL_INTERVAL_MS = 5_000;
+
+export interface HeartbeatPayload {
+ deviceType: "web" | "mobile";
+ focusedAgentId: string | null;
+ lastActivityAt: string;
+ appVisible: boolean;
+ appVisibilityChangedAt?: string;
+}
+
+export interface HeartbeatClient {
+ readonly isConnected: boolean;
+ sendHeartbeat(payload: HeartbeatPayload): void;
+}
+
+export interface ClientActivityTrackerInput {
+ client: HeartbeatClient;
+ deviceType: "web" | "mobile";
+ initialFocusedAgentId: string | null;
+ initialAppVisible: boolean;
+ now: () => number;
+ onAppResumed?: (awayMs: number) => void;
+}
+
+export interface ClientActivityTracker {
+ recordUserActivity(): void;
+ maybeSendImmediateHeartbeat(): void;
+ setFocusedAgentId(id: string | null): void;
+ notifyAppVisibility(visible: boolean): { changed: boolean };
+ notifySystemIdleMs(idleMs: number | null): void;
+ sendHeartbeat(): void;
+}
+
+export function createClientActivityTracker(
+ input: ClientActivityTrackerInput,
+): ClientActivityTracker {
+ const { client, deviceType, now, onAppResumed } = input;
+ let lastActivityAtMs = now();
+ let appVisible = input.initialAppVisible;
+ let appVisibilityChangedAtMs = now();
+ let backgroundedAtMs: number | null = appVisible ? null : now();
+ let focusedAgentId = input.initialFocusedAgentId;
+ let lastImmediateHeartbeatAtMs = 0;
+
+ function sendHeartbeat(): void {
+ if (!client.isConnected) return;
+ client.sendHeartbeat({
+ deviceType,
+ focusedAgentId,
+ lastActivityAt: new Date(lastActivityAtMs).toISOString(),
+ appVisible,
+ appVisibilityChangedAt: new Date(appVisibilityChangedAtMs).toISOString(),
+ });
+ }
+
+ function recordUserActivity(): void {
+ lastActivityAtMs = now();
+ }
+
+ function maybeSendImmediateHeartbeat(): void {
+ if (!client.isConnected) return;
+ const t = now();
+ if (t - lastImmediateHeartbeatAtMs < ACTIVITY_HEARTBEAT_THROTTLE_MS) return;
+ lastImmediateHeartbeatAtMs = t;
+ sendHeartbeat();
+ }
+
+ return {
+ recordUserActivity,
+ maybeSendImmediateHeartbeat,
+ setFocusedAgentId(id) {
+ if (focusedAgentId === id) return;
+ focusedAgentId = id;
+ recordUserActivity();
+ sendHeartbeat();
+ },
+ notifyAppVisibility(nextVisible) {
+ if (appVisible === nextVisible) return { changed: false };
+ appVisible = nextVisible;
+ appVisibilityChangedAtMs = now();
+ if (!nextVisible) {
+ backgroundedAtMs = now();
+ return { changed: true };
+ }
+ const at = backgroundedAtMs;
+ backgroundedAtMs = null;
+ if (at !== null) {
+ onAppResumed?.(Math.max(0, now() - at));
+ }
+ recordUserActivity();
+ return { changed: true };
+ },
+ notifySystemIdleMs(idleMs) {
+ if (idleMs === null) return;
+ const systemLastActivityAtMs = now() - idleMs;
+ if (systemLastActivityAtMs > lastActivityAtMs) {
+ lastActivityAtMs = systemLastActivityAtMs;
+ }
+ },
+ sendHeartbeat,
+ };
+}
diff --git a/packages/app/src/hooks/hardware-keyboard-submit-controller.test.ts b/packages/app/src/hooks/hardware-keyboard-submit-controller.test.ts
new file mode 100644
index 000000000..a7acd8ccc
--- /dev/null
+++ b/packages/app/src/hooks/hardware-keyboard-submit-controller.test.ts
@@ -0,0 +1,133 @@
+import { describe, expect, it } from "vitest";
+import {
+ createHardwareKeyboardSubmitController,
+ type HardwareKeyboardSubmitListenerPort,
+} from "./hardware-keyboard-submit-controller";
+
+interface FakeKeyboard extends HardwareKeyboardSubmitListenerPort {
+ emit(): void;
+ readonly isEnabled: boolean;
+ readonly listenerCount: number;
+}
+
+function createFakeKeyboard(): FakeKeyboard {
+ const handlers = new Set<() => void>();
+ let enabled = false;
+ return {
+ addListener(handler) {
+ handlers.add(handler);
+ return { remove: () => handlers.delete(handler) };
+ },
+ setEnabled(value) {
+ enabled = value;
+ },
+ emit() {
+ handlers.forEach((handler) => handler());
+ },
+ get isEnabled() {
+ return enabled;
+ },
+ get listenerCount() {
+ return handlers.size;
+ },
+ };
+}
+
+describe("hardware-keyboard-submit-controller", () => {
+ it("dispatches to onSubmit when the keyboard emits while enabled", () => {
+ const keyboard = createFakeKeyboard();
+ const controller = createHardwareKeyboardSubmitController(keyboard);
+ let calls = 0;
+ controller.setOnSubmit(() => {
+ calls += 1;
+ });
+
+ controller.enable();
+ keyboard.emit();
+
+ expect(calls).toBe(1);
+ expect(keyboard.isEnabled).toBe(true);
+ });
+
+ it("does not subscribe or enable when never enabled", () => {
+ const keyboard = createFakeKeyboard();
+ const controller = createHardwareKeyboardSubmitController(keyboard);
+ let calls = 0;
+ controller.setOnSubmit(() => {
+ calls += 1;
+ });
+
+ keyboard.emit();
+
+ expect(calls).toBe(0);
+ expect(keyboard.listenerCount).toBe(0);
+ expect(keyboard.isEnabled).toBe(false);
+ });
+
+ it("disables native hardware submit and unsubscribes on disable", () => {
+ const keyboard = createFakeKeyboard();
+ const controller = createHardwareKeyboardSubmitController(keyboard);
+ controller.setOnSubmit(() => {});
+
+ controller.enable();
+ expect(keyboard.isEnabled).toBe(true);
+ expect(keyboard.listenerCount).toBe(1);
+
+ controller.disable();
+ expect(keyboard.isEnabled).toBe(false);
+ expect(keyboard.listenerCount).toBe(0);
+ });
+
+ it("dispatches the latest onSubmit handler", () => {
+ const keyboard = createFakeKeyboard();
+ const controller = createHardwareKeyboardSubmitController(keyboard);
+ const received: string[] = [];
+ controller.setOnSubmit(() => received.push("first"));
+
+ controller.enable();
+ controller.setOnSubmit(() => received.push("second"));
+ keyboard.emit();
+
+ expect(received).toEqual(["second"]);
+ });
+
+ it("does not dispatch after disable", () => {
+ const keyboard = createFakeKeyboard();
+ const controller = createHardwareKeyboardSubmitController(keyboard);
+ let calls = 0;
+ controller.setOnSubmit(() => {
+ calls += 1;
+ });
+
+ controller.enable();
+ controller.disable();
+ keyboard.emit();
+
+ expect(calls).toBe(0);
+ });
+
+ it("ignores repeated enable calls", () => {
+ const keyboard = createFakeKeyboard();
+ const controller = createHardwareKeyboardSubmitController(keyboard);
+ let calls = 0;
+ controller.setOnSubmit(() => {
+ calls += 1;
+ });
+
+ controller.enable();
+ controller.enable();
+ keyboard.emit();
+
+ expect(calls).toBe(1);
+ expect(keyboard.listenerCount).toBe(1);
+ });
+
+ it("ignores disable without a prior enable", () => {
+ const keyboard = createFakeKeyboard();
+ const controller = createHardwareKeyboardSubmitController(keyboard);
+
+ controller.disable();
+
+ expect(keyboard.isEnabled).toBe(false);
+ });
+});
diff --git a/packages/app/src/hooks/hardware-keyboard-submit-controller.ts b/packages/app/src/hooks/hardware-keyboard-submit-controller.ts
new file mode 100644
index 000000000..0854f858b
--- /dev/null
+++ b/packages/app/src/hooks/hardware-keyboard-submit-controller.ts
@@ -0,0 +1,34 @@
+export interface HardwareKeyboardSubmitListenerPort {
+ addListener(handler: () => void): { remove: () => void };
+ setEnabled(enabled: boolean): void;
+}
+
+export interface HardwareKeyboardSubmitController {
+ setOnSubmit(handler: () => void): void;
+ enable(): void;
+ disable(): void;
+}
+
+export function createHardwareKeyboardSubmitController(
+ port: HardwareKeyboardSubmitListenerPort,
+): HardwareKeyboardSubmitController {
+ let subscription: { remove: () => void } | null = null;
+ let onSubmit: () => void = () => {};
+
+ return {
+ setOnSubmit(handler) {
+ onSubmit = handler;
+ },
+ enable() {
+ if (subscription) return;
+ subscription = port.addListener(() => onSubmit());
+ port.setEnabled(true);
+ },
+ disable() {
+ if (!subscription) return;
+ port.setEnabled(false);
+ subscription.remove();
+ subscription = null;
+ },
+ };
+}
diff --git a/packages/app/src/hooks/hover-safe-zone-tracker.test.ts b/packages/app/src/hooks/hover-safe-zone-tracker.test.ts
new file mode 100644
index 000000000..b4fdc3df0
--- /dev/null
+++ b/packages/app/src/hooks/hover-safe-zone-tracker.test.ts
@@ -0,0 +1,108 @@
+import { describe, expect, it } from "vitest";
+import { createHoverSafeZoneTracker, type RectLike } from "@/hooks/hover-safe-zone-tracker";
+
+const TRIGGER: RectLike = { left: 0, right: 100, top: 20, bottom: 60 };
+const CONTENT: RectLike = { left: 120, right: 240, top: 20, bottom: 120 };
+
+interface TrackerHandle {
+ pointerMoved(x: number, y: number): void;
+ pointerLeftWindow(): void;
+ windowBlurred(): void;
+ readonly enters: number;
+ readonly leaves: number;
+}
+
+function createHandle(
+ rects: { trigger: RectLike | null; content: RectLike | null } = {
+ trigger: TRIGGER,
+ content: CONTENT,
+ },
+): TrackerHandle {
+ let enters = 0;
+ let leaves = 0;
+ const tracker = createHoverSafeZoneTracker({
+ getTriggerRect: () => rects.trigger,
+ getContentRect: () => rects.content,
+ onEnterSafeZone: () => {
+ enters += 1;
+ },
+ onLeaveSafeZone: () => {
+ leaves += 1;
+ },
+ });
+ return {
+ pointerMoved: tracker.pointerMoved,
+ pointerLeftWindow: tracker.pointerLeftWindow,
+ windowBlurred: tracker.windowBlurred,
+ get enters() {
+ return enters;
+ },
+ get leaves() {
+ return leaves;
+ },
+ };
+}
+
+describe("hover safe-zone tracker", () => {
+ it("tracks transitions across trigger, bridge, content, and outside space", () => {
+ const handle = createHandle();
+
+ // Bridge between trigger and content.
+ handle.pointerMoved(110, 40);
+ expect(handle.enters).toBe(1);
+ expect(handle.leaves).toBe(0);
+
+ // Outside everything — fires leave once.
+ handle.pointerMoved(300, 40);
+ expect(handle.leaves).toBe(1);
+
+ // Back into the bridge — fires enter again.
+ handle.pointerMoved(130, 40);
+ expect(handle.enters).toBe(2);
+ });
+
+ it("refreshes the safe-zone enter callback while moving inside", () => {
+ const handle = createHandle();
+
+ handle.pointerMoved(110, 40);
+ handle.pointerMoved(130, 40);
+
+ expect(handle.enters).toBe(2);
+ expect(handle.leaves).toBe(0);
+ });
+
+ it("treats leaving the browser window as leaving the safe zone", () => {
+ const handle = createHandle();
+
+ handle.pointerLeftWindow();
+ expect(handle.leaves).toBe(1);
+
+ // Already outside — blur does not fire a second leave.
+ handle.windowBlurred();
+ expect(handle.leaves).toBe(1);
+ });
+
+ it("falls back to trigger-or-content membership when a rect is missing", () => {
+ const handle = createHandle({ trigger: TRIGGER, content: null });
+
+ // Inside the trigger.
+ handle.pointerMoved(50, 40);
+ expect(handle.enters).toBe(1);
+
+ // Inside the (now-missing) bridge — counts as outside.
+ handle.pointerMoved(110, 40);
+ expect(handle.leaves).toBe(1);
+ });
+
+ it("treats overlapping trigger and content as having no bridge", () => {
+ const handle = createHandle({
+ trigger: { left: 0, right: 200, top: 0, bottom: 50 },
+ content: { left: 100, right: 300, top: 60, bottom: 100 },
+ });
+
+ // Outside both rects, in what would be a bridge — should be outside.
+ handle.pointerMoved(150, 55);
+ expect(handle.enters).toBe(0);
+ expect(handle.leaves).toBe(1);
+ });
+});
diff --git a/packages/app/src/hooks/hover-safe-zone-tracker.ts b/packages/app/src/hooks/hover-safe-zone-tracker.ts
new file mode 100644
index 000000000..d097413c5
--- /dev/null
+++ b/packages/app/src/hooks/hover-safe-zone-tracker.ts
@@ -0,0 +1,74 @@
+export interface RectLike {
+ left: number;
+ right: number;
+ top: number;
+ bottom: number;
+}
+
+export interface HoverSafeZoneTrackerInput {
+ getTriggerRect: () => RectLike | null;
+ getContentRect: () => RectLike | null;
+ onEnterSafeZone: () => void;
+ onLeaveSafeZone: () => void;
+}
+
+export interface HoverSafeZoneTracker {
+ pointerMoved(x: number, y: number): void;
+ pointerLeftWindow(): void;
+ windowBlurred(): void;
+}
+
+// Tracks the pointer's position relative to a hover card's "safe zone": the
+// trigger, the content, and the rectangular bridge between them. The bridge
+// lets the pointer cross the visual gap without dropping the hover. Fires
+// `onEnterSafeZone` on every move that lands inside (so consumers can refresh
+// timers) and `onLeaveSafeZone` once per inside→outside transition.
+export function createHoverSafeZoneTracker(input: HoverSafeZoneTrackerInput): HoverSafeZoneTracker {
+ const { getTriggerRect, getContentRect, onEnterSafeZone, onLeaveSafeZone } = input;
+ // The pointer opened the card, so we start inside.
+ let wasInside = true;
+
+ function leave(): void {
+ if (!wasInside) return;
+ wasInside = false;
+ onLeaveSafeZone();
+ }
+
+ return {
+ pointerMoved(x, y) {
+ if (isInsideSafeZone(getTriggerRect(), getContentRect(), x, y)) {
+ wasInside = true;
+ onEnterSafeZone();
+ return;
+ }
+ leave();
+ },
+ pointerLeftWindow: leave,
+ windowBlurred: leave,
+ };
+}
+
+function isInsideRect(rect: RectLike | null, x: number, y: number): boolean {
+ if (!rect) return false;
+ return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
+}
+
+function isInsideSafeZone(
+ trigger: RectLike | null,
+ content: RectLike | null,
+ x: number,
+ y: number,
+): boolean {
+ if (isInsideRect(trigger, x, y)) return true;
+ if (isInsideRect(content, x, y)) return true;
+ if (!trigger || !content) return false;
+
+ // Bridge: the horizontal strip connecting trigger and content, stretched
+ // vertically to span both. If they overlap horizontally there's no bridge.
+ const bridgeLeft = Math.min(trigger.right, content.right);
+ const bridgeRight = Math.max(trigger.left, content.left);
+ if (bridgeLeft >= bridgeRight) return false;
+ const bridgeTop = Math.min(trigger.top, content.top);
+ const bridgeBottom = Math.max(trigger.bottom, content.bottom);
+ return x >= bridgeLeft && x <= bridgeRight && y >= bridgeTop && y <= bridgeBottom;
+}
diff --git a/packages/app/src/hooks/image-attachment-picker.native.test.ts b/packages/app/src/hooks/image-attachment-picker.native.test.ts
deleted file mode 100644
index fd1fa52a1..000000000
--- a/packages/app/src/hooks/image-attachment-picker.native.test.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-
-vi.mock("expo-image-manipulator", () => ({
- SaveFormat: { PNG: "png" },
- ImageManipulator: {
- manipulate: (_source: string) => ({
- async renderAsync() {
- return {
- release() {},
- async saveAsync(options: { format?: string }) {
- return {
- uri:
- options.format === "png"
- ? "file:///cache/ImageManipulator/safe-picked.png"
- : "file:///cache/ImageManipulator/unsafe-picked.jpg",
- width: 100,
- height: 100,
- };
- },
- };
- },
- release() {},
- }),
- },
-}));
-
-import { normalizePickedImageAssets } from "./image-attachment-picker.native";
-
-describe("native image attachment picker", () => {
- it("preserves native picked JPEG and PNG attachment inputs", async () => {
- const result = await normalizePickedImageAssets([
- {
- uri: "file:///photos/IMG_0001.JPG",
- mimeType: "image/jpeg",
- fileName: "picked.jpeg",
- },
- {
- uri: "file:///photos/screenshot.png",
- mimeType: "image/png",
- fileName: "screenshot.png",
- },
- ]);
-
- expect(result).toEqual([
- {
- source: { kind: "file_uri", uri: "file:///photos/IMG_0001.JPG" },
- mimeType: "image/jpeg",
- fileName: "picked.jpg",
- },
- {
- source: { kind: "file_uri", uri: "file:///photos/screenshot.png" },
- mimeType: "image/png",
- fileName: "screenshot.png",
- },
- ]);
- });
-
- it("turns a native picked HEIC-like asset into a PNG attachment input", async () => {
- const result = await normalizePickedImageAssets([
- {
- uri: "file:///photos/IMG_0001.HEIC",
- mimeType: "image/png",
- fileName: "picked.png",
- },
- ]);
-
- expect(result).toEqual([
- {
- source: { kind: "file_uri", uri: "file:///cache/ImageManipulator/safe-picked.png" },
- mimeType: "image/png",
- fileName: "picked.png",
- },
- ]);
- });
-});
diff --git a/packages/app/src/hooks/image-attachment-picker.native.ts b/packages/app/src/hooks/image-attachment-picker.native.ts
index 23d9bb351..dfb36c575 100644
--- a/packages/app/src/hooks/image-attachment-picker.native.ts
+++ b/packages/app/src/hooks/image-attachment-picker.native.ts
@@ -1,87 +1,16 @@
import { ImageManipulator, SaveFormat } from "expo-image-manipulator";
+import {
+ normalizePickedImageAssetsWith,
+ type ExpoImagePickerAssetLike,
+ type PickedImageAttachmentInput,
+} from "./picked-image-normalizer";
-export type PickedImageSource = { kind: "file_uri"; uri: string } | { kind: "blob"; blob: Blob };
-
-export interface PickedImageAttachmentInput {
- source: PickedImageSource;
- mimeType?: string | null;
- fileName?: string | null;
-}
-
-export interface ExpoImagePickerAssetLike {
- uri: string;
- mimeType?: string | null;
- fileName?: string | null;
- file?: File | null;
-}
-
-interface SupportedPickedImageFormat {
- mimeType: "image/jpeg" | "image/png";
- extension: "jpg" | "png";
-}
-
-const JPEG_FORMAT: SupportedPickedImageFormat = {
- mimeType: "image/jpeg",
- extension: "jpg",
-};
-
-const PNG_FORMAT: SupportedPickedImageFormat = {
- mimeType: "image/png",
- extension: "png",
-};
-
-function extensionFromPath(path: string | null | undefined): string | null {
- const match = path?.match(/\.([a-z0-9]+)(?:[?#].*)?$/i);
- return match?.[1]?.toLowerCase() ?? null;
-}
-
-function supportedFormatForExtension(extension: string | null): SupportedPickedImageFormat | null {
- if (extension === "jpg" || extension === "jpeg") {
- return JPEG_FORMAT;
- }
- if (extension === "png") {
- return PNG_FORMAT;
- }
- return null;
-}
-
-function supportedFormatForMimeType(
- mimeType: string | null | undefined,
-): SupportedPickedImageFormat | null {
- const normalizedMimeType = mimeType?.toLowerCase();
- if (normalizedMimeType === "image/jpeg" || normalizedMimeType === "image/jpg") {
- return JPEG_FORMAT;
- }
- if (normalizedMimeType === "image/png") {
- return PNG_FORMAT;
- }
- return null;
-}
-
-function pickedAssetSupportedFormat(
- asset: ExpoImagePickerAssetLike,
-): SupportedPickedImageFormat | null {
- const uriExtension = extensionFromPath(asset.uri);
- if (uriExtension) {
- return supportedFormatForExtension(uriExtension);
- }
-
- return (
- supportedFormatForExtension(extensionFromPath(asset.fileName)) ??
- supportedFormatForMimeType(asset.mimeType)
- );
-}
-
-function replaceFileExtension(
- fileName: string | null | undefined,
- extension: SupportedPickedImageFormat["extension"],
-): string | null {
- if (!fileName) {
- return null;
- }
-
- return fileName.replace(/\.[^./\\]+$/, "") + `.${extension}`;
-}
+export type {
+ ExportPickedImageAsPng,
+ ExpoImagePickerAssetLike,
+ PickedImageAttachmentInput,
+ PickedImageSource,
+} from "./picked-image-normalizer";
async function exportPickedImageAsPng(uri: string): Promise {
const context = ImageManipulator.manipulate(uri);
@@ -102,28 +31,9 @@ async function exportPickedImageAsPng(uri: string): Promise {
export async function normalizePickedImageAssets(
assets: readonly ExpoImagePickerAssetLike[],
): Promise {
- return await Promise.all(
- assets.map(async (asset) => {
- const supportedFormat = pickedAssetSupportedFormat(asset);
- if (supportedFormat) {
- return {
- source: { kind: "file_uri", uri: asset.uri },
- mimeType: supportedFormat.mimeType,
- fileName: replaceFileExtension(asset.fileName, supportedFormat.extension),
- };
- }
-
- const convertedUri = await exportPickedImageAsPng(asset.uri);
-
- return {
- source: { kind: "file_uri", uri: convertedUri },
- mimeType: PNG_FORMAT.mimeType,
- fileName: replaceFileExtension(asset.fileName, PNG_FORMAT.extension),
- };
- }),
- );
+ return normalizePickedImageAssetsWith(assets, exportPickedImageAsPng);
}
-export async function openImagePathsWithDesktopDialog(): Promise {
+export async function openImagePathsWithDesktopDialog(_dialog?: unknown): Promise {
throw new Error("Desktop dialog API is not available on native.");
}
diff --git a/packages/app/src/hooks/image-attachment-picker.test.ts b/packages/app/src/hooks/image-attachment-picker.test.ts
index 3ae194dc0..821312db2 100644
--- a/packages/app/src/hooks/image-attachment-picker.test.ts
+++ b/packages/app/src/hooks/image-attachment-picker.test.ts
@@ -1,23 +1,29 @@
-import { describe, expect, it, vi, beforeEach } from "vitest";
-
-const desktopHostState = vi.hoisted(() => ({
- api: null as unknown,
-}));
-
-vi.mock("@/desktop/host", () => ({
- getDesktopHost: () => desktopHostState.api,
-}));
-
+import { describe, expect, it } from "vitest";
+import type { DesktopDialogBridge, DesktopDialogOpenOptions } from "@/desktop/host";
import {
normalizePickedImageAssets,
openImagePathsWithDesktopDialog,
} from "./image-attachment-picker";
-describe("image-attachment-picker", () => {
- beforeEach(() => {
- desktopHostState.api = null;
- });
+function fakeDialogReturning(selection: string | string[] | null): {
+ dialog: DesktopDialogBridge;
+ recordedOptions: DesktopDialogOpenOptions[];
+} {
+ const recordedOptions: DesktopDialogOpenOptions[] = [];
+ return {
+ dialog: {
+ open: async (options?: DesktopDialogOpenOptions) => {
+ if (options) {
+ recordedOptions.push(options);
+ }
+ return selection;
+ },
+ },
+ recordedOptions,
+ };
+}
+describe("image-attachment-picker", () => {
it("normalizes a picked File into a blob source", async () => {
const file = new File(["hello"], "picked.png", { type: "image/png" });
@@ -70,27 +76,24 @@ describe("image-attachment-picker", () => {
});
it("uses the desktop dialog api when available", async () => {
- const open = vi.fn().mockResolvedValue(["/tmp/one.png", "/tmp/two.jpg"]);
- desktopHostState.api = {
- dialog: { open },
- };
+ const { dialog, recordedOptions } = fakeDialogReturning(["/tmp/one.png", "/tmp/two.jpg"]);
- const result = await openImagePathsWithDesktopDialog();
+ const result = await openImagePathsWithDesktopDialog(dialog);
- expect(open).toHaveBeenCalledWith(
- expect.objectContaining({
- multiple: true,
- directory: false,
- title: "Attach images",
- }),
- );
+ expect(recordedOptions).toHaveLength(1);
+ expect(recordedOptions[0]).toMatchObject({
+ multiple: true,
+ directory: false,
+ title: "Attach images",
+ });
expect(result).toEqual(["/tmp/one.png", "/tmp/two.jpg"]);
});
it("throws when desktop dialog API is not available", async () => {
- desktopHostState.api = {};
-
- await expect(openImagePathsWithDesktopDialog()).rejects.toThrow(
+ await expect(openImagePathsWithDesktopDialog(null)).rejects.toThrow(
+ "Desktop dialog API is not available.",
+ );
+ await expect(openImagePathsWithDesktopDialog({})).rejects.toThrow(
"Desktop dialog API is not available.",
);
});
diff --git a/packages/app/src/hooks/image-attachment-picker.ts b/packages/app/src/hooks/image-attachment-picker.ts
index e11d3111c..b3478ae54 100644
--- a/packages/app/src/hooks/image-attachment-picker.ts
+++ b/packages/app/src/hooks/image-attachment-picker.ts
@@ -1,4 +1,4 @@
-import { getDesktopHost } from "@/desktop/host";
+import type { DesktopDialogBridge } from "@/desktop/host";
import { isAbsolutePath } from "@/utils/path";
export type PickedImageSource = { kind: "file_uri"; uri: string } | { kind: "blob"; blob: Blob };
@@ -79,8 +79,9 @@ function normalizeDesktopDialogSelection(selection: string | string[] | null): s
return Array.isArray(selection) ? selection : [selection];
}
-export async function openImagePathsWithDesktopDialog(): Promise {
- const desktop = getDesktopHost();
+export async function openImagePathsWithDesktopDialog(
+ dialog: DesktopDialogBridge | null | undefined,
+): Promise {
const options = {
directory: false,
multiple: true,
@@ -88,7 +89,7 @@ export async function openImagePathsWithDesktopDialog(): Promise {
title: "Attach images",
};
- const dialogOpen = desktop?.dialog?.open;
+ const dialogOpen = dialog?.open;
if (typeof dialogOpen !== "function") {
throw new Error("Desktop dialog API is not available.");
}
diff --git a/packages/app/src/hooks/open-project.ts b/packages/app/src/hooks/open-project.ts
new file mode 100644
index 000000000..bc0dbd585
--- /dev/null
+++ b/packages/app/src/hooks/open-project.ts
@@ -0,0 +1,43 @@
+import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
+import { normalizeWorkspaceDescriptor, type WorkspaceDescriptor } from "@/stores/session-store";
+import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store";
+
+export interface OpenProjectDirectlyInput {
+ serverId: string;
+ projectPath: string;
+ isConnected: boolean;
+ client: Pick | null;
+ mergeWorkspaces: (serverId: string, workspaces: Iterable) => void;
+ setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void;
+ openDraftTab: (workspaceKey: string) => string | null;
+ navigateToWorkspace: (serverId: string, workspaceId: string) => void;
+}
+
+export async function openProjectDirectly(input: OpenProjectDirectlyInput): Promise {
+ const normalizedServerId = input.serverId.trim();
+ const trimmedPath = input.projectPath.trim();
+ if (!normalizedServerId || !trimmedPath || !input.client || !input.isConnected) {
+ return false;
+ }
+
+ const payload = await input.client.openProject(trimmedPath);
+ if (payload.error || !payload.workspace) {
+ return false;
+ }
+
+ const workspace = normalizeWorkspaceDescriptor(payload.workspace);
+ input.mergeWorkspaces(normalizedServerId, [workspace]);
+ input.setHasHydratedWorkspaces(normalizedServerId, true);
+
+ const workspaceKey = buildWorkspaceTabPersistenceKey({
+ serverId: normalizedServerId,
+ workspaceId: workspace.id,
+ });
+ if (!workspaceKey) {
+ return false;
+ }
+
+ input.openDraftTab(workspaceKey);
+ input.navigateToWorkspace(normalizedServerId, workspace.id);
+ return true;
+}
diff --git a/packages/app/src/hooks/picked-image-normalizer.test.ts b/packages/app/src/hooks/picked-image-normalizer.test.ts
new file mode 100644
index 000000000..a00144af2
--- /dev/null
+++ b/packages/app/src/hooks/picked-image-normalizer.test.ts
@@ -0,0 +1,79 @@
+import { describe, expect, it } from "vitest";
+import {
+ normalizePickedImageAssetsWith,
+ type ExportPickedImageAsPng,
+} from "./picked-image-normalizer";
+
+function fakeExportAsPng(): {
+ exportAsPng: ExportPickedImageAsPng;
+ recordedUris: string[];
+} {
+ const recordedUris: string[] = [];
+ return {
+ exportAsPng: async (uri: string) => {
+ recordedUris.push(uri);
+ return "file:///cache/ImageManipulator/safe-picked.png";
+ },
+ recordedUris,
+ };
+}
+
+describe("native image attachment picker", () => {
+ it("preserves native picked JPEG and PNG attachment inputs", async () => {
+ const { exportAsPng, recordedUris } = fakeExportAsPng();
+
+ const result = await normalizePickedImageAssetsWith(
+ [
+ {
+ uri: "file:///photos/IMG_0001.JPG",
+ mimeType: "image/jpeg",
+ fileName: "picked.jpeg",
+ },
+ {
+ uri: "file:///photos/screenshot.png",
+ mimeType: "image/png",
+ fileName: "screenshot.png",
+ },
+ ],
+ exportAsPng,
+ );
+
+ expect(result).toEqual([
+ {
+ source: { kind: "file_uri", uri: "file:///photos/IMG_0001.JPG" },
+ mimeType: "image/jpeg",
+ fileName: "picked.jpg",
+ },
+ {
+ source: { kind: "file_uri", uri: "file:///photos/screenshot.png" },
+ mimeType: "image/png",
+ fileName: "screenshot.png",
+ },
+ ]);
+ expect(recordedUris).toEqual([]);
+ });
+
+ it("turns a native picked HEIC-like asset into a PNG attachment input", async () => {
+ const { exportAsPng, recordedUris } = fakeExportAsPng();
+
+ const result = await normalizePickedImageAssetsWith(
+ [
+ {
+ uri: "file:///photos/IMG_0001.HEIC",
+ mimeType: "image/png",
+ fileName: "picked.png",
+ },
+ ],
+ exportAsPng,
+ );
+
+ expect(result).toEqual([
+ {
+ source: { kind: "file_uri", uri: "file:///cache/ImageManipulator/safe-picked.png" },
+ mimeType: "image/png",
+ fileName: "picked.png",
+ },
+ ]);
+ expect(recordedUris).toEqual(["file:///photos/IMG_0001.HEIC"]);
+ });
+});
diff --git a/packages/app/src/hooks/picked-image-normalizer.ts b/packages/app/src/hooks/picked-image-normalizer.ts
new file mode 100644
index 000000000..a662d10ff
--- /dev/null
+++ b/packages/app/src/hooks/picked-image-normalizer.ts
@@ -0,0 +1,110 @@
+export type PickedImageSource = { kind: "file_uri"; uri: string } | { kind: "blob"; blob: Blob };
+
+export interface PickedImageAttachmentInput {
+ source: PickedImageSource;
+ mimeType?: string | null;
+ fileName?: string | null;
+}
+
+export interface ExpoImagePickerAssetLike {
+ uri: string;
+ mimeType?: string | null;
+ fileName?: string | null;
+ file?: File | null;
+}
+
+export type ExportPickedImageAsPng = (uri: string) => Promise;
+
+interface SupportedPickedImageFormat {
+ mimeType: "image/jpeg" | "image/png";
+ extension: "jpg" | "png";
+}
+
+const JPEG_FORMAT: SupportedPickedImageFormat = {
+ mimeType: "image/jpeg",
+ extension: "jpg",
+};
+
+const PNG_FORMAT: SupportedPickedImageFormat = {
+ mimeType: "image/png",
+ extension: "png",
+};
+
+function extensionFromPath(path: string | null | undefined): string | null {
+ const match = path?.match(/\.([a-z0-9]+)(?:[?#].*)?$/i);
+ return match?.[1]?.toLowerCase() ?? null;
+}
+
+function supportedFormatForExtension(extension: string | null): SupportedPickedImageFormat | null {
+ if (extension === "jpg" || extension === "jpeg") {
+ return JPEG_FORMAT;
+ }
+ if (extension === "png") {
+ return PNG_FORMAT;
+ }
+ return null;
+}
+
+function supportedFormatForMimeType(
+ mimeType: string | null | undefined,
+): SupportedPickedImageFormat | null {
+ const normalizedMimeType = mimeType?.toLowerCase();
+ if (normalizedMimeType === "image/jpeg" || normalizedMimeType === "image/jpg") {
+ return JPEG_FORMAT;
+ }
+ if (normalizedMimeType === "image/png") {
+ return PNG_FORMAT;
+ }
+ return null;
+}
+
+function pickedAssetSupportedFormat(
+ asset: ExpoImagePickerAssetLike,
+): SupportedPickedImageFormat | null {
+ const uriExtension = extensionFromPath(asset.uri);
+ if (uriExtension) {
+ return supportedFormatForExtension(uriExtension);
+ }
+
+ return (
+ supportedFormatForExtension(extensionFromPath(asset.fileName)) ??
+ supportedFormatForMimeType(asset.mimeType)
+ );
+}
+
+function replaceFileExtension(
+ fileName: string | null | undefined,
+ extension: SupportedPickedImageFormat["extension"],
+): string | null {
+ if (!fileName) {
+ return null;
+ }
+
+ return fileName.replace(/\.[^./\\]+$/, "") + `.${extension}`;
+}
+
+export async function normalizePickedImageAssetsWith(
+ assets: readonly ExpoImagePickerAssetLike[],
+ exportAsPng: ExportPickedImageAsPng,
+): Promise {
+ return await Promise.all(
+ assets.map(async (asset) => {
+ const supportedFormat = pickedAssetSupportedFormat(asset);
+ if (supportedFormat) {
+ return {
+ source: { kind: "file_uri", uri: asset.uri },
+ mimeType: supportedFormat.mimeType,
+ fileName: replaceFileExtension(asset.fileName, supportedFormat.extension),
+ };
+ }
+
+ const convertedUri = await exportAsPng(asset.uri);
+
+ return {
+ source: { kind: "file_uri", uri: convertedUri },
+ mimeType: PNG_FORMAT.mimeType,
+ fileName: replaceFileExtension(asset.fileName, PNG_FORMAT.extension),
+ };
+ }),
+ );
+}
diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts
new file mode 100644
index 000000000..1963e0dd4
--- /dev/null
+++ b/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts
@@ -0,0 +1,248 @@
+import { describe, expect, it } from "vitest";
+import type { WorkspaceStructureProject } from "@/stores/session-store-hooks";
+import {
+ appendMissingOrderKeys,
+ applyStoredOrdering,
+ buildSidebarProjectsFromStructure,
+ computeSidebarOrderUpdates,
+ deriveSidebarLoadingState,
+ type SidebarProjectEntry,
+} from "./sidebar-workspaces-view-model";
+
+interface OrderedItem {
+ key: string;
+}
+
+function item(key: string): OrderedItem {
+ return { key };
+}
+
+function project(input: {
+ projectKey: string;
+ projectName?: string;
+ projectKind?: WorkspaceStructureProject["projectKind"];
+ iconWorkingDir?: string;
+ workspaceKeys: string[];
+}): WorkspaceStructureProject {
+ return {
+ projectKey: input.projectKey,
+ projectName: input.projectName ?? input.projectKey,
+ projectKind: input.projectKind ?? "git",
+ iconWorkingDir: input.iconWorkingDir ?? input.projectKey,
+ workspaceKeys: input.workspaceKeys,
+ };
+}
+
+function sidebarProject(input: {
+ projectKey: string;
+ workspaceKeys: string[];
+ serverId?: string;
+}): SidebarProjectEntry {
+ const projects = buildSidebarProjectsFromStructure({
+ serverId: input.serverId ?? "srv",
+ projects: [project({ projectKey: input.projectKey, workspaceKeys: input.workspaceKeys })],
+ });
+ const result = projects[0];
+ if (!result) {
+ throw new Error("expected a project entry");
+ }
+ return result;
+}
+
+describe("applyStoredOrdering", () => {
+ it("keeps unknown items on the baseline while applying stored order", () => {
+ const result = applyStoredOrdering({
+ items: [item("new"), item("a"), item("b")],
+ storedOrder: ["b", "a"],
+ getKey: (entry) => entry.key,
+ });
+
+ expect(result.map((entry) => entry.key)).toEqual(["new", "b", "a"]);
+ });
+
+ it("ignores stale and duplicate stored keys", () => {
+ const result = applyStoredOrdering({
+ items: [item("x"), item("y")],
+ storedOrder: ["missing", "y", "y", "x"],
+ getKey: (entry) => entry.key,
+ });
+
+ expect(result.map((entry) => entry.key)).toEqual(["y", "x"]);
+ });
+
+ it("returns baseline when there is no persisted order", () => {
+ const baseline = [item("first"), item("second")];
+ const result = applyStoredOrdering({
+ items: baseline,
+ storedOrder: [],
+ getKey: (entry) => entry.key,
+ });
+
+ expect(result).toBe(baseline);
+ });
+});
+
+describe("appendMissingOrderKeys", () => {
+ it("appends unseen keys while preserving existing order", () => {
+ const result = appendMissingOrderKeys({
+ currentOrder: ["project-b", "project-a"],
+ visibleKeys: ["project-a", "project-b", "project-c"],
+ });
+
+ expect(result).toEqual(["project-b", "project-a", "project-c"]);
+ });
+
+ it("returns the same array when there are no unseen keys", () => {
+ const currentOrder = ["project-a", "project-b"];
+
+ const result = appendMissingOrderKeys({
+ currentOrder,
+ visibleKeys: ["project-b", "project-a"],
+ });
+
+ expect(result).toBe(currentOrder);
+ });
+});
+
+describe("buildSidebarProjectsFromStructure", () => {
+ it("creates structural workspace rows from ordered workspace keys", () => {
+ const projects = buildSidebarProjectsFromStructure({
+ serverId: "srv",
+ projects: [
+ project({
+ projectKey: "project-1",
+ projectName: "Project 1",
+ iconWorkingDir: "/repo/main",
+ workspaceKeys: ["ws-main"],
+ }),
+ ],
+ });
+
+ expect(projects).toHaveLength(1);
+ expect(projects[0]?.projectName).toBe("Project 1");
+ expect(projects[0]?.workspaces[0]).toMatchObject({
+ workspaceKey: "srv:ws-main",
+ serverId: "srv",
+ workspaceId: "ws-main",
+ projectRootPath: "/repo/main",
+ projectKind: "git",
+ });
+ });
+
+ it("preserves the structure hook project order", () => {
+ const projects = buildSidebarProjectsFromStructure({
+ serverId: "srv",
+ projects: [
+ project({ projectKey: "project-b", workspaceKeys: ["ws-b"] }),
+ project({ projectKey: "project-a", workspaceKeys: ["ws-a"] }),
+ ],
+ });
+
+ expect(projects.map((entry) => entry.projectKey)).toEqual(["project-b", "project-a"]);
+ });
+
+ it("preserves the structure hook workspace order", () => {
+ const projects = buildSidebarProjectsFromStructure({
+ serverId: "srv",
+ projects: [project({ projectKey: "project-1", workspaceKeys: ["feature", "main"] })],
+ });
+
+ expect(projects[0]?.workspaces.map((workspace) => workspace.workspaceId)).toEqual([
+ "feature",
+ "main",
+ ]);
+ });
+});
+
+describe("computeSidebarOrderUpdates", () => {
+ it("returns no updates when there are no visible projects", () => {
+ const updates = computeSidebarOrderUpdates({
+ projects: [],
+ persistedProjectOrder: ["stale-project"],
+ getWorkspaceOrder: () => [],
+ });
+
+ expect(updates).toEqual({ projectOrder: null, workspaceOrders: [] });
+ });
+
+ it("appends unseen projects and workspaces to the persisted orders", () => {
+ const projects = [
+ sidebarProject({ projectKey: "project-a", workspaceKeys: ["ws-1", "ws-2"] }),
+ sidebarProject({ projectKey: "project-b", workspaceKeys: ["ws-3"] }),
+ ];
+
+ const updates = computeSidebarOrderUpdates({
+ projects,
+ persistedProjectOrder: ["project-a"],
+ getWorkspaceOrder: (projectKey) => (projectKey === "project-a" ? ["srv:ws-1"] : []),
+ });
+
+ expect(updates.projectOrder).toEqual(["project-a", "project-b"]);
+ expect(updates.workspaceOrders).toEqual([
+ { projectKey: "project-a", order: ["srv:ws-1", "srv:ws-2"] },
+ { projectKey: "project-b", order: ["srv:ws-3"] },
+ ]);
+ });
+
+ it("returns no project-order update when persisted order already covers visible keys", () => {
+ const projects = [
+ sidebarProject({ projectKey: "project-a", workspaceKeys: ["ws-1"] }),
+ sidebarProject({ projectKey: "project-b", workspaceKeys: ["ws-2"] }),
+ ];
+
+ const updates = computeSidebarOrderUpdates({
+ projects,
+ persistedProjectOrder: ["project-b", "project-a"],
+ getWorkspaceOrder: (projectKey) => (projectKey === "project-a" ? ["srv:ws-1"] : ["srv:ws-2"]),
+ });
+
+ expect(updates.projectOrder).toBeNull();
+ expect(updates.workspaceOrders).toEqual([]);
+ });
+});
+
+describe("deriveSidebarLoadingState", () => {
+ it("reports initial-load while active and unhydrated with no projects", () => {
+ expect(
+ deriveSidebarLoadingState({
+ isActive: true,
+ serverId: "srv",
+ hasHydratedWorkspaces: false,
+ hasProjects: false,
+ }),
+ ).toEqual({ isLoading: true, isInitialLoad: true, isRevalidating: false });
+ });
+
+ it("stays loading but not initial once projects are visible", () => {
+ expect(
+ deriveSidebarLoadingState({
+ isActive: true,
+ serverId: "srv",
+ hasHydratedWorkspaces: false,
+ hasProjects: true,
+ }),
+ ).toEqual({ isLoading: true, isInitialLoad: false, isRevalidating: false });
+ });
+
+ it("clears loading once workspaces have hydrated", () => {
+ expect(
+ deriveSidebarLoadingState({
+ isActive: true,
+ serverId: "srv",
+ hasHydratedWorkspaces: true,
+ hasProjects: true,
+ }),
+ ).toEqual({ isLoading: false, isInitialLoad: false, isRevalidating: false });
+ });
+
+ it("short-circuits to idle when inactive", () => {
+ expect(
+ deriveSidebarLoadingState({
+ isActive: false,
+ serverId: "srv",
+ hasHydratedWorkspaces: false,
+ hasProjects: false,
+ }),
+ ).toEqual({ isLoading: false, isInitialLoad: false, isRevalidating: false });
+ });
+});
diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.ts
new file mode 100644
index 000000000..62f547958
--- /dev/null
+++ b/packages/app/src/hooks/sidebar-workspaces-view-model.ts
@@ -0,0 +1,200 @@
+import type { PrHint } from "@/git/use-pr-status-query";
+import type { WorkspaceDescriptor } from "@/stores/session-store";
+import type { WorkspaceStructureProject } from "@/stores/session-store-hooks";
+
+const EMPTY_PROJECTS: SidebarProjectEntry[] = [];
+
+export type SidebarStateBucket = WorkspaceDescriptor["status"];
+
+export interface SidebarWorkspaceEntry {
+ workspaceKey: string;
+ serverId: string;
+ workspaceId: string;
+ projectKey: string;
+ projectRootPath?: string;
+ workspaceDirectory?: string;
+ projectKind: WorkspaceDescriptor["projectKind"];
+ workspaceKind: WorkspaceDescriptor["workspaceKind"];
+ name: string;
+ statusBucket: SidebarStateBucket;
+ archivingAt: string | null;
+ diffStat: { additions: number; deletions: number } | null;
+ prHint: PrHint | null;
+ archiveHasUncommittedChanges: boolean | null;
+ archiveUnpushedCommitCount: number | null;
+ scripts: WorkspaceDescriptor["scripts"];
+ hasRunningScripts: boolean;
+}
+
+export interface SidebarProjectEntry {
+ projectKey: string;
+ projectName: string;
+ projectKind: WorkspaceDescriptor["projectKind"];
+ iconWorkingDir: string;
+ workspaces: SidebarWorkspaceEntry[];
+}
+
+function createStructuralWorkspaceEntry(input: {
+ serverId: string;
+ project: WorkspaceStructureProject;
+ workspaceId: string;
+}): SidebarWorkspaceEntry {
+ return {
+ workspaceKey: `${input.serverId}:${input.workspaceId}`,
+ serverId: input.serverId,
+ workspaceId: input.workspaceId,
+ projectKey: input.project.projectKey,
+ projectRootPath: input.project.iconWorkingDir,
+ workspaceDirectory: undefined,
+ projectKind: input.project.projectKind,
+ workspaceKind: "checkout",
+ name: input.workspaceId,
+ statusBucket: "done",
+ archivingAt: null,
+ diffStat: null,
+ prHint: null,
+ archiveHasUncommittedChanges: null,
+ archiveUnpushedCommitCount: null,
+ scripts: [],
+ hasRunningScripts: false,
+ };
+}
+
+export function buildSidebarProjectsFromStructure(input: {
+ serverId: string;
+ projects: WorkspaceStructureProject[];
+}): SidebarProjectEntry[] {
+ if (input.projects.length === 0) {
+ return EMPTY_PROJECTS;
+ }
+
+ return input.projects.map((project) => ({
+ projectKey: project.projectKey,
+ projectName: project.projectName,
+ projectKind: project.projectKind,
+ iconWorkingDir: project.iconWorkingDir,
+ workspaces: project.workspaceKeys.map((workspaceId) =>
+ createStructuralWorkspaceEntry({
+ serverId: input.serverId,
+ project,
+ workspaceId,
+ }),
+ ),
+ }));
+}
+
+export function applyStoredOrdering(input: {
+ items: T[];
+ storedOrder: string[];
+ getKey: (item: T) => string;
+}): T[] {
+ if (input.items.length <= 1 || input.storedOrder.length === 0) {
+ return input.items;
+ }
+
+ const itemByKey = new Map();
+ for (const item of input.items) {
+ itemByKey.set(input.getKey(item), item);
+ }
+
+ const prunedOrder: string[] = [];
+ const seen = new Set();
+ for (const key of input.storedOrder) {
+ if (!itemByKey.has(key) || seen.has(key)) {
+ continue;
+ }
+ seen.add(key);
+ prunedOrder.push(key);
+ }
+
+ if (prunedOrder.length === 0) {
+ return input.items;
+ }
+
+ const orderedSet = new Set(prunedOrder);
+ const ordered: T[] = [];
+ let orderedIndex = 0;
+
+ for (const item of input.items) {
+ const key = input.getKey(item);
+ if (!orderedSet.has(key)) {
+ ordered.push(item);
+ continue;
+ }
+
+ const targetKey = prunedOrder[orderedIndex] ?? key;
+ orderedIndex += 1;
+ ordered.push(itemByKey.get(targetKey) ?? item);
+ }
+
+ return ordered;
+}
+
+export function appendMissingOrderKeys(input: {
+ currentOrder: string[];
+ visibleKeys: string[];
+}): string[] {
+ if (input.visibleKeys.length === 0) {
+ return input.currentOrder;
+ }
+
+ const existingKeys = new Set(input.currentOrder);
+ const missingKeys = input.visibleKeys.filter((key) => !existingKeys.has(key));
+ if (missingKeys.length === 0) {
+ return input.currentOrder;
+ }
+
+ return [...input.currentOrder, ...missingKeys];
+}
+
+export interface SidebarOrderUpdates {
+ projectOrder: string[] | null;
+ workspaceOrders: Array<{ projectKey: string; order: string[] }>;
+}
+
+export function computeSidebarOrderUpdates(input: {
+ projects: SidebarProjectEntry[];
+ persistedProjectOrder: string[];
+ getWorkspaceOrder: (projectKey: string) => string[];
+}): SidebarOrderUpdates {
+ if (input.projects.length === 0) {
+ return { projectOrder: null, workspaceOrders: [] };
+ }
+
+ const nextProjectOrder = appendMissingOrderKeys({
+ currentOrder: input.persistedProjectOrder,
+ visibleKeys: input.projects.map((project) => project.projectKey),
+ });
+ const projectOrder = nextProjectOrder === input.persistedProjectOrder ? null : nextProjectOrder;
+
+ const workspaceOrders: Array<{ projectKey: string; order: string[] }> = [];
+ for (const project of input.projects) {
+ const persistedWorkspaceOrder = input.getWorkspaceOrder(project.projectKey);
+ const nextWorkspaceOrder = appendMissingOrderKeys({
+ currentOrder: persistedWorkspaceOrder,
+ visibleKeys: project.workspaces.map((workspace) => workspace.workspaceKey),
+ });
+ if (nextWorkspaceOrder !== persistedWorkspaceOrder) {
+ workspaceOrders.push({ projectKey: project.projectKey, order: nextWorkspaceOrder });
+ }
+ }
+
+ return { projectOrder, workspaceOrders };
+}
+
+export interface SidebarLoadingState {
+ isLoading: boolean;
+ isInitialLoad: boolean;
+ isRevalidating: boolean;
+}
+
+export function deriveSidebarLoadingState(input: {
+ isActive: boolean;
+ serverId: string | null;
+ hasHydratedWorkspaces: boolean;
+ hasProjects: boolean;
+}): SidebarLoadingState {
+ const isLoading = input.isActive && Boolean(input.serverId) && !input.hasHydratedWorkspaces;
+ const isInitialLoad = isLoading && !input.hasProjects;
+ return { isLoading, isInitialLoad, isRevalidating: false };
+}
diff --git a/packages/app/src/hooks/use-agent-commands-query.test.ts b/packages/app/src/hooks/use-agent-commands-query.test.ts
index 4bcd288cc..dfff07095 100644
--- a/packages/app/src/hooks/use-agent-commands-query.test.ts
+++ b/packages/app/src/hooks/use-agent-commands-query.test.ts
@@ -1,79 +1,75 @@
-/**
- * @vitest-environment jsdom
- */
-import React from "react";
-import { renderHook, waitFor } from "@testing-library/react";
-import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import { afterEach, describe, expect, it, vi } from "vitest";
+import { describe, expect, it } from "vitest";
+import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
+import {
+ type AgentCommandsClient,
+ type DraftCommandConfig,
+ fetchAgentCommands,
+} from "./use-agent-commands-query";
-import { useAgentCommandsQuery } from "./use-agent-commands-query";
+type ListCommands = DaemonClient["listCommands"];
+type ListCommandsResult = Awaited>;
-const { mockClient, mockRuntime } = vi.hoisted(() => {
- const hoistedClient = {
- listCommands: vi.fn(),
- };
+interface ListCommandsCall {
+ agentId: string;
+ draftConfig: DraftCommandConfig | undefined;
+}
+
+interface FakeAgentCommandsClient extends AgentCommandsClient {
+ calls: ListCommandsCall[];
+}
+
+function createClient(response: ListCommandsResult): FakeAgentCommandsClient {
+ const calls: ListCommandsCall[] = [];
return {
- mockClient: hoistedClient,
- mockRuntime: {
- client: hoistedClient,
- isConnected: true,
- },
+ calls,
+ listCommands: (async (
+ agentId: string,
+ requestIdOrOptions?: string | { draftConfig?: DraftCommandConfig },
+ ) => {
+ const options =
+ typeof requestIdOrOptions === "object" && requestIdOrOptions !== null
+ ? requestIdOrOptions
+ : undefined;
+ calls.push({ agentId, draftConfig: options?.draftConfig });
+ return response;
+ }) as ListCommands,
};
-});
-
-vi.mock("@/runtime/host-runtime", () => ({
- useHostRuntimeClient: () => mockRuntime.client,
- useHostRuntimeIsConnected: () => mockRuntime.isConnected,
-}));
-
-function createQueryClient(): QueryClient {
- return new QueryClient({
- defaultOptions: {
- queries: {
- retry: false,
- },
- },
- });
}
-function renderCommandsHook(input: Parameters[0]) {
- const queryClient = createQueryClient();
- const wrapper = ({ children }: { children: React.ReactNode }) =>
- React.createElement(QueryClientProvider, { client: queryClient }, children);
-
- return renderHook(() => useAgentCommandsQuery(input), { wrapper });
+function commandsPayload(commands: ListCommandsResult["commands"]): ListCommandsResult {
+ return {
+ requestId: "req_commands",
+ agentId: "",
+ error: null,
+ commands,
+ };
}
-describe("useAgentCommandsQuery", () => {
- afterEach(() => {
- vi.clearAllMocks();
- mockRuntime.client = mockClient;
- mockRuntime.isConnected = true;
- });
-
+describe("fetchAgentCommands", () => {
it("loads commands for a draft composer without an agent id", async () => {
- mockClient.listCommands.mockResolvedValue({
- commands: [{ name: "compact", description: "Compact context", argumentHint: "" }],
- });
+ const client = createClient(
+ commandsPayload([{ name: "compact", description: "Compact context", argumentHint: "" }]),
+ );
- const draftConfig = {
- provider: "opencode" as const,
+ const draftConfig: DraftCommandConfig = {
+ provider: "opencode",
cwd: "/repo",
modeId: "build",
};
- const { result } = renderCommandsHook({
- serverId: "server-1",
- agentId: "",
- draftConfig,
- });
+ const commands = await fetchAgentCommands({ client, agentId: "", draftConfig });
- await waitFor(() => {
- expect(result.current.commands).toEqual([
- { name: "compact", description: "Compact context", argumentHint: "" },
- ]);
- });
+ expect(commands).toEqual([
+ { name: "compact", description: "Compact context", argumentHint: "" },
+ ]);
+ expect(client.calls).toEqual([{ agentId: "", draftConfig }]);
+ });
- expect(mockClient.listCommands).toHaveBeenCalledWith("", { draftConfig });
+ it("passes the agent id when fetching commands for a running agent", async () => {
+ const client = createClient(commandsPayload([]));
+
+ await fetchAgentCommands({ client, agentId: "agent-1" });
+
+ expect(client.calls).toEqual([{ agentId: "agent-1", draftConfig: undefined }]);
});
});
diff --git a/packages/app/src/hooks/use-agent-commands-query.ts b/packages/app/src/hooks/use-agent-commands-query.ts
index c7b511707..05071d903 100644
--- a/packages/app/src/hooks/use-agent-commands-query.ts
+++ b/packages/app/src/hooks/use-agent-commands-query.ts
@@ -1,4 +1,5 @@
import { useQuery } from "@tanstack/react-query";
+import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
@@ -19,7 +20,24 @@ export interface DraftCommandConfig {
featureValues?: Record;
}
-function commandsQueryKey(serverId: string, agentId: string, draftConfig?: DraftCommandConfig) {
+export type AgentCommandsClient = Pick;
+
+export async function fetchAgentCommands(input: {
+ client: AgentCommandsClient;
+ agentId: string;
+ draftConfig?: DraftCommandConfig;
+}): Promise {
+ const response = await input.client.listCommands(input.agentId, {
+ draftConfig: input.draftConfig,
+ });
+ return response.commands as AgentSlashCommand[];
+}
+
+function agentCommandsQueryKey(
+ serverId: string,
+ agentId: string,
+ draftConfig?: DraftCommandConfig,
+) {
return [
"agentCommands",
serverId,
@@ -50,13 +68,12 @@ export function useAgentCommandsQuery({
const isConnected = useHostRuntimeIsConnected(serverId);
const query = useQuery({
- queryKey: commandsQueryKey(serverId, agentId, draftConfig),
+ queryKey: agentCommandsQueryKey(serverId, agentId, draftConfig),
queryFn: async () => {
if (!client) {
throw new Error("Daemon client not available");
}
- const response = await client.listCommands(agentId, { draftConfig });
- return response.commands as AgentSlashCommand[];
+ return fetchAgentCommands({ client, agentId, draftConfig });
},
enabled: enabled && !!client && isConnected && (!!agentId || !!draftConfig),
staleTime: COMMANDS_STALE_TIME,
diff --git a/packages/app/src/hooks/use-agent-history.test.ts b/packages/app/src/hooks/use-agent-history.test.ts
new file mode 100644
index 000000000..0cdb392c2
--- /dev/null
+++ b/packages/app/src/hooks/use-agent-history.test.ts
@@ -0,0 +1,209 @@
+import { describe, expect, it } from "vitest";
+import type {
+ DaemonClient,
+ FetchAgentHistoryEntry,
+ FetchAgentHistoryOptions,
+} from "@getpaseo/client/internal/daemon-client";
+import { type AgentHistoryClient, fetchAgentHistoryPage } from "./use-agent-history";
+
+type FetchAgentHistory = DaemonClient["fetchAgentHistory"];
+type FetchAgentHistoryResult = Awaited>;
+
+interface FakeAgentHistoryClient extends AgentHistoryClient {
+ calls: FetchAgentHistoryOptions[];
+}
+
+function createClient(pages: FetchAgentHistoryResult[]): FakeAgentHistoryClient {
+ const calls: FetchAgentHistoryOptions[] = [];
+ let index = 0;
+ return {
+ calls,
+ fetchAgentHistory: async (options) => {
+ calls.push(options ?? {});
+ const page = pages[index] ?? pages[pages.length - 1];
+ index += 1;
+ if (!page) {
+ throw new Error("No more history pages configured");
+ }
+ return page;
+ },
+ };
+}
+
+function historyPayload(input: {
+ entries: FetchAgentHistoryEntry[];
+ hasMore?: boolean;
+ nextCursor?: string | null;
+}): FetchAgentHistoryResult {
+ return {
+ requestId: "req_history",
+ entries: input.entries,
+ pageInfo: {
+ nextCursor: input.nextCursor ?? null,
+ prevCursor: null,
+ hasMore: input.hasMore ?? false,
+ },
+ };
+}
+
+function historyEntry(input: {
+ id: string;
+ cwd: string;
+ updatedAt: string;
+ title?: string | null;
+ archivedAt?: string | null;
+}): FetchAgentHistoryEntry {
+ return {
+ agent: {
+ id: input.id,
+ provider: "codex",
+ status: "closed",
+ createdAt: input.updatedAt,
+ updatedAt: input.updatedAt,
+ lastUserMessageAt: null,
+ lastError: undefined,
+ runtimeInfo: {
+ provider: "codex",
+ sessionId: null,
+ },
+ capabilities: {
+ supportsStreaming: true,
+ supportsSessionPersistence: true,
+ supportsDynamicModes: true,
+ supportsMcpServers: true,
+ supportsReasoningStream: true,
+ supportsToolInvocations: true,
+ },
+ currentModeId: null,
+ availableModes: [],
+ pendingPermissions: [],
+ persistence: null,
+ title: input.title ?? null,
+ cwd: input.cwd,
+ model: null,
+ thinkingOptionId: null,
+ requiresAttention: false,
+ attentionReason: null,
+ attentionTimestamp: null,
+ archivedAt: input.archivedAt ?? null,
+ labels: {},
+ },
+ project: {
+ projectKey: input.cwd,
+ projectName: "workspace",
+ checkout: {
+ cwd: input.cwd,
+ isGit: false,
+ currentBranch: null,
+ remoteUrl: null,
+ worktreeRoot: null,
+ isPaseoOwnedWorktree: false,
+ mainRepoRoot: null,
+ },
+ },
+ };
+}
+
+describe("fetchAgentHistoryPage", () => {
+ it("requests the first page with the default limit and updated_at descending sort", async () => {
+ const client = createClient([
+ historyPayload({
+ entries: [
+ historyEntry({
+ id: "history-1",
+ cwd: "/repo",
+ updatedAt: "2026-04-02T10:00:00.000Z",
+ title: "History one",
+ }),
+ ],
+ hasMore: true,
+ nextCursor: "cursor-2",
+ }),
+ ]);
+
+ const page = await fetchAgentHistoryPage({ client, serverId: "server-1", cursor: null });
+
+ expect(client.calls).toEqual([
+ {
+ sort: [{ key: "updated_at", direction: "desc" }],
+ page: { limit: 200 },
+ } satisfies FetchAgentHistoryOptions,
+ ]);
+ expect(page.agents.map((agent) => agent.id)).toEqual(["history-1"]);
+ expect(page.pageInfo).toEqual({
+ nextCursor: "cursor-2",
+ prevCursor: null,
+ hasMore: true,
+ });
+ });
+
+ it("passes the cursor when fetching subsequent pages", async () => {
+ const client = createClient([
+ historyPayload({
+ entries: [
+ historyEntry({
+ id: "history-2",
+ cwd: "/repo",
+ updatedAt: "2026-04-01T10:00:00.000Z",
+ title: "History two",
+ }),
+ ],
+ }),
+ ]);
+
+ await fetchAgentHistoryPage({ client, serverId: "server-1", cursor: "cursor-2" });
+
+ expect(client.calls.at(-1)).toEqual({
+ sort: [{ key: "updated_at", direction: "desc" }],
+ page: { limit: 200, cursor: "cursor-2" },
+ } satisfies FetchAgentHistoryOptions);
+ });
+
+ it("maps daemon history entries into aggregated agents tagged with the requested server", async () => {
+ const client = createClient([
+ historyPayload({
+ entries: [
+ historyEntry({
+ id: "history-1",
+ cwd: "/repo",
+ updatedAt: "2026-04-02T10:00:00.000Z",
+ title: "History one",
+ }),
+ ],
+ }),
+ ]);
+
+ const page = await fetchAgentHistoryPage({ client, serverId: "server-1", cursor: null });
+
+ expect(page.agents).toEqual([
+ expect.objectContaining({
+ id: "history-1",
+ serverId: "server-1",
+ serverLabel: "server-1",
+ title: "History one",
+ cwd: "/repo",
+ provider: "codex",
+ archivedAt: null,
+ }),
+ ]);
+ });
+
+ it("carries archived entries through with their archivedAt timestamp", async () => {
+ const client = createClient([
+ historyPayload({
+ entries: [
+ historyEntry({
+ id: "history-archived",
+ cwd: "/repo",
+ updatedAt: "2026-04-01T10:00:00.000Z",
+ archivedAt: "2026-04-01T10:05:00.000Z",
+ }),
+ ],
+ }),
+ ]);
+
+ const page = await fetchAgentHistoryPage({ client, serverId: "server-1", cursor: null });
+
+ expect(page.agents[0]?.archivedAt).toEqual(new Date("2026-04-01T10:05:00.000Z"));
+ });
+});
diff --git a/packages/app/src/hooks/use-agent-history.test.tsx b/packages/app/src/hooks/use-agent-history.test.tsx
deleted file mode 100644
index d43082216..000000000
--- a/packages/app/src/hooks/use-agent-history.test.tsx
+++ /dev/null
@@ -1,267 +0,0 @@
-/**
- * @vitest-environment jsdom
- */
-import React from "react";
-import { act, renderHook, waitFor } from "@testing-library/react";
-import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import { afterEach, describe, expect, it, vi } from "vitest";
-import type {
- DaemonClient,
- FetchAgentHistoryEntry,
- FetchAgentHistoryOptions,
-} from "@getpaseo/client/internal/daemon-client";
-import { useSessionStore, type Agent } from "@/stores/session-store";
-import { useAgentHistory } from "./use-agent-history";
-
-const { mockClient, mockRuntimeStore } = vi.hoisted(() => {
- const hoistedClient = {
- fetchAgentHistory: vi.fn(),
- };
- const hoistedRuntimeStore = {
- refreshAgentDirectory: vi.fn(),
- };
- return { mockClient: hoistedClient, mockRuntimeStore: hoistedRuntimeStore };
-});
-
-vi.mock("@/runtime/host-runtime", () => ({
- getHostRuntimeStore: () => mockRuntimeStore,
- useHostRuntimeClient: () => mockClient,
- useHostRuntimeIsConnected: () => true,
- useHosts: () => [{ serverId: "server-1", label: "Local" }],
-}));
-
-function createQueryClient(): QueryClient {
- return new QueryClient({
- defaultOptions: {
- queries: { retry: false },
- },
- });
-}
-
-function renderAgentHistoryHook(options?: { enabled?: boolean }) {
- const queryClient = createQueryClient();
- const wrapper = ({ children }: { children: React.ReactNode }) =>
- React.createElement(QueryClientProvider, { client: queryClient }, children);
-
- return renderHook(() => useAgentHistory({ serverId: "server-1", enabled: options?.enabled }), {
- wrapper,
- });
-}
-
-function makeHistoryPayload(input: {
- entries: FetchAgentHistoryEntry[];
- hasMore?: boolean;
- nextCursor?: string | null;
-}): Awaited> {
- return {
- requestId: "req_history",
- entries: input.entries,
- pageInfo: {
- nextCursor: input.nextCursor ?? null,
- prevCursor: null,
- hasMore: input.hasMore ?? false,
- },
- };
-}
-
-function makeHistoryEntry(input: {
- id: string;
- cwd: string;
- updatedAt: string;
- title?: string | null;
- archivedAt?: string | null;
-}): FetchAgentHistoryEntry {
- return {
- agent: {
- id: input.id,
- provider: "codex",
- status: "closed",
- createdAt: input.updatedAt,
- updatedAt: input.updatedAt,
- lastUserMessageAt: null,
- lastError: undefined,
- runtimeInfo: {
- provider: "codex",
- sessionId: null,
- },
- capabilities: {
- supportsStreaming: true,
- supportsSessionPersistence: true,
- supportsDynamicModes: true,
- supportsMcpServers: true,
- supportsReasoningStream: true,
- supportsToolInvocations: true,
- },
- currentModeId: null,
- availableModes: [],
- pendingPermissions: [],
- persistence: null,
- title: input.title ?? null,
- cwd: input.cwd,
- model: null,
- thinkingOptionId: null,
- requiresAttention: false,
- attentionReason: null,
- attentionTimestamp: null,
- archivedAt: input.archivedAt ?? null,
- labels: {},
- },
- project: {
- projectKey: input.cwd,
- projectName: "workspace",
- checkout: {
- cwd: input.cwd,
- isGit: false,
- currentBranch: null,
- remoteUrl: null,
- worktreeRoot: null,
- isPaseoOwnedWorktree: false,
- mainRepoRoot: null,
- },
- },
- };
-}
-
-function makeActiveAgent(): Agent {
- const timestamp = new Date("2026-04-01T10:00:00.000Z");
- return {
- serverId: "server-1",
- id: "active-1",
- provider: "codex",
- status: "idle",
- createdAt: timestamp,
- updatedAt: timestamp,
- lastUserMessageAt: null,
- lastActivityAt: timestamp,
- capabilities: {
- supportsStreaming: true,
- supportsSessionPersistence: true,
- supportsDynamicModes: true,
- supportsMcpServers: true,
- supportsReasoningStream: true,
- supportsToolInvocations: true,
- },
- currentModeId: null,
- availableModes: [],
- pendingPermissions: [],
- persistence: null,
- title: "Active",
- cwd: "/repo",
- model: null,
- labels: {},
- archivedAt: null,
- parentAgentId: null,
- };
-}
-
-afterEach(() => {
- mockClient.fetchAgentHistory.mockReset();
- mockRuntimeStore.refreshAgentDirectory.mockReset();
- useSessionStore.setState({ sessions: {}, agentLastActivity: new Map() });
-});
-
-function agentIds(agents: ReadonlyArray<{ id: string }>): string[] {
- return agents.map((agent) => agent.id);
-}
-
-describe("useAgentHistory", () => {
- it("loads history one page at a time without refreshing active agents", async () => {
- mockClient.fetchAgentHistory
- .mockResolvedValueOnce(
- makeHistoryPayload({
- entries: [
- makeHistoryEntry({
- id: "history-1",
- cwd: "/repo",
- updatedAt: "2026-04-02T10:00:00.000Z",
- title: "History one",
- }),
- ],
- hasMore: true,
- nextCursor: "cursor-2",
- }),
- )
- .mockResolvedValueOnce(
- makeHistoryPayload({
- entries: [
- makeHistoryEntry({
- id: "history-2",
- cwd: "/repo",
- updatedAt: "2026-04-01T10:00:00.000Z",
- title: "History two",
- archivedAt: "2026-04-01T10:05:00.000Z",
- }),
- ],
- }),
- );
-
- act(() => {
- useSessionStore
- .getState()
- .initializeSession("server-1", mockClient as unknown as DaemonClient);
- useSessionStore.getState().setAgents("server-1", new Map([["active-1", makeActiveAgent()]]));
- });
-
- const { result } = renderAgentHistoryHook();
-
- await waitFor(() => {
- expect(mockClient.fetchAgentHistory).toHaveBeenCalledTimes(1);
- });
-
- expect(mockClient.fetchAgentHistory.mock.calls.map(([options]) => options)).toEqual([
- {
- sort: [{ key: "updated_at", direction: "desc" }],
- page: { limit: 200 },
- },
- ] satisfies FetchAgentHistoryOptions[]);
- await waitFor(() => {
- expect(agentIds(result.current.agents)).toEqual(["history-1"]);
- });
- expect(result.current.hasMore).toBe(true);
-
- await act(async () => {
- result.current.loadMore();
- });
-
- await waitFor(() => {
- expect(mockClient.fetchAgentHistory).toHaveBeenCalledTimes(2);
- });
-
- expect(mockClient.fetchAgentHistory.mock.calls.at(-1)?.[0]).toEqual({
- sort: [{ key: "updated_at", direction: "desc" }],
- page: { limit: 200, cursor: "cursor-2" },
- } satisfies FetchAgentHistoryOptions);
- await waitFor(() => {
- expect(agentIds(result.current.agents)).toEqual(["history-1", "history-2"]);
- });
- expect(result.current.hasMore).toBe(false);
- expect(
- Array.from(useSessionStore.getState().sessions["server-1"]?.agents.keys() ?? []),
- ).toEqual(["active-1"]);
- expect(mockRuntimeStore.refreshAgentDirectory).not.toHaveBeenCalled();
- });
-
- it("waits until history is enabled before calling the history RPC", async () => {
- mockClient.fetchAgentHistory.mockResolvedValue(makeHistoryPayload({ entries: [] }));
-
- const queryClient = createQueryClient();
- const wrapper = ({ children }: { children: React.ReactNode }) =>
- React.createElement(QueryClientProvider, { client: queryClient }, children);
- let enabled = false;
- const { rerender } = renderHook(() => useAgentHistory({ serverId: "server-1", enabled }), {
- wrapper,
- });
- await act(async () => {
- await Promise.resolve();
- });
-
- expect(mockClient.fetchAgentHistory).not.toHaveBeenCalled();
-
- enabled = true;
- rerender();
-
- await waitFor(() => {
- expect(mockClient.fetchAgentHistory).toHaveBeenCalledTimes(1);
- });
- });
-});
diff --git a/packages/app/src/hooks/use-agent-history.ts b/packages/app/src/hooks/use-agent-history.ts
index 0ddf54709..ff9fa0101 100644
--- a/packages/app/src/hooks/use-agent-history.ts
+++ b/packages/app/src/hooks/use-agent-history.ts
@@ -26,13 +26,15 @@ export interface AgentHistoryResult {
loadMore: () => void;
}
-interface AgentHistoryPage {
+export interface AgentHistoryPage {
agents: AggregatedAgent[];
pageInfo: FetchAgentHistoryPageInfo;
}
-async function fetchAgentHistoryPage(input: {
- client: DaemonClient;
+export type AgentHistoryClient = Pick;
+
+export async function fetchAgentHistoryPage(input: {
+ client: AgentHistoryClient;
serverId: string;
cursor: string | null;
}): Promise {
@@ -147,7 +149,3 @@ export function useAgentHistory(options: {
loadMore,
};
}
-
-export const __private__ = {
- fetchAgentHistoryPage,
-};
diff --git a/packages/app/src/hooks/use-agent-initialization.test.ts b/packages/app/src/hooks/use-agent-initialization.test.ts
index cdb9496bf..2c7b9f38b 100644
--- a/packages/app/src/hooks/use-agent-initialization.test.ts
+++ b/packages/app/src/hooks/use-agent-initialization.test.ts
@@ -1,29 +1,39 @@
-// @vitest-environment jsdom
-
-import { act, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useSessionStore } from "@/stores/session-store";
import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy";
import { getInitDeferred, getInitKey, resolveInitDeferred } from "@/utils/agent-initialization";
-import { useAgentInitialization } from "./use-agent-initialization";
+import {
+ createSetAgentInitializing,
+ ensureAgentIsInitialized,
+ refreshAgent,
+} from "./use-agent-initialization";
const serverId = "server-1";
const agentId = "agent-1";
-function makeClient() {
+interface FakeDaemonClient {
+ fetchAgentTimeline: ReturnType;
+ refreshAgent: ReturnType;
+}
+
+function makeClient(): FakeDaemonClient {
return {
fetchAgentTimeline: vi.fn().mockResolvedValue(undefined),
refreshAgent: vi.fn().mockResolvedValue(undefined),
};
}
+function bindSetAgentInitializing() {
+ return createSetAgentInitializing(serverId, useSessionStore.getState().setInitializingAgents);
+}
+
afterEach(() => {
resolveInitDeferred(getInitKey(serverId, agentId));
useSessionStore.setState({ sessions: {}, agentLastActivity: new Map() });
vi.restoreAllMocks();
});
-describe("useAgentInitialization", () => {
+describe("ensureAgentIsInitialized", () => {
it("requests bounded canonical catch-up after the current cursor when authoritative history is loaded", () => {
const client = makeClient();
useSessionStore.getState().initializeSession(serverId, client as never);
@@ -35,12 +45,11 @@ describe("useAgentInitialization", () => {
);
useSessionStore.getState().setAgentAuthoritativeHistoryApplied(serverId, agentId, true);
- const { result } = renderHook(() =>
- useAgentInitialization({ serverId, client: client as never }),
- );
-
- act(() => {
- void result.current.ensureAgentIsInitialized(agentId);
+ void ensureAgentIsInitialized({
+ serverId,
+ agentId,
+ client: client as never,
+ setAgentInitializing: bindSetAgentInitializing(),
});
expect(client.fetchAgentTimeline).toHaveBeenCalledWith(agentId, {
@@ -56,12 +65,11 @@ describe("useAgentInitialization", () => {
const client = makeClient();
useSessionStore.getState().initializeSession(serverId, client as never);
- const { result } = renderHook(() =>
- useAgentInitialization({ serverId, client: client as never }),
- );
-
- act(() => {
- void result.current.ensureAgentIsInitialized(agentId);
+ void ensureAgentIsInitialized({
+ serverId,
+ agentId,
+ client: client as never,
+ setAgentInitializing: bindSetAgentInitializing(),
});
expect(client.fetchAgentTimeline).toHaveBeenCalledWith(agentId, {
@@ -77,20 +85,17 @@ describe("useAgentInitialization", () => {
const client = makeClient();
useSessionStore.getState().initializeSession(serverId, client as never);
- const { result } = renderHook(() =>
- useAgentInitialization({ serverId, client: client as never }),
- );
-
- const promise = result.current.ensureAgentIsInitialized(agentId);
-
- act(() => {
- vi.advanceTimersByTime(29_999);
+ const promise = ensureAgentIsInitialized({
+ serverId,
+ agentId,
+ client: client as never,
+ setAgentInitializing: bindSetAgentInitializing(),
});
+
+ vi.advanceTimersByTime(29_999);
expect(getInitDeferred(getInitKey(serverId, agentId))).toBeDefined();
- act(() => {
- vi.advanceTimersByTime(1);
- });
+ vi.advanceTimersByTime(1);
await expect(promise).rejects.toThrow("History sync timed out after 30s");
expect(getInitDeferred(getInitKey(serverId, agentId))).toBeUndefined();
@@ -99,15 +104,17 @@ describe("useAgentInitialization", () => {
);
vi.useRealTimers();
});
+});
- it("refresh fetches a bounded canonical tail after refreshing the agent", async () => {
+describe("refreshAgent", () => {
+ it("fetches a bounded canonical tail after refreshing the agent", async () => {
const client = makeClient();
- const { result } = renderHook(() =>
- useAgentInitialization({ serverId, client: client as never }),
- );
+ useSessionStore.getState().initializeSession(serverId, client as never);
- await act(async () => {
- await result.current.refreshAgent(agentId);
+ await refreshAgent({
+ agentId,
+ client: client as never,
+ setAgentInitializing: bindSetAgentInitializing(),
});
expect(client.refreshAgent).toHaveBeenCalledWith(agentId);
diff --git a/packages/app/src/hooks/use-agent-initialization.ts b/packages/app/src/hooks/use-agent-initialization.ts
index 6d89a074c..8e1e72deb 100644
--- a/packages/app/src/hooks/use-agent-initialization.ts
+++ b/packages/app/src/hooks/use-agent-initialization.ts
@@ -1,6 +1,6 @@
-import { useCallback } from "react";
-import { useSessionStore } from "@/stores/session-store";
+import { useCallback, useMemo } from "react";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
+import { useSessionStore } from "@/stores/session-store";
import {
attachInitTimeout,
createInitDeferred,
@@ -10,7 +10,109 @@ import {
} from "@/utils/agent-initialization";
import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy";
-const INIT_TIMEOUT_MS = 30_000;
+export const INIT_TIMEOUT_MS = 30_000;
+
+export type SetAgentInitializing = (agentId: string, initializing: boolean) => void;
+
+export interface EnsureAgentIsInitializedInput {
+ serverId: string;
+ agentId: string;
+ client: Pick | null;
+ setAgentInitializing: SetAgentInitializing;
+}
+
+export function ensureAgentIsInitialized(input: EnsureAgentIsInitializedInput): Promise {
+ const { serverId, agentId, client, setAgentInitializing } = input;
+ const key = getInitKey(serverId, agentId);
+ const existing = getInitDeferred(key);
+ if (existing) {
+ return existing.promise;
+ }
+
+ const session = useSessionStore.getState().sessions[serverId];
+ const cursor = session?.agentTimelineCursor.get(agentId);
+ const hasAuthoritativeHistory = session?.agentAuthoritativeHistoryApplied.get(agentId) === true;
+ const timelineRequest =
+ hasAuthoritativeHistory && cursor
+ ? {
+ direction: "after" as const,
+ cursor: { epoch: cursor.epoch, seq: cursor.endSeq },
+ limit: TIMELINE_FETCH_PAGE_SIZE,
+ projection: "canonical" as const,
+ }
+ : {
+ direction: "tail" as const,
+ limit: TIMELINE_FETCH_PAGE_SIZE,
+ projection: "canonical" as const,
+ };
+
+ const deferred = createInitDeferred(key, timelineRequest.direction);
+ const timeoutId = setTimeout(() => {
+ setAgentInitializing(agentId, false);
+ rejectInitDeferred(
+ key,
+ new Error(`History sync timed out after ${Math.round(INIT_TIMEOUT_MS / 1000)}s`),
+ );
+ }, INIT_TIMEOUT_MS);
+ attachInitTimeout(key, timeoutId);
+
+ setAgentInitializing(agentId, true);
+
+ if (!client) {
+ setAgentInitializing(agentId, false);
+ rejectInitDeferred(key, new Error("Host is not connected"));
+ return deferred.promise;
+ }
+
+ client.fetchAgentTimeline(agentId, timelineRequest).catch((error) => {
+ setAgentInitializing(agentId, false);
+ rejectInitDeferred(key, error instanceof Error ? error : new Error(String(error)));
+ });
+
+ return deferred.promise;
+}
+
+export interface RefreshAgentInput {
+ agentId: string;
+ client: Pick | null;
+ setAgentInitializing: SetAgentInitializing;
+}
+
+export async function refreshAgent(input: RefreshAgentInput): Promise {
+ const { agentId, client, setAgentInitializing } = input;
+ if (!client) {
+ throw new Error("Host is not connected");
+ }
+ setAgentInitializing(agentId, true);
+
+ try {
+ await client.refreshAgent(agentId);
+ await client.fetchAgentTimeline(agentId, {
+ direction: "tail",
+ limit: TIMELINE_FETCH_PAGE_SIZE,
+ projection: "canonical",
+ });
+ } catch (error) {
+ setAgentInitializing(agentId, false);
+ throw error;
+ }
+}
+
+export function createSetAgentInitializing(
+ serverId: string,
+ setInitializingAgents: ReturnType["setInitializingAgents"],
+): SetAgentInitializing {
+ return (agentId, initializing) => {
+ setInitializingAgents(serverId, (prev) => {
+ if (prev.get(agentId) === initializing) {
+ return prev;
+ }
+ const next = new Map(prev);
+ next.set(agentId, initializing);
+ return next;
+ });
+ };
+}
export function useAgentInitialization({
serverId,
@@ -20,95 +122,24 @@ export function useAgentInitialization({
client: DaemonClient | null;
}) {
const setInitializingAgents = useSessionStore((state) => state.setInitializingAgents);
- const setAgentInitializing = useCallback(
- (agentId: string, initializing: boolean) => {
- setInitializingAgents(serverId, (prev) => {
- if (prev.get(agentId) === initializing) {
- return prev;
- }
- const next = new Map(prev);
- next.set(agentId, initializing);
- return next;
- });
- },
+ const setAgentInitializing = useMemo(
+ () => createSetAgentInitializing(serverId, setInitializingAgents),
[serverId, setInitializingAgents],
);
- const ensureAgentIsInitialized = useCallback(
- (agentId: string): Promise => {
- const key = getInitKey(serverId, agentId);
- const existing = getInitDeferred(key);
- if (existing) {
- return existing.promise;
- }
-
- const session = useSessionStore.getState().sessions[serverId];
- const cursor = session?.agentTimelineCursor.get(agentId);
- const hasAuthoritativeHistory =
- session?.agentAuthoritativeHistoryApplied.get(agentId) === true;
- const timelineRequest =
- hasAuthoritativeHistory && cursor
- ? {
- direction: "after" as const,
- cursor: { epoch: cursor.epoch, seq: cursor.endSeq },
- limit: TIMELINE_FETCH_PAGE_SIZE,
- projection: "canonical" as const,
- }
- : {
- direction: "tail" as const,
- limit: TIMELINE_FETCH_PAGE_SIZE,
- projection: "canonical" as const,
- };
-
- const deferred = createInitDeferred(key, timelineRequest.direction);
- const timeoutId = setTimeout(() => {
- setAgentInitializing(agentId, false);
- rejectInitDeferred(
- key,
- new Error(`History sync timed out after ${Math.round(INIT_TIMEOUT_MS / 1000)}s`),
- );
- }, INIT_TIMEOUT_MS);
- attachInitTimeout(key, timeoutId);
-
- setAgentInitializing(agentId, true);
-
- if (!client) {
- setAgentInitializing(agentId, false);
- rejectInitDeferred(key, new Error("Host is not connected"));
- return deferred.promise;
- }
-
- client.fetchAgentTimeline(agentId, timelineRequest).catch((error) => {
- setAgentInitializing(agentId, false);
- rejectInitDeferred(key, error instanceof Error ? error : new Error(String(error)));
- });
-
- return deferred.promise;
- },
+ const ensureAgentIsInitializedCallback = useCallback(
+ (agentId: string): Promise =>
+ ensureAgentIsInitialized({ serverId, agentId, client, setAgentInitializing }),
[client, serverId, setAgentInitializing],
);
- const refreshAgent = useCallback(
- async (agentId: string) => {
- if (!client) {
- throw new Error("Host is not connected");
- }
- setAgentInitializing(agentId, true);
-
- try {
- await client.refreshAgent(agentId);
- await client.fetchAgentTimeline(agentId, {
- direction: "tail",
- limit: TIMELINE_FETCH_PAGE_SIZE,
- projection: "canonical",
- });
- } catch (error) {
- setAgentInitializing(agentId, false);
- throw error;
- }
- },
+ const refreshAgentCallback = useCallback(
+ (agentId: string): Promise => refreshAgent({ agentId, client, setAgentInitializing }),
[client, setAgentInitializing],
);
- return { ensureAgentIsInitialized, refreshAgent };
+ return {
+ ensureAgentIsInitialized: ensureAgentIsInitializedCallback,
+ refreshAgent: refreshAgentCallback,
+ };
}
diff --git a/packages/app/src/hooks/use-archive-agent.test.ts b/packages/app/src/hooks/use-archive-agent.test.ts
index bd05954f0..429fdc602 100644
--- a/packages/app/src/hooks/use-archive-agent.test.ts
+++ b/packages/app/src/hooks/use-archive-agent.test.ts
@@ -1,18 +1,15 @@
-/**
- * @vitest-environment jsdom
- */
-import React from "react";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
-import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import { act, renderHook, waitFor } from "@testing-library/react";
+import { QueryClient } from "@tanstack/react-query";
import { beforeEach, describe, expect, it } from "vitest";
import type { Agent } from "@/stores/session-store";
import { useSessionStore } from "@/stores/session-store";
import { agentHistoryQueryKey } from "./agent-history-query-key";
import {
- __private__,
applyArchivedAgentCloseResults,
- usePendingArchiveAgentIds,
+ isAgentArchiving,
+ removeAgentFromListPayload,
+ selectPendingArchiveAgentIds,
+ setAgentArchiving,
} from "./use-archive-agent";
function makeAgent(overrides: Partial = {}): Agent {
@@ -56,14 +53,14 @@ describe("useArchiveAgent", () => {
const queryClient = new QueryClient();
expect(
- __private__.isAgentArchiving({
+ isAgentArchiving({
queryClient,
serverId: "server-a",
agentId: "agent-1",
}),
).toBe(false);
- __private__.setAgentArchiving({
+ setAgentArchiving({
queryClient,
serverId: "server-a",
agentId: "agent-1",
@@ -71,21 +68,21 @@ describe("useArchiveAgent", () => {
});
expect(
- __private__.isAgentArchiving({
+ isAgentArchiving({
queryClient,
serverId: "server-a",
agentId: "agent-1",
}),
).toBe(true);
expect(
- __private__.isAgentArchiving({
+ isAgentArchiving({
queryClient,
serverId: "server-a",
agentId: "agent-2",
}),
).toBe(false);
- __private__.setAgentArchiving({
+ setAgentArchiving({
queryClient,
serverId: "server-a",
agentId: "agent-1",
@@ -93,7 +90,7 @@ describe("useArchiveAgent", () => {
});
expect(
- __private__.isAgentArchiving({
+ isAgentArchiving({
queryClient,
serverId: "server-a",
agentId: "agent-1",
@@ -102,7 +99,7 @@ describe("useArchiveAgent", () => {
});
it("selects pending archive ids for a single server", () => {
- const pendingIds = __private__.selectPendingArchiveAgentIds(
+ const pendingIds = selectPendingArchiveAgentIds(
{
"server-a:agent-1": true,
"server-a:agent-2": true,
@@ -114,42 +111,13 @@ describe("useArchiveAgent", () => {
expect(Array.from(pendingIds)).toEqual(["agent-1", "agent-2"]);
});
- it("subscribes to pending archive ids through react-query", async () => {
- const queryClient = new QueryClient();
- const wrapper = ({ children }: { children: React.ReactNode }) =>
- React.createElement(QueryClientProvider, { client: queryClient }, children);
-
- const { result } = renderHook(() => usePendingArchiveAgentIds("server-a"), { wrapper });
-
- expect(Array.from(result.current)).toEqual([]);
-
- act(() => {
- __private__.setAgentArchiving({
- queryClient,
- serverId: "server-a",
- agentId: "agent-1",
- isArchiving: true,
- });
- __private__.setAgentArchiving({
- queryClient,
- serverId: "server-b",
- agentId: "agent-2",
- isArchiving: true,
- });
- });
-
- await waitFor(() => {
- expect(Array.from(result.current)).toEqual(["agent-1"]);
- });
- });
-
it("removes an archived agent from cached list payloads", () => {
const payload = {
entries: [{ agent: { id: "agent-1" } }, { agent: { id: "agent-2" } }],
pageInfo: { hasMore: false },
};
- const next = __private__.removeAgentFromListPayload(payload, "agent-1");
+ const next = removeAgentFromListPayload(payload, "agent-1");
expect(next.entries).toEqual([{ agent: { id: "agent-2" } }]);
expect(next.pageInfo).toEqual({ hasMore: false });
diff --git a/packages/app/src/hooks/use-archive-agent.ts b/packages/app/src/hooks/use-archive-agent.ts
index 6122893ae..333e0b71c 100644
--- a/packages/app/src/hooks/use-archive-agent.ts
+++ b/packages/app/src/hooks/use-archive-agent.ts
@@ -11,12 +11,7 @@ export interface ArchiveAgentInput {
agentId: string;
}
-export interface ArchivedAgentCloseResult {
- agentId: string;
- archivedAt: string;
-}
-
-type ArchiveAgentPendingState = Record;
+export type ArchiveAgentPendingState = Record;
interface SetAgentArchivingInput extends ArchiveAgentInput {
queryClient: QueryClient;
@@ -27,35 +22,24 @@ interface IsAgentArchivingInput extends ArchiveAgentInput {
queryClient: QueryClient;
}
-interface AgentsListQueryData {
+export interface AgentsListQueryData {
entries?: Array<{ agent?: { id?: string | null } | null } | null>;
}
-interface ArchivedAgentListCacheSnapshot {
- sidebarAgentsList: AgentsListQueryData | undefined;
- allAgents: AgentsListQueryData | undefined;
- agentHistory: AgentHistoryQueryData | undefined;
-}
-
-interface ArchiveAgentMutationContext {
- agent: ReturnType;
- lists: ArchivedAgentListCacheSnapshot;
-}
-
-interface AgentHistoryQueryAgent {
+export interface AgentHistoryQueryAgent {
id?: string | null;
archivedAt?: Date | null;
}
-interface AgentHistoryQueryPage {
+export interface AgentHistoryQueryPage {
agents?: AgentHistoryQueryAgent[];
}
-interface AgentHistoryQueryData {
+export interface AgentHistoryQueryData {
pages?: AgentHistoryQueryPage[];
}
-function toArchiveKey(input: ArchiveAgentInput): string {
+export function toArchiveKey(input: ArchiveAgentInput): string {
const serverId = input.serverId.trim();
const agentId = input.agentId.trim();
if (!serverId || !agentId) {
@@ -64,11 +48,11 @@ function toArchiveKey(input: ArchiveAgentInput): string {
return `${serverId}:${agentId}`;
}
-function readPendingState(queryClient: QueryClient): ArchiveAgentPendingState {
+export function readPendingState(queryClient: QueryClient): ArchiveAgentPendingState {
return queryClient.getQueryData(ARCHIVE_AGENT_PENDING_QUERY_KEY) ?? {};
}
-function selectPendingArchiveAgentIds(
+export function selectPendingArchiveAgentIds(
pendingState: ArchiveAgentPendingState,
serverId: string,
): ReadonlySet {
@@ -97,7 +81,7 @@ function selectPendingArchiveAgentIds(
return new Set(agentIds);
}
-function setAgentArchiving(input: SetAgentArchivingInput): void {
+export function setAgentArchiving(input: SetAgentArchivingInput): void {
const key = toArchiveKey(input);
if (!key) {
return;
@@ -125,7 +109,7 @@ function setAgentArchiving(input: SetAgentArchivingInput): void {
);
}
-function isAgentArchiving(input: IsAgentArchivingInput): boolean {
+export function isAgentArchiving(input: IsAgentArchivingInput): boolean {
const key = toArchiveKey(input);
if (!key) {
return false;
@@ -133,7 +117,7 @@ function isAgentArchiving(input: IsAgentArchivingInput): boolean {
return readPendingState(input.queryClient)[key] ?? false;
}
-function removeAgentFromListPayload(
+export function removeAgentFromListPayload(
payload: T,
agentId: string,
): T {
@@ -150,7 +134,10 @@ function removeAgentFromListPayload(
} as T;
}
-function removeAgentFromCachedLists(queryClient: QueryClient, input: ArchiveAgentInput): void {
+export function removeAgentFromCachedLists(
+ queryClient: QueryClient,
+ input: ArchiveAgentInput,
+): void {
const agentId = input.agentId.trim();
if (!agentId) {
return;
@@ -166,7 +153,7 @@ function removeAgentFromCachedLists(queryClient: QueryClient, input: ArchiveAgen
);
}
-function markAgentArchivedInHistoryPayload(
+export function markAgentArchivedInHistoryPayload(
payload: T,
input: ArchiveAgentInput & { archivedAt: string },
): T {
@@ -204,7 +191,7 @@ function markAgentArchivedInHistoryPayload;
+ lists: ArchivedAgentListCacheSnapshot;
+}
+
function getStoredAgentSnapshot(input: ArchiveAgentInput) {
return useSessionStore.getState().sessions[input.serverId]?.agents.get(input.agentId);
}
@@ -352,13 +362,6 @@ export function applyArchivedAgentCloseResults(input: ApplyArchivedAgentCloseRes
}
}
-export function clearArchiveAgentPending(input: IsAgentArchivingInput): void {
- setAgentArchiving({
- ...input,
- isArchiving: false,
- });
-}
-
function useArchiveAgentPendingQuery() {
return useQuery({
queryKey: ARCHIVE_AGENT_PENDING_QUERY_KEY,
@@ -472,13 +475,3 @@ export function useArchiveAgent() {
isArchivingAgent,
};
}
-
-export const __private__ = {
- toArchiveKey,
- readPendingState,
- selectPendingArchiveAgentIds,
- setAgentArchiving,
- isAgentArchiving,
- removeAgentFromListPayload,
- markAgentArchivedInHistoryPayload,
-};
diff --git a/packages/app/src/hooks/use-changes-preferences.test.ts b/packages/app/src/hooks/use-changes-preferences.test.ts
deleted file mode 100644
index 04451a73d..000000000
--- a/packages/app/src/hooks/use-changes-preferences.test.ts
+++ /dev/null
@@ -1,74 +0,0 @@
-import { beforeEach, describe, expect, it, vi } from "vitest";
-
-const asyncStorageMock = vi.hoisted(() => ({
- getItem: vi.fn<(_: string) => Promise>(),
- setItem: vi.fn<(_: string, __: string) => Promise>(),
-}));
-
-vi.mock("@react-native-async-storage/async-storage", () => ({
- default: asyncStorageMock,
-}));
-
-describe("use-changes-preferences", () => {
- beforeEach(() => {
- vi.resetModules();
- asyncStorageMock.getItem.mockReset();
- asyncStorageMock.setItem.mockReset();
- });
-
- it("defaults to unified layout with visible whitespace", async () => {
- asyncStorageMock.getItem.mockResolvedValue(null);
- asyncStorageMock.setItem.mockResolvedValue();
-
- const mod = await import("./use-changes-preferences");
- const result = await mod.loadChangesPreferencesFromStorage();
-
- expect(result).toEqual(mod.DEFAULT_CHANGES_PREFERENCES);
- expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
- "@paseo:changes-preferences",
- JSON.stringify(mod.DEFAULT_CHANGES_PREFERENCES),
- );
- });
-
- it("migrates the legacy wrap-lines toggle into the new preferences object", async () => {
- asyncStorageMock.getItem.mockImplementation(async (key: string) => {
- if (key === "diff-wrap-lines") {
- return "true";
- }
- return null;
- });
- asyncStorageMock.setItem.mockResolvedValue();
-
- const mod = await import("./use-changes-preferences");
- const result = await mod.loadChangesPreferencesFromStorage();
-
- expect(result).toEqual({
- layout: "unified",
- wrapLines: true,
- hideWhitespace: false,
- });
- });
-
- it("loads persisted layout and whitespace preferences", async () => {
- asyncStorageMock.getItem.mockImplementation(async (key: string) => {
- if (key === "@paseo:changes-preferences") {
- return JSON.stringify({
- layout: "split",
- hideWhitespace: true,
- wrapLines: false,
- });
- }
- return null;
- });
-
- const mod = await import("./use-changes-preferences");
- const result = await mod.loadChangesPreferencesFromStorage();
-
- expect(result).toEqual({
- layout: "split",
- hideWhitespace: true,
- wrapLines: false,
- });
- expect(asyncStorageMock.setItem).not.toHaveBeenCalled();
- });
-});
diff --git a/packages/app/src/hooks/use-changes-preferences.ts b/packages/app/src/hooks/use-changes-preferences.ts
deleted file mode 100644
index cfc6ee57b..000000000
--- a/packages/app/src/hooks/use-changes-preferences.ts
+++ /dev/null
@@ -1,89 +0,0 @@
-import { useCallback } from "react";
-import AsyncStorage from "@react-native-async-storage/async-storage";
-import { useQuery, useQueryClient } from "@tanstack/react-query";
-import { z } from "zod";
-
-const CHANGES_PREFERENCES_STORAGE_KEY = "@paseo:changes-preferences";
-const LEGACY_WRAP_LINES_STORAGE_KEY = "diff-wrap-lines";
-const CHANGES_PREFERENCES_QUERY_KEY = ["changes-preferences"];
-
-const changesPreferencesSchema = z.object({
- layout: z.enum(["unified", "split"]).optional(),
- wrapLines: z.boolean().optional(),
- hideWhitespace: z.boolean().optional(),
-});
-
-export interface ChangesPreferences {
- layout: "unified" | "split";
- wrapLines: boolean;
- hideWhitespace: boolean;
-}
-
-export const DEFAULT_CHANGES_PREFERENCES: ChangesPreferences = {
- layout: "unified",
- wrapLines: false,
- hideWhitespace: false,
-};
-
-async function loadLegacyWrapLinesPreference(): Promise {
- const legacyValue = await AsyncStorage.getItem(LEGACY_WRAP_LINES_STORAGE_KEY);
- if (legacyValue === "true") {
- return true;
- }
- if (legacyValue === "false") {
- return false;
- }
- return null;
-}
-
-export async function loadChangesPreferencesFromStorage(): Promise {
- const stored = await AsyncStorage.getItem(CHANGES_PREFERENCES_STORAGE_KEY);
- if (stored) {
- const parsed = changesPreferencesSchema.safeParse(JSON.parse(stored));
- if (parsed.success) {
- return { ...DEFAULT_CHANGES_PREFERENCES, ...parsed.data };
- }
- }
-
- const legacyWrapLines = await loadLegacyWrapLinesPreference();
- const next = {
- ...DEFAULT_CHANGES_PREFERENCES,
- ...(legacyWrapLines !== null ? { wrapLines: legacyWrapLines } : {}),
- } satisfies ChangesPreferences;
- await AsyncStorage.setItem(CHANGES_PREFERENCES_STORAGE_KEY, JSON.stringify(next));
- return next;
-}
-
-export interface UseChangesPreferencesReturn {
- preferences: ChangesPreferences;
- isLoading: boolean;
- updatePreferences: (updates: Partial) => Promise;
-}
-
-export function useChangesPreferences(): UseChangesPreferencesReturn {
- const queryClient = useQueryClient();
- const { data, isPending } = useQuery({
- queryKey: CHANGES_PREFERENCES_QUERY_KEY,
- queryFn: loadChangesPreferencesFromStorage,
- staleTime: Infinity,
- gcTime: Infinity,
- });
-
- const updatePreferences = useCallback(
- async (updates: Partial) => {
- const prev =
- queryClient.getQueryData(CHANGES_PREFERENCES_QUERY_KEY) ??
- DEFAULT_CHANGES_PREFERENCES;
- const next = { ...prev, ...updates };
- queryClient.setQueryData