diff --git a/docs/testing.md b/docs/testing.md index f9488ec44..2c6a30705 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -157,3 +157,12 @@ If code isn't testable, refactor it. Signs: - Setup requires too much global state Aim for deep modules: small interface, deep implementation. Fewer methods = fewer tests needed, simpler params = simpler setup. + +## Two test categories, no others + +Every test in this repo lives in exactly one of these shapes: + +1. **Unit tests with ports and adapters** — production code receives its real-world dependencies (DB, HTTP, CLI process, clock, randomness, filesystem, other modules) through an injected interface. Tests wire a typed in-memory fake colocated with the production module. **No `vi.mock`, `vi.hoisted`, `vi.spyOn` of own exports, JSDOM, `@testing-library` component mounting, RN test renderer, monkey-patched globals, or fake-server fixtures.** If a test needs any of those, the production module is missing a port — fix the seam, then write the test against a fake adapter. +2. **Real end-to-end tests** — real daemon, real network, real browser (Playwright for app code) or a real isolated server instance (for daemon code). No JSDOM, no mocked transport. + +Anything in between — component tests in JSDOM, vitest tests that mock the module under test, tests that assert on private state — is slop on its way out. diff --git a/packages/app/src/app/index.test.tsx b/packages/app/src/app/index.test.tsx deleted file mode 100644 index 0740675ad..000000000 --- a/packages/app/src/app/index.test.tsx +++ /dev/null @@ -1,149 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import React from "react"; -import { act } from "@testing-library/react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { HostRuntimeBootstrapState } from "./_layout"; -import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; - -const { redirectMock, state } = vi.hoisted(() => { - const hoistedState = { - pathname: "/", - bootstrapState: { - splashError: null, - retry: vi.fn(), - hasGivenUpWaitingForHost: false, - storeReady: false, - } as HostRuntimeBootstrapState, - anyOnlineHostServerId: null as string | null, - isWorkspaceSelectionLoaded: true, - workspaceSelection: null as ActiveWorkspaceSelection | null, - }; - - return { - redirectMock: vi.fn(), - state: hoistedState, - }; -}); - -vi.mock("expo-router", () => ({ - Redirect: ({ href }: { href: string }) => { - redirectMock(href); - return React.createElement("div", { "data-testid": "redirect", "data-href": href }); - }, - usePathname: () => state.pathname, -})); - -vi.mock("@/app/_layout", () => ({ - useHostRuntimeBootstrapState: () => state.bootstrapState, - useEarliestOnlineHostServerId: () => state.anyOnlineHostServerId, -})); - -vi.mock("@/desktop/daemon/desktop-daemon", () => ({ - shouldUseDesktopDaemon: () => false, -})); - -vi.mock("@/screens/startup-splash-screen", () => ({ - StartupSplashScreen: () => React.createElement("div", { "data-testid": "startup-splash" }), -})); - -vi.mock("@/stores/navigation-active-workspace-store", () => ({ - useIsLastWorkspaceSelectionHydrated: () => state.isWorkspaceSelectionLoaded, - useLastWorkspaceSelection: () => state.workspaceSelection, -})); - -describe("Index route startup navigation", () => { - let container: HTMLDivElement; - let root: Root; - - beforeEach(() => { - vi.resetModules(); - state.pathname = "/"; - state.bootstrapState = { - splashError: null, - retry: vi.fn(), - hasGivenUpWaitingForHost: false, - storeReady: false, - }; - state.anyOnlineHostServerId = null; - state.isWorkspaceSelectionLoaded = true; - state.workspaceSelection = null; - redirectMock.mockReset(); - - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - }); - - afterEach(() => { - act(() => { - root.unmount(); - }); - container.remove(); - }); - - async function renderIndex() { - const { default: Index } = await import("./index"); - await act(async () => { - root.render(); - }); - } - - it("shows the startup splash while no host is online and the welcome timer has not fired", async () => { - await renderIndex(); - - expect(container.querySelector("[data-testid='startup-splash']")).not.toBeNull(); - expect(redirectMock).not.toHaveBeenCalled(); - }); - - it("shows the startup splash while the persisted workspace selection has not loaded", async () => { - state.anyOnlineHostServerId = "server-1"; - state.isWorkspaceSelectionLoaded = false; - - await renderIndex(); - - expect(container.querySelector("[data-testid='startup-splash']")).not.toBeNull(); - expect(redirectMock).not.toHaveBeenCalled(); - }); - - it("restores the persisted workspace when the online host matches its server id", async () => { - state.anyOnlineHostServerId = "server-1"; - state.workspaceSelection = { serverId: "server-1", workspaceId: "workspace-a" }; - - await renderIndex(); - - expect(redirectMock).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a"); - expect(container.querySelector("[data-testid='redirect']")).not.toBeNull(); - }); - - it("restores the persisted workspace even when the first online host is different", async () => { - state.anyOnlineHostServerId = "server-2"; - state.workspaceSelection = { serverId: "server-1", workspaceId: "workspace-a" }; - - await renderIndex(); - - expect(redirectMock).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a"); - }); - - it("navigates to the host root when no persisted workspace exists", async () => { - state.anyOnlineHostServerId = "server-2"; - state.workspaceSelection = null; - - await renderIndex(); - - expect(redirectMock).toHaveBeenCalledWith("/h/server-2"); - }); - - it("falls back to welcome when the give-up timer fires with no host online", async () => { - state.bootstrapState = { - ...state.bootstrapState, - hasGivenUpWaitingForHost: true, - }; - - await renderIndex(); - - expect(redirectMock).toHaveBeenCalledWith("/welcome"); - }); -}); diff --git a/packages/app/src/attachments/attachment-file-system.ts b/packages/app/src/attachments/attachment-file-system.ts new file mode 100644 index 000000000..30a22a776 --- /dev/null +++ b/packages/app/src/attachments/attachment-file-system.ts @@ -0,0 +1,54 @@ +import { File } from "expo-file-system"; +import * as FileSystem from "expo-file-system/legacy"; + +export type AttachmentFileInfo = + | { exists: true; isDirectory: boolean; size: number | null } + | { exists: false }; + +export interface AttachmentFileSystem { + readonly cacheDirectory: string | null; + getInfo(uri: string): Promise; + makeDirectory(uri: string, options: { intermediates: boolean }): Promise; + writeBytes(uri: string, bytes: Uint8Array): Promise; + copy(input: { from: string; to: string }): Promise; + readAsBase64(uri: string): Promise; + delete(uri: string, options: { idempotent: boolean }): Promise; + listDirectory(uri: string): Promise; +} + +export function createExpoAttachmentFileSystem(): AttachmentFileSystem { + return { + cacheDirectory: FileSystem.cacheDirectory, + async getInfo(uri) { + const info = await FileSystem.getInfoAsync(uri); + if (!info.exists) { + return { exists: false }; + } + const size = + typeof (info as { size?: number }).size === "number" + ? (info as { size: number }).size + : null; + return { exists: true, isDirectory: info.isDirectory ?? false, size }; + }, + async makeDirectory(uri, options) { + await FileSystem.makeDirectoryAsync(uri, options); + }, + async writeBytes(uri, bytes) { + new File(uri).write(bytes); + }, + async copy(input) { + await FileSystem.copyAsync(input); + }, + async readAsBase64(uri) { + return await FileSystem.readAsStringAsync(uri, { + encoding: FileSystem.EncodingType.Base64, + }); + }, + async delete(uri, options) { + await FileSystem.deleteAsync(uri, options); + }, + async listDirectory(uri) { + return await FileSystem.readDirectoryAsync(uri); + }, + }; +} diff --git a/packages/app/src/attachments/local-file-attachment-store.test.ts b/packages/app/src/attachments/local-file-attachment-store.test.ts index a9cc02bc0..48f974fa1 100644 --- a/packages/app/src/attachments/local-file-attachment-store.test.ts +++ b/packages/app/src/attachments/local-file-attachment-store.test.ts @@ -1,64 +1,14 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { createLocalFileAttachmentStore } from "./local-file-attachment-store"; - -const fileSystemMock = vi.hoisted(() => ({ - getInfoAsync: vi.fn(async (uri: string) => { - if (uri.endsWith("/preview-assets/")) { - return { exists: true, isDirectory: true }; - } - return { exists: true, isDirectory: false, size: 4 }; - }), - makeDirectoryAsync: vi.fn(async () => {}), - writeAsStringAsync: vi.fn(async () => {}), - copyAsync: vi.fn(async () => {}), - readAsStringAsync: vi.fn(async () => "AAECAw=="), - deleteAsync: vi.fn(async () => {}), - readDirectoryAsync: vi.fn(async () => []), - fileWrite: vi.fn((_uri: string, _content: Uint8Array) => {}), -})); - -vi.mock("expo-file-system", () => ({ - File: class { - uri: string; - - constructor(uri: string) { - this.uri = uri; - } - - write(content: Uint8Array) { - fileSystemMock.fileWrite(this.uri, content); - } - }, -})); - -vi.mock("expo-file-system/legacy", () => ({ - cacheDirectory: "file:///cache/", - EncodingType: { Base64: "base64" }, - getInfoAsync: fileSystemMock.getInfoAsync, - makeDirectoryAsync: fileSystemMock.makeDirectoryAsync, - writeAsStringAsync: fileSystemMock.writeAsStringAsync, - copyAsync: fileSystemMock.copyAsync, - readAsStringAsync: fileSystemMock.readAsStringAsync, - deleteAsync: fileSystemMock.deleteAsync, - readDirectoryAsync: fileSystemMock.readDirectoryAsync, -})); +import { createTestAttachmentFileSystem } from "./test-attachment-file-system"; describe("local file attachment store", () => { - beforeEach(() => { - fileSystemMock.getInfoAsync.mockClear(); - fileSystemMock.makeDirectoryAsync.mockClear(); - fileSystemMock.writeAsStringAsync.mockClear(); - fileSystemMock.copyAsync.mockClear(); - fileSystemMock.readAsStringAsync.mockClear(); - fileSystemMock.deleteAsync.mockClear(); - fileSystemMock.readDirectoryAsync.mockClear(); - fileSystemMock.fileWrite.mockClear(); - }); - it("writes raw byte sources directly to the managed file path", async () => { + const fileSystem = createTestAttachmentFileSystem(); const store = createLocalFileAttachmentStore({ storageType: "native-file", baseDirectoryName: "preview-assets", + fileSystem, resolvePreviewUrl: async (attachment) => `file://${attachment.storageKey}`, }); @@ -69,11 +19,6 @@ describe("local file attachment store", () => { source: { kind: "bytes", bytes: new Uint8Array([0, 1, 2, 3]) }, }); - expect(fileSystemMock.fileWrite).toHaveBeenCalledWith( - "file:///cache/preview-assets/preview_8_test.png", - new Uint8Array([0, 1, 2, 3]), - ); - expect(fileSystemMock.writeAsStringAsync).not.toHaveBeenCalled(); expect(attachment).toMatchObject({ id: "preview_8_test", mimeType: "image/png", @@ -82,5 +27,9 @@ describe("local file attachment store", () => { fileName: "result.png", byteSize: 4, }); + expect(fileSystem.files.get("file:///cache/preview-assets/preview_8_test.png")).toEqual( + new Uint8Array([0, 1, 2, 3]), + ); + expect(fileSystem.directories.has("file:///cache/preview-assets")).toBe(true); }); }); diff --git a/packages/app/src/attachments/local-file-attachment-store.ts b/packages/app/src/attachments/local-file-attachment-store.ts index 1ed2bd69e..b9e76a86e 100644 --- a/packages/app/src/attachments/local-file-attachment-store.ts +++ b/packages/app/src/attachments/local-file-attachment-store.ts @@ -1,5 +1,4 @@ -import { File } from "expo-file-system"; -import * as FileSystem from "expo-file-system/legacy"; +import type { AttachmentFileSystem } from "@/attachments/attachment-file-system"; import { type AttachmentStore, type AttachmentStorageType, @@ -37,12 +36,12 @@ function extensionForAttachment(params: { fileName?: string | null; mimeType: st return IMAGE_EXTENSION_BY_MIME_TYPE[params.mimeType] ?? ".img"; } -async function ensureDirectory(uri: string): Promise { - const info = await FileSystem.getInfoAsync(uri); +async function ensureDirectory(fileSystem: AttachmentFileSystem, uri: string): Promise { + const info = await fileSystem.getInfo(uri); if (info.exists && info.isDirectory) { return; } - await FileSystem.makeDirectoryAsync(uri, { intermediates: true }); + await fileSystem.makeDirectory(uri, { intermediates: true }); } async function dataUrlToBytes(dataUrl: string): Promise { @@ -55,6 +54,7 @@ async function blobToBytes(blob: Blob): Promise { } async function writeFromSource(input: { + fileSystem: AttachmentFileSystem; source: SaveAttachmentInput["source"]; targetUri: string; mimeType: string; @@ -64,7 +64,7 @@ async function writeFromSource(input: { if (from === input.targetUri) { return; } - await FileSystem.copyAsync({ from, to: input.targetUri }); + await input.fileSystem.copy({ from, to: input.targetUri }); return; } @@ -77,7 +77,7 @@ async function writeFromSource(input: { bytes = input.source.bytes; } - new File(input.targetUri).write(bytes); + await input.fileSystem.writeBytes(input.targetUri, bytes); } function attachmentUri(metadata: AttachmentMetadata): string { @@ -87,11 +87,13 @@ function attachmentUri(metadata: AttachmentMetadata): string { export function createLocalFileAttachmentStore(params: { storageType: Extract; baseDirectoryName: string; + fileSystem: AttachmentFileSystem; resolvePreviewUrl: (attachment: AttachmentMetadata) => Promise; releasePreviewUrl?: (input: { attachment: AttachmentMetadata; url: string }) => Promise; }): AttachmentStore { - const baseDirectory = FileSystem.cacheDirectory - ? `${FileSystem.cacheDirectory}${params.baseDirectoryName}/` + const { fileSystem } = params; + const baseDirectory = fileSystem.cacheDirectory + ? `${fileSystem.cacheDirectory}${params.baseDirectoryName}/` : null; async function resolveTarget(input: SaveAttachmentInput): Promise<{ @@ -103,10 +105,10 @@ export function createLocalFileAttachmentStore(params: { storageKey: string; }> { if (!baseDirectory) { - throw new Error("expo-file-system cacheDirectory is unavailable."); + throw new Error("Attachment file-system cacheDirectory is unavailable."); } - await ensureDirectory(baseDirectory); + await ensureDirectory(fileSystem, baseDirectory); const id = input.id ?? generateAttachmentId(); let mimeTypeFromSource: string | undefined; @@ -140,16 +142,14 @@ export function createLocalFileAttachmentStore(params: { async save(input): Promise { const target = await resolveTarget(input); await writeFromSource({ + fileSystem, source: input.source, targetUri: target.targetUri, mimeType: target.mimeType, }); - const info = await FileSystem.getInfoAsync(target.targetUri); - const byteSize = - info.exists && typeof (info as { size?: number }).size === "number" - ? (info as { size: number }).size - : null; + const info = await fileSystem.getInfo(target.targetUri); + const byteSize = info.exists ? info.size : null; return { id: target.id, mimeType: target.mimeType, @@ -162,10 +162,7 @@ export function createLocalFileAttachmentStore(params: { }, async encodeBase64({ attachment }): Promise { - const uri = attachmentUri(attachment); - return await FileSystem.readAsStringAsync(uri, { - encoding: FileSystem.EncodingType.Base64, - }); + return await fileSystem.readAsBase64(attachmentUri(attachment)); }, async resolvePreviewUrl({ attachment }): Promise { @@ -184,22 +181,22 @@ export function createLocalFileAttachmentStore(params: { : {}), async delete({ attachment }): Promise { - await FileSystem.deleteAsync(attachmentUri(attachment), { idempotent: true }); + await fileSystem.delete(attachmentUri(attachment), { idempotent: true }); }, async garbageCollect({ referencedIds }): Promise { if (!baseDirectory) { return; } - await ensureDirectory(baseDirectory); - const entries = await FileSystem.readDirectoryAsync(baseDirectory); + await ensureDirectory(fileSystem, baseDirectory); + const entries = await fileSystem.listDirectory(baseDirectory); await Promise.all( entries.map(async (entryName) => { const id = entryName.split(".", 1)[0] ?? ""; if (!id || referencedIds.has(id)) { return; } - await FileSystem.deleteAsync(`${baseDirectory}${entryName}`, { + await fileSystem.delete(`${baseDirectory}${entryName}`, { idempotent: true, }); }), diff --git a/packages/app/src/attachments/native/native-file-attachment-store.ts b/packages/app/src/attachments/native/native-file-attachment-store.ts index 78c3d97cd..0d5135563 100644 --- a/packages/app/src/attachments/native/native-file-attachment-store.ts +++ b/packages/app/src/attachments/native/native-file-attachment-store.ts @@ -1,3 +1,4 @@ +import { createExpoAttachmentFileSystem } from "@/attachments/attachment-file-system"; import { createLocalFileAttachmentStore } from "@/attachments/local-file-attachment-store"; import { isAbsolutePath } from "@/utils/path"; @@ -5,6 +6,7 @@ export function createNativeFileAttachmentStore() { return createLocalFileAttachmentStore({ storageType: "native-file", baseDirectoryName: "paseo-native-attachments", + fileSystem: createExpoAttachmentFileSystem(), resolvePreviewUrl: async (attachment) => { if (attachment.storageKey.startsWith("file://")) { return attachment.storageKey; diff --git a/packages/app/src/attachments/store.ts b/packages/app/src/attachments/store.ts index ffcfc6e50..8371d3e45 100644 --- a/packages/app/src/attachments/store.ts +++ b/packages/app/src/attachments/store.ts @@ -9,7 +9,9 @@ async function createAttachmentStore(): Promise { if (isElectronRuntime()) { const { createDesktopAttachmentStore } = await import("../desktop/attachments/desktop-attachment-store"); - return createDesktopAttachmentStore(); + const { createDesktopAttachmentBridge } = + await import("../desktop/attachments/desktop-attachment-bridge"); + return createDesktopAttachmentStore(createDesktopAttachmentBridge()); } const { createIndexedDbAttachmentStore } = await import("./web/indexeddb-attachment-store"); diff --git a/packages/app/src/attachments/test-attachment-file-system.ts b/packages/app/src/attachments/test-attachment-file-system.ts new file mode 100644 index 000000000..f035da96a --- /dev/null +++ b/packages/app/src/attachments/test-attachment-file-system.ts @@ -0,0 +1,95 @@ +import type { AttachmentFileInfo, AttachmentFileSystem } from "./attachment-file-system"; + +export interface TestAttachmentFileSystem extends AttachmentFileSystem { + readonly files: ReadonlyMap; + readonly directories: ReadonlySet; + setFile(uri: string, bytes: Uint8Array): void; + setDirectory(uri: string): void; +} + +export function createTestAttachmentFileSystem(options?: { + cacheDirectory?: string | null; +}): TestAttachmentFileSystem { + const files = new Map(); + const directories = new Set(); + const cacheDirectory = + options && Object.hasOwn(options, "cacheDirectory") + ? (options.cacheDirectory ?? null) + : "file:///cache/"; + + function describe(uri: string): AttachmentFileInfo { + if (directories.has(uri) || directories.has(stripTrailingSlash(uri))) { + return { exists: true, isDirectory: true, size: null }; + } + const bytes = files.get(uri); + if (bytes) { + return { exists: true, isDirectory: false, size: bytes.byteLength }; + } + return { exists: false }; + } + + return { + files, + directories, + cacheDirectory, + setFile(uri, bytes) { + files.set(uri, bytes); + }, + setDirectory(uri) { + directories.add(stripTrailingSlash(uri)); + }, + async getInfo(uri) { + return describe(uri); + }, + async makeDirectory(uri) { + directories.add(stripTrailingSlash(uri)); + }, + async writeBytes(uri, bytes) { + files.set(uri, bytes); + }, + async copy({ from, to }) { + const bytes = files.get(from); + if (!bytes) { + throw new Error(`copy: source does not exist: ${from}`); + } + files.set(to, bytes); + }, + async readAsBase64(uri) { + const bytes = files.get(uri); + if (!bytes) { + throw new Error(`readAsBase64: file does not exist: ${uri}`); + } + return toBase64(bytes); + }, + async delete(uri, deleteOptions) { + if (!files.delete(uri) && !deleteOptions.idempotent) { + throw new Error(`delete: file does not exist: ${uri}`); + } + }, + async listDirectory(uri) { + const prefix = uri.endsWith("/") ? uri : `${uri}/`; + const entries: string[] = []; + for (const path of files.keys()) { + if (path.startsWith(prefix)) { + entries.push(path.slice(prefix.length)); + } + } + return entries; + }, + }; +} + +function stripTrailingSlash(uri: string): string { + return uri.endsWith("/") ? uri.slice(0, -1) : uri; +} + +function toBase64(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + if (typeof btoa === "function") { + return btoa(binary); + } + return Buffer.from(binary, "binary").toString("base64"); +} diff --git a/packages/app/src/components/drag-reorder/drag-reducer.test.ts b/packages/app/src/components/drag-reorder/drag-reducer.test.ts new file mode 100644 index 000000000..623f7cdca --- /dev/null +++ b/packages/app/src/components/drag-reorder/drag-reducer.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { dragStateInitial, dragStateReducer } from "./drag-reducer"; + +describe("dragStateReducer", () => { + it("starts tracking the active item with a snapshot of the data", () => { + const next = dragStateReducer(dragStateInitial(), { + type: "start", + id: "alpha", + data: ["alpha", "beta"], + }); + + expect(next).toEqual({ activeId: "alpha", dragItems: ["alpha", "beta"] }); + }); + + it("clears the active item and the snapshot", () => { + const next = dragStateReducer( + { activeId: "alpha", dragItems: ["alpha", "beta"] }, + { type: "clear" }, + ); + + expect(next).toEqual({ activeId: null, dragItems: null }); + }); + + it("replaces an in-flight drag when a new one starts", () => { + const next = dragStateReducer( + { activeId: "alpha", dragItems: ["alpha", "beta"] }, + { type: "start", id: "beta", data: ["beta", "gamma"] }, + ); + + expect(next).toEqual({ activeId: "beta", dragItems: ["beta", "gamma"] }); + }); +}); diff --git a/packages/app/src/components/drag-reorder/drag-reducer.ts b/packages/app/src/components/drag-reorder/drag-reducer.ts new file mode 100644 index 000000000..b9b508f2d --- /dev/null +++ b/packages/app/src/components/drag-reorder/drag-reducer.ts @@ -0,0 +1,19 @@ +export interface DragState { + activeId: string | null; + dragItems: T[] | null; +} + +export type DragAction = { type: "start"; id: string; data: T[] } | { type: "clear" }; + +export function dragStateInitial(): DragState { + return { activeId: null, dragItems: null }; +} + +export function dragStateReducer(state: DragState, action: DragAction): DragState { + switch (action.type) { + case "start": + return { activeId: action.id, dragItems: action.data }; + case "clear": + return { activeId: null, dragItems: null }; + } +} diff --git a/packages/app/src/components/drag-reorder/index.ts b/packages/app/src/components/drag-reorder/index.ts new file mode 100644 index 000000000..4016bd1e6 --- /dev/null +++ b/packages/app/src/components/drag-reorder/index.ts @@ -0,0 +1,12 @@ +export { reorderItemsOnDragEnd } from "./reorder-items"; +export type { DragEndInput } from "./reorder-items"; +export { + getPointerActivationConstraint, + type PointerActivationConfig, + type PointerActivationConstraint, +} from "./pointer-activation"; +export { + useDragReorderState, + type DragReorderHandlers, + type DragReorderState, +} from "./use-drag-reorder-state"; diff --git a/packages/app/src/components/drag-reorder/pointer-activation.test.ts b/packages/app/src/components/drag-reorder/pointer-activation.test.ts new file mode 100644 index 000000000..d1c6fa041 --- /dev/null +++ b/packages/app/src/components/drag-reorder/pointer-activation.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { getPointerActivationConstraint } from "./pointer-activation"; + +const config = { defaultDistance: 6, holdDelayMs: 250, holdTolerance: 8 }; + +describe("getPointerActivationConstraint", () => { + it("uses distance activation for default draggable rows", () => { + expect(getPointerActivationConstraint(false, config)).toEqual({ distance: 6 }); + }); + + it("requires a held pointer before activating handle-based drags", () => { + expect(getPointerActivationConstraint(true, config)).toEqual({ delay: 250, tolerance: 8 }); + }); +}); diff --git a/packages/app/src/components/drag-reorder/pointer-activation.ts b/packages/app/src/components/drag-reorder/pointer-activation.ts new file mode 100644 index 000000000..cb68e2bda --- /dev/null +++ b/packages/app/src/components/drag-reorder/pointer-activation.ts @@ -0,0 +1,19 @@ +export type PointerActivationConstraint = + | { distance: number } + | { delay: number; tolerance: number }; + +export interface PointerActivationConfig { + defaultDistance: number; + holdDelayMs: number; + holdTolerance: number; +} + +export function getPointerActivationConstraint( + useDragHandle: boolean, + config: PointerActivationConfig, +): PointerActivationConstraint { + if (useDragHandle) { + return { delay: config.holdDelayMs, tolerance: config.holdTolerance }; + } + return { distance: config.defaultDistance }; +} diff --git a/packages/app/src/components/drag-reorder/reorder-items.test.ts b/packages/app/src/components/drag-reorder/reorder-items.test.ts new file mode 100644 index 000000000..29992cf24 --- /dev/null +++ b/packages/app/src/components/drag-reorder/reorder-items.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { reorderItemsOnDragEnd } from "./reorder-items"; + +const items = ["alpha", "beta", "gamma"]; +const byValue = (item: string): string => item; + +describe("reorderItemsOnDragEnd", () => { + it("moves the active item to the over position", () => { + expect( + reorderItemsOnDragEnd({ + items, + activeId: "alpha", + overId: "gamma", + keyExtractor: byValue, + }), + ).toEqual(["beta", "gamma", "alpha"]); + }); + + it("is a no-op when the drop target is missing", () => { + expect( + reorderItemsOnDragEnd({ + items, + activeId: "alpha", + overId: null, + keyExtractor: byValue, + }), + ).toBeNull(); + }); + + it("is a no-op when the active and over items are the same", () => { + expect( + reorderItemsOnDragEnd({ + items, + activeId: "beta", + overId: "beta", + keyExtractor: byValue, + }), + ).toBeNull(); + }); + + it("is a no-op when the active id is not in the list", () => { + expect( + reorderItemsOnDragEnd({ + items, + activeId: "delta", + overId: "beta", + keyExtractor: byValue, + }), + ).toBeNull(); + }); + + it("is a no-op when the over id is not in the list", () => { + expect( + reorderItemsOnDragEnd({ + items, + activeId: "alpha", + overId: "delta", + keyExtractor: byValue, + }), + ).toBeNull(); + }); +}); diff --git a/packages/app/src/components/drag-reorder/reorder-items.ts b/packages/app/src/components/drag-reorder/reorder-items.ts new file mode 100644 index 000000000..b3de8afec --- /dev/null +++ b/packages/app/src/components/drag-reorder/reorder-items.ts @@ -0,0 +1,24 @@ +import { arrayMove } from "@dnd-kit/sortable"; + +export interface DragEndInput { + items: T[]; + activeId: string; + overId: string | null | undefined; + keyExtractor: (item: T, index: number) => string; +} + +export function reorderItemsOnDragEnd({ + items, + activeId, + overId, + keyExtractor, +}: DragEndInput): T[] | null { + if (!overId || activeId === overId) return null; + + const oldIndex = items.findIndex((item, i) => keyExtractor(item, i) === activeId); + const newIndex = items.findIndex((item, i) => keyExtractor(item, i) === overId); + + if (oldIndex < 0 || newIndex < 0 || oldIndex === newIndex) return null; + + return arrayMove(items, oldIndex, newIndex); +} diff --git a/packages/app/src/components/drag-reorder/use-drag-reorder-state.ts b/packages/app/src/components/drag-reorder/use-drag-reorder-state.ts new file mode 100644 index 000000000..400beeb28 --- /dev/null +++ b/packages/app/src/components/drag-reorder/use-drag-reorder-state.ts @@ -0,0 +1,73 @@ +import { useCallback, useReducer } from "react"; +import type { DragEndEvent, DragStartEvent } from "@dnd-kit/core"; +import { dragStateInitial, dragStateReducer } from "./drag-reducer"; +import { reorderItemsOnDragEnd } from "./reorder-items"; + +export interface DragReorderHandlers { + onDragStart: (event: DragStartEvent) => void; + onDragCancel: () => void; + onDragEnd: (event: DragEndEvent) => void; +} + +export interface DragReorderState { + activeId: string | null; + items: T[]; + handlers: DragReorderHandlers; +} + +export function useDragReorderState({ + data, + keyExtractor, + onDragEnd, + onDragBegin, + disabled = false, +}: { + data: T[]; + keyExtractor: (item: T, index: number) => string; + onDragEnd?: (items: T[]) => void; + onDragBegin?: () => void; + disabled?: boolean; +}): DragReorderState { + const [state, dispatch] = useReducer(dragStateReducer, undefined, dragStateInitial); + + const handleDragStart = useCallback( + (event: DragStartEvent) => { + if (disabled) return; + dispatch({ type: "start", id: String(event.active.id), data }); + onDragBegin?.(); + }, + [data, disabled, onDragBegin], + ); + + const clearDragState = useCallback(() => { + dispatch({ type: "clear" }); + }, []); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event; + const items = state.dragItems ?? data; + dispatch({ type: "clear" }); + if (disabled) return; + + const reordered = reorderItemsOnDragEnd({ + items, + activeId: String(active.id), + overId: over ? String(over.id) : null, + keyExtractor, + }); + if (reordered) onDragEnd?.(reordered); + }, + [data, disabled, keyExtractor, onDragEnd, state.dragItems], + ); + + return { + activeId: state.activeId, + items: state.dragItems ?? data, + handlers: { + onDragStart: handleDragStart, + onDragCancel: clearDragState, + onDragEnd: handleDragEnd, + }, + }; +} diff --git a/packages/app/src/components/draggable-list.web.test.tsx b/packages/app/src/components/draggable-list.web.test.tsx deleted file mode 100644 index 29fc391c4..000000000 --- a/packages/app/src/components/draggable-list.web.test.tsx +++ /dev/null @@ -1,167 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import React from "react"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { DraggableList } from "./draggable-list.web"; - -interface DndContextProps { - onDragStart?: (event: { active: { id: string } }) => void; - onDragCancel?: () => void; -} - -let latestDndContextProps: DndContextProps | null = null; -const dndKitMocks = vi.hoisted(() => ({ - useSensor: vi.fn(() => ({})), -})); - -vi.mock("@dnd-kit/core", () => ({ - DndContext: ({ children, ...props }: React.PropsWithChildren) => { - latestDndContextProps = props; - return
{children}
; - }, - closestCenter: vi.fn(), - KeyboardSensor: vi.fn(), - PointerSensor: vi.fn(), - useSensor: dndKitMocks.useSensor, - useSensors: vi.fn(() => []), -})); - -vi.mock("@dnd-kit/sortable", () => ({ - SortableContext: ({ children }: React.PropsWithChildren) => children, - arrayMove: (items: T[], from: number, to: number) => { - const next = [...items]; - const [item] = next.splice(from, 1); - if (item !== undefined) { - next.splice(to, 0, item); - } - return next; - }, - sortableKeyboardCoordinates: vi.fn(), - useSortable: () => ({ - attributes: {}, - listeners: {}, - setNodeRef: vi.fn(), - setActivatorNodeRef: vi.fn(), - transform: null, - transition: undefined, - isDragging: false, - }), - verticalListSortingStrategy: {}, -})); - -vi.mock("./use-web-scrollbar", () => ({ - useWebScrollViewScrollbar: () => ({ - onLayout: vi.fn(), - onContentSizeChange: vi.fn(), - onScroll: vi.fn(), - overlay: null, - }), -})); - -let root: Root | null = null; -let container: HTMLElement | null = null; - -beforeEach(() => { - vi.stubGlobal("React", React); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - vi.stubGlobal( - "ResizeObserver", - class ResizeObserver { - observe() {} - unobserve() {} - disconnect() {} - }, - ); - latestDndContextProps = null; - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); -}); - -afterEach(() => { - if (root) { - act(() => { - root?.unmount(); - }); - } - root = null; - container?.remove(); - container = null; - vi.unstubAllGlobals(); -}); - -const DATA: string[] = ["alpha", "beta"]; - -function keyExtractor(item: string): string { - return item; -} - -function renderItem({ item, isActive }: { item: string; isActive: boolean }) { - return ( -
- {item} -
- ); -} - -function renderList({ useDragHandle = false }: { useDragHandle?: boolean } = {}): void { - act(() => { - root?.render( - , - ); - }); -} - -function getItemActiveState(item: string): string | null { - return ( - container?.querySelector(`[data-testid="item-${item}"]`)?.getAttribute("data-active") ?? null - ); -} - -describe("DraggableList web", () => { - it("uses distance activation for default draggable rows", () => { - renderList(); - - expect(dndKitMocks.useSensor).toHaveBeenCalledWith( - expect.any(Function), - expect.objectContaining({ - activationConstraint: { distance: 6 }, - }), - ); - }); - - it("requires a held pointer before activating handle-based drags", () => { - renderList({ useDragHandle: true }); - - expect(dndKitMocks.useSensor).toHaveBeenCalledWith( - expect.any(Function), - expect.objectContaining({ - activationConstraint: { delay: 250, tolerance: 8 }, - }), - ); - }); - - it("clears active drag state when a drag is cancelled", () => { - renderList(); - - act(() => { - latestDndContextProps?.onDragStart?.({ active: { id: "alpha" } }); - }); - expect(getItemActiveState("alpha")).toBe("true"); - - act(() => { - latestDndContextProps?.onDragCancel?.(); - }); - expect(getItemActiveState("alpha")).toBe("false"); - }); -}); diff --git a/packages/app/src/components/draggable-list.web.tsx b/packages/app/src/components/draggable-list.web.tsx index a6dd57f7f..e9f673c67 100644 --- a/packages/app/src/components/draggable-list.web.tsx +++ b/packages/app/src/components/draggable-list.web.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useRef, useState, type ReactElement } from "react"; +import { useCallback, useMemo, useRef, type ReactElement } from "react"; import { ScrollView, View } from "react-native"; import { DndContext, @@ -8,19 +8,17 @@ import { type Modifier, useSensor, useSensors, - type DragEndEvent, - type DragStartEvent, } from "@dnd-kit/core"; import { SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, useSortable, - arrayMove, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import type { DraggableListProps, DraggableRenderItemInfo } from "./draggable-list.types"; import { useWebScrollViewScrollbar } from "./use-web-scrollbar"; +import { getPointerActivationConstraint, useDragReorderState } from "./drag-reorder"; export type { DraggableListProps, DraggableRenderItemInfo }; @@ -30,8 +28,11 @@ const restrictToVerticalAxis: Modifier = ({ transform }) => ({ }); const DND_MODIFIERS = [restrictToVerticalAxis]; -const DEFAULT_POINTER_ACTIVATION_CONSTRAINT = { distance: 6 }; -const HANDLE_POINTER_ACTIVATION_CONSTRAINT = { delay: 250, tolerance: 8 }; +const POINTER_ACTIVATION_CONFIG = { + defaultDistance: 6, + holdDelayMs: 250, + holdTolerance: 8, +}; interface SortableItemProps { id: string; @@ -137,17 +138,21 @@ export function DraggableList({ onDragBegin, nestable: _nestable = false, }: DraggableListProps) { - const [activeId, setActiveId] = useState(null); - const [dragItems, setDragItems] = useState(null); - const items = dragItems ?? data; + const { activeId, items, handlers } = useDragReorderState({ + data, + keyExtractor, + onDragEnd, + onDragBegin, + }); const showCustomScrollbar = enableDesktopWebScrollbar && scrollEnabled; const scrollViewRef = useRef(null); const scrollbar = useWebScrollViewScrollbar(scrollViewRef, { enabled: showCustomScrollbar, }); - const pointerActivationConstraint = useDragHandle - ? HANDLE_POINTER_ACTIVATION_CONSTRAINT - : DEFAULT_POINTER_ACTIVATION_CONSTRAINT; + const pointerActivationConstraint = getPointerActivationConstraint( + useDragHandle, + POINTER_ACTIVATION_CONFIG, + ); const sensors = useSensors( useSensor(PointerSensor, { @@ -158,39 +163,6 @@ export function DraggableList({ }), ); - const handleDragStart = useCallback( - (event: DragStartEvent) => { - setDragItems(data); - setActiveId(String(event.active.id)); - onDragBegin?.(); - }, - [data, onDragBegin], - ); - - const clearDragState = useCallback(() => { - setActiveId(null); - setDragItems(null); - }, []); - - const handleDragEnd = useCallback( - (event: DragEndEvent) => { - const { active, over } = event; - - clearDragState(); - - if (over && active.id !== over.id) { - const oldIndex = items.findIndex((item, i) => keyExtractor(item, i) === active.id); - const newIndex = items.findIndex((item, i) => keyExtractor(item, i) === over.id); - - if (oldIndex >= 0 && newIndex >= 0 && oldIndex !== newIndex) { - const newItems = arrayMove(items, oldIndex, newIndex); - onDragEnd(newItems); - } - } - }, - [clearDragState, items, keyExtractor, onDragEnd], - ); - const ids = useMemo( () => items.map((item, index) => keyExtractor(item, index)), [items, keyExtractor], @@ -224,9 +196,9 @@ export function DraggableList({ sensors={sensors} collisionDetection={closestCenter} modifiers={DND_MODIFIERS} - onDragStart={handleDragStart} - onDragCancel={clearDragState} - onDragEnd={handleDragEnd} + onDragStart={handlers.onDragStart} + onDragCancel={handlers.onDragCancel} + onDragEnd={handlers.onDragEnd} > {items.map((item, index) => { @@ -255,9 +227,9 @@ export function DraggableList({ sensors={sensors} collisionDetection={closestCenter} modifiers={DND_MODIFIERS} - onDragStart={handleDragStart} - onDragCancel={clearDragState} - onDragEnd={handleDragEnd} + onDragStart={handlers.onDragStart} + onDragCancel={handlers.onDragCancel} + onDragEnd={handlers.onDragEnd} > {items.map((item, index) => { diff --git a/packages/app/src/components/import-session-sheet-view-model.test.ts b/packages/app/src/components/import-session-sheet-view-model.test.ts new file mode 100644 index 000000000..dd79b45c7 --- /dev/null +++ b/packages/app/src/components/import-session-sheet-view-model.test.ts @@ -0,0 +1,273 @@ +import { describe, expect, it } from "vitest"; +import type { FetchRecentProviderSessionEntry } from "@getpaseo/client/internal/daemon-client"; +import { + aggregateSessionEntries, + ALL_FILTER_VALUE, + buildProviderLabelMap, + collectErroredProviderLabels, + computeEmptyState, + getPromptPreview, + getSessionTitle, + resolveProvidersToFetch, + type SessionsQueryResult, + sumFilteredAlreadyImportedCount, +} from "@/components/import-session-sheet-view-model"; + +function entry( + overrides: Partial = {}, +): FetchRecentProviderSessionEntry { + return { + providerId: "claude", + providerLabel: "Claude Code", + providerHandleId: "thread-1", + cwd: "/repo/paseo", + title: null, + firstPromptPreview: null, + lastPromptPreview: null, + lastActivityAt: "2026-04-30T10:00:00.000Z", + ...overrides, + }; +} + +function settled( + data: SessionsQueryResult["data"], + flags?: Partial>, +): SessionsQueryResult { + return { + data, + isError: false, + isLoading: false, + isPending: false, + ...flags, + }; +} + +describe("resolveProvidersToFetch", () => { + it("returns null when the daemon does not support provider snapshots", () => { + expect(resolveProvidersToFetch(false, [{ provider: "claude" }])).toBeNull(); + }); + + it("returns null while snapshot entries have not loaded yet", () => { + expect(resolveProvidersToFetch(true, undefined)).toBeNull(); + }); + + it("returns only enabled importable providers", () => { + const providers = resolveProvidersToFetch(true, [ + { provider: "claude" }, + { provider: "codex" }, + { provider: "opencode", enabled: false }, + { provider: "z-ai" }, + ]); + expect(providers).toEqual(["claude", "codex"]); + }); + + it("returns an empty array when snapshot has no enabled importable providers", () => { + const providers = resolveProvidersToFetch(true, [ + { provider: "claude", enabled: false }, + { provider: "z-ai" }, + ]); + expect(providers).toEqual([]); + }); +}); + +describe("buildProviderLabelMap", () => { + it("returns an empty map when snapshot entries are missing", () => { + expect(buildProviderLabelMap(undefined).size).toBe(0); + }); + + it("indexes labels by provider id, skipping entries without a label", () => { + const labels = buildProviderLabelMap([ + { provider: "claude", label: "Claude Code" }, + { provider: "codex" }, + { provider: "z-ai", label: "Z.AI" }, + ]); + expect(labels.get("claude")).toBe("Claude Code"); + expect(labels.get("codex")).toBeUndefined(); + expect(labels.get("z-ai")).toBe("Z.AI"); + }); +}); + +describe("aggregateSessionEntries", () => { + it("returns an empty array when no queries have data", () => { + expect(aggregateSessionEntries([settled(undefined)])).toEqual([]); + }); + + it("dedupes by providerId+providerHandleId across query results", () => { + const result = aggregateSessionEntries([ + settled({ + entries: [ + entry({ providerHandleId: "thread-1", lastActivityAt: "2026-04-30T10:00:00.000Z" }), + ], + }), + settled({ + entries: [ + entry({ providerHandleId: "thread-1", lastActivityAt: "2026-04-30T11:00:00.000Z" }), + entry({ providerHandleId: "thread-2", lastActivityAt: "2026-04-30T09:00:00.000Z" }), + ], + }), + ]); + expect(result.map((e) => e.providerHandleId)).toEqual(["thread-1", "thread-2"]); + }); + + it("sorts collected entries by lastActivityAt descending", () => { + const result = aggregateSessionEntries([ + settled({ + entries: [ + entry({ providerHandleId: "old", lastActivityAt: "2026-04-29T10:00:00.000Z" }), + entry({ providerHandleId: "new", lastActivityAt: "2026-04-30T10:00:00.000Z" }), + ], + }), + ]); + expect(result.map((e) => e.providerHandleId)).toEqual(["new", "old"]); + }); +}); + +describe("sumFilteredAlreadyImportedCount", () => { + it("returns 0 when no queries report a filtered count", () => { + expect(sumFilteredAlreadyImportedCount([settled({ entries: [] })])).toBe(0); + }); + + it("sums the filtered already-imported counts across queries", () => { + const total = sumFilteredAlreadyImportedCount([ + settled({ entries: [], filteredAlreadyImportedCount: 2 }), + settled({ entries: [], filteredAlreadyImportedCount: 3 }), + settled(undefined), + ]); + expect(total).toBe(5); + }); +}); + +describe("collectErroredProviderLabels", () => { + it("returns no labels when no providers are being fetched", () => { + expect(collectErroredProviderLabels(null, [], new Map())).toEqual([]); + }); + + it("returns labels for each errored provider, falling back to provider id", () => { + const labels = collectErroredProviderLabels( + ["claude", "codex"], + [settled(undefined, { isError: true }), settled({ entries: [] })], + new Map([["claude", "Claude Code"]]), + ); + expect(labels).toEqual(["Claude Code"]); + }); + + it("uses provider id when the label map has no entry", () => { + const labels = collectErroredProviderLabels( + ["codex"], + [settled(undefined, { isError: true })], + new Map(), + ); + expect(labels).toEqual(["codex"]); + }); +}); + +describe("getSessionTitle", () => { + it("prefers the trimmed title", () => { + expect(getSessionTitle(entry({ title: " Importable " }))).toBe("Importable"); + }); + + it("falls back to the trimmed first prompt preview when title is empty", () => { + expect(getSessionTitle(entry({ title: " ", firstPromptPreview: " Hello " }))).toBe("Hello"); + }); + + it("falls back to Untitled session when both title and first prompt are blank", () => { + expect(getSessionTitle(entry({ title: null, firstPromptPreview: " " }))).toBe( + "Untitled session", + ); + }); +}); + +describe("getPromptPreview", () => { + it("prefers the trimmed last prompt preview", () => { + expect( + getPromptPreview( + entry({ lastPromptPreview: " later ", firstPromptPreview: " earlier " }), + ), + ).toBe("later"); + }); + + it("falls back to the first prompt preview when last is blank", () => { + expect( + getPromptPreview(entry({ lastPromptPreview: " ", firstPromptPreview: " earlier " })), + ).toBe("earlier"); + }); + + it("falls back to a placeholder when both prompts are blank", () => { + expect(getPromptPreview(entry({ lastPromptPreview: null, firstPromptPreview: null }))).toBe( + "No prompt preview", + ); + }); +}); + +describe("computeEmptyState", () => { + const baseInputs = { + isLoadingSessions: false, + allQueriesErrored: false, + isQueryingProviders: true, + allQueriesSettled: true, + selectedProvider: ALL_FILTER_VALUE, + aggregatedCount: 0, + visibleCount: 0, + totalAlreadyImportedCount: 0, + providerLabelById: new Map(), + }; + + it("hides the empty state while sessions are still loading", () => { + const result = computeEmptyState({ ...baseInputs, isLoadingSessions: true }); + expect(result.showEmptyState).toBe(false); + }); + + it("hides the empty state when every query errored", () => { + const result = computeEmptyState({ ...baseInputs, allQueriesErrored: true }); + expect(result.showEmptyState).toBe(false); + }); + + it("hides the empty state until every provider query has settled", () => { + const result = computeEmptyState({ ...baseInputs, allQueriesSettled: false }); + expect(result.showEmptyState).toBe(false); + }); + + it("hides the empty state when there are visible entries", () => { + const result = computeEmptyState({ + ...baseInputs, + aggregatedCount: 2, + visibleCount: 2, + }); + expect(result.showEmptyState).toBe(false); + }); + + it("shows the default no-sessions message when nothing is loaded and nothing is filtered", () => { + const result = computeEmptyState(baseInputs); + expect(result).toEqual({ + showEmptyState: true, + emptyStateTitle: "No recent sessions to import.", + }); + }); + + it("shows the already-imported message when imported entries were filtered out", () => { + const result = computeEmptyState({ + ...baseInputs, + totalAlreadyImportedCount: 4, + }); + expect(result.emptyStateTitle).toBe("All recent sessions are already imported."); + }); + + it("shows a provider-scoped message when a filter hides aggregated entries", () => { + const result = computeEmptyState({ + ...baseInputs, + selectedProvider: "claude", + aggregatedCount: 3, + providerLabelById: new Map([["claude", "Claude Code"]]), + }); + expect(result.emptyStateTitle).toBe("No Claude Code sessions found."); + }); + + it("falls back to the provider id when the filtered provider lacks a label", () => { + const result = computeEmptyState({ + ...baseInputs, + selectedProvider: "z-ai", + aggregatedCount: 1, + }); + expect(result.emptyStateTitle).toBe("No z-ai sessions found."); + }); +}); diff --git a/packages/app/src/components/import-session-sheet-view-model.ts b/packages/app/src/components/import-session-sheet-view-model.ts new file mode 100644 index 000000000..31d81038e --- /dev/null +++ b/packages/app/src/components/import-session-sheet-view-model.ts @@ -0,0 +1,145 @@ +import type { FetchRecentProviderSessionEntry } from "@getpaseo/client/internal/daemon-client"; +import type { AgentProvider } from "@getpaseo/protocol/agent-types"; +import { IMPORTABLE_PROVIDERS } from "@getpaseo/protocol/importable-providers"; + +export const IMPORTABLE_PROVIDER_IDS: Set = new Set(IMPORTABLE_PROVIDERS); +export const PER_PROVIDER_LIMIT = 15; +export const ALL_FILTER_VALUE = "__all__"; + +export interface SessionsQueryResult { + data: + | { + entries: FetchRecentProviderSessionEntry[]; + filteredAlreadyImportedCount?: number; + } + | undefined; + isError: boolean; + isLoading: boolean; + isPending: boolean; +} + +export function resolveProvidersToFetch( + supportsSnapshot: boolean, + snapshotEntries: ReadonlyArray<{ provider: string; enabled?: boolean }> | undefined, +): AgentProvider[] | null { + // COMPAT(providersSnapshot): the import-recent-sessions feature ships alongside + // providersSnapshot (v0.1.48, 2026-04-05). Daemons older than that lack both — + // we render an "update host" empty state instead of degrading. Drop this gate + // when the supported daemon floor is >= v0.1.48 (target: 2026-10-05). + if (!supportsSnapshot) return null; + if (!snapshotEntries) return null; + return snapshotEntries + .filter((entry) => IMPORTABLE_PROVIDER_IDS.has(entry.provider) && entry.enabled !== false) + .map((entry) => entry.provider); +} + +export function buildProviderLabelMap( + snapshotEntries: ReadonlyArray<{ provider: string; label?: string }> | undefined, +): Map { + const map = new Map(); + if (!snapshotEntries) return map; + for (const entry of snapshotEntries) { + if (entry.label) { + map.set(entry.provider, entry.label); + } + } + return map; +} + +export function aggregateSessionEntries( + queries: ReadonlyArray, +): FetchRecentProviderSessionEntry[] { + const seen = new Set(); + const collected: FetchRecentProviderSessionEntry[] = []; + for (const query of queries) { + if (!query.data) continue; + for (const entry of query.data.entries) { + const key = `${entry.providerId}:${entry.providerHandleId}`; + if (seen.has(key)) continue; + seen.add(key); + collected.push(entry); + } + } + collected.sort( + (a, b) => new Date(b.lastActivityAt).getTime() - new Date(a.lastActivityAt).getTime(), + ); + return collected; +} + +export function sumFilteredAlreadyImportedCount( + queries: ReadonlyArray, +): number { + let total = 0; + for (const query of queries) { + total += query.data?.filteredAlreadyImportedCount ?? 0; + } + return total; +} + +export function collectErroredProviderLabels( + providersToFetch: AgentProvider[] | null, + queries: ReadonlyArray, + providerLabelById: ReadonlyMap, +): string[] { + if (providersToFetch === null) return []; + const labels: string[] = []; + for (let index = 0; index < queries.length; index++) { + if (queries[index]?.isError) { + const provider = providersToFetch[index]; + labels.push(providerLabelById.get(provider) ?? provider); + } + } + return labels; +} + +export function getSessionTitle(entry: FetchRecentProviderSessionEntry): string { + const title = entry.title?.trim(); + if (title) { + return title; + } + const firstPromptPreview = entry.firstPromptPreview?.trim(); + if (firstPromptPreview) { + return firstPromptPreview; + } + return "Untitled session"; +} + +export function getPromptPreview(entry: FetchRecentProviderSessionEntry): string { + return entry.lastPromptPreview?.trim() || entry.firstPromptPreview?.trim() || "No prompt preview"; +} + +export interface EmptyStateInputs { + isLoadingSessions: boolean; + allQueriesErrored: boolean; + isQueryingProviders: boolean; + allQueriesSettled: boolean; + selectedProvider: string; + aggregatedCount: number; + visibleCount: number; + totalAlreadyImportedCount: number; + providerLabelById: ReadonlyMap; +} + +export function computeEmptyState(input: EmptyStateInputs): { + showEmptyState: boolean; + emptyStateTitle: string; +} { + const showEmptyState = + !input.isLoadingSessions && + !input.allQueriesErrored && + input.isQueryingProviders && + input.allQueriesSettled && + input.visibleCount === 0; + if (!showEmptyState) { + return { showEmptyState, emptyStateTitle: "" }; + } + const isFilteredEmpty = input.selectedProvider !== ALL_FILTER_VALUE && input.aggregatedCount > 0; + if (isFilteredEmpty) { + const label = input.providerLabelById.get(input.selectedProvider) ?? input.selectedProvider; + return { showEmptyState, emptyStateTitle: `No ${label} sessions found.` }; + } + if (input.totalAlreadyImportedCount > 0) { + return { showEmptyState, emptyStateTitle: "All recent sessions are already imported." }; + } + return { showEmptyState, emptyStateTitle: "No recent sessions to import." }; +} diff --git a/packages/app/src/components/import-session-sheet.tsx b/packages/app/src/components/import-session-sheet.tsx index b737a10f1..b85976c88 100644 --- a/packages/app/src/components/import-session-sheet.tsx +++ b/packages/app/src/components/import-session-sheet.tsx @@ -6,7 +6,6 @@ import type { FetchRecentProviderSessionEntry, } from "@getpaseo/client/internal/daemon-client"; import type { AgentProvider } from "@getpaseo/protocol/agent-types"; -import { IMPORTABLE_PROVIDERS } from "@getpaseo/protocol/importable-providers"; import { Inbox, RotateCw } from "lucide-react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; @@ -15,12 +14,21 @@ import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/s import { getProviderIcon } from "@/components/provider-icons"; import { formatTimeAgo } from "@/utils/time"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; +import { + aggregateSessionEntries, + ALL_FILTER_VALUE, + buildProviderLabelMap, + collectErroredProviderLabels, + computeEmptyState, + getPromptPreview, + getSessionTitle, + PER_PROVIDER_LIMIT, + resolveProvidersToFetch, + sumFilteredAlreadyImportedCount, +} from "@/components/import-session-sheet-view-model"; -const IMPORTABLE_PROVIDER_IDS: Set = new Set(IMPORTABLE_PROVIDERS); -const PER_PROVIDER_LIMIT = 15; const IMPORT_SHEET_SNAP_POINTS = ["70%", "92%"]; const DISABLED_ACCESSIBILITY_STATE = { disabled: true }; -const ALL_FILTER_VALUE = "__all__"; type RecentProviderSessionsClient = Pick< DaemonClient, @@ -49,41 +57,6 @@ interface SessionsQueryConfig { queryFn: () => Promise; } -interface SessionsQueryResult { - data: RecentSessionsResponse | undefined; - isError: boolean; - isLoading: boolean; - isPending: boolean; -} - -function resolveProvidersToFetch( - supportsSnapshot: boolean, - snapshotEntries: ReadonlyArray<{ provider: string; enabled?: boolean }> | undefined, -): AgentProvider[] | null { - // COMPAT(providersSnapshot): the import-recent-sessions feature ships alongside - // providersSnapshot (v0.1.48, 2026-04-05). Daemons older than that lack both — - // we render an "update host" empty state instead of degrading. Drop this gate - // when the supported daemon floor is >= v0.1.48 (target: 2026-10-05). - if (!supportsSnapshot) return null; - if (!snapshotEntries) return null; - return snapshotEntries - .filter((entry) => IMPORTABLE_PROVIDER_IDS.has(entry.provider) && entry.enabled !== false) - .map((entry) => entry.provider); -} - -function buildProviderLabelMap( - snapshotEntries: ReadonlyArray<{ provider: string; label?: string }> | undefined, -): Map { - const map = new Map(); - if (!snapshotEntries) return map; - for (const entry of snapshotEntries) { - if (entry.label) { - map.set(entry.provider, entry.label); - } - } - return map; -} - function buildSessionsQueriesConfig(args: { providersToFetch: AgentProvider[] | null; sessionsQueryRoot: ReadonlyArray; @@ -110,66 +83,6 @@ function buildSessionsQueriesConfig(args: { })); } -function aggregateSessionEntries( - queries: ReadonlyArray, -): FetchRecentProviderSessionEntry[] { - const seen = new Set(); - const collected: FetchRecentProviderSessionEntry[] = []; - for (const query of queries) { - if (!query.data) continue; - for (const entry of query.data.entries) { - const key = `${entry.providerId}:${entry.providerHandleId}`; - if (seen.has(key)) continue; - seen.add(key); - collected.push(entry); - } - } - collected.sort( - (a, b) => new Date(b.lastActivityAt).getTime() - new Date(a.lastActivityAt).getTime(), - ); - return collected; -} - -function sumFilteredAlreadyImportedCount(queries: ReadonlyArray): number { - let total = 0; - for (const query of queries) { - total += query.data?.filteredAlreadyImportedCount ?? 0; - } - return total; -} - -function collectErroredProviderLabels( - providersToFetch: AgentProvider[] | null, - queries: ReadonlyArray, - providerLabelById: ReadonlyMap, -): string[] { - if (providersToFetch === null) return []; - const labels: string[] = []; - for (let index = 0; index < queries.length; index++) { - if (queries[index]?.isError) { - const provider = providersToFetch[index]; - labels.push(providerLabelById.get(provider) ?? provider); - } - } - return labels; -} - -function getSessionTitle(entry: FetchRecentProviderSessionEntry): string { - const title = entry.title?.trim(); - if (title) { - return title; - } - const firstPromptPreview = entry.firstPromptPreview?.trim(); - if (firstPromptPreview) { - return firstPromptPreview; - } - return "Untitled session"; -} - -function getPromptPreview(entry: FetchRecentProviderSessionEntry): string { - return entry.lastPromptPreview?.trim() || entry.firstPromptPreview?.trim() || "No prompt preview"; -} - interface SheetStatusMessagesProps { isClientReady: boolean; isSnapshotUnsupported: boolean; @@ -222,42 +135,6 @@ function SheetStatusMessages({ ); } -interface EmptyStateInputs { - isLoadingSessions: boolean; - allQueriesErrored: boolean; - isQueryingProviders: boolean; - allQueriesSettled: boolean; - selectedProvider: string; - aggregatedCount: number; - visibleCount: number; - totalAlreadyImportedCount: number; - providerLabelById: ReadonlyMap; -} - -function computeEmptyState(input: EmptyStateInputs): { - showEmptyState: boolean; - emptyStateTitle: string; -} { - const showEmptyState = - !input.isLoadingSessions && - !input.allQueriesErrored && - input.isQueryingProviders && - input.allQueriesSettled && - input.visibleCount === 0; - if (!showEmptyState) { - return { showEmptyState, emptyStateTitle: "" }; - } - const isFilteredEmpty = input.selectedProvider !== ALL_FILTER_VALUE && input.aggregatedCount > 0; - if (isFilteredEmpty) { - const label = input.providerLabelById.get(input.selectedProvider) ?? input.selectedProvider; - return { showEmptyState, emptyStateTitle: `No ${label} sessions found.` }; - } - if (input.totalAlreadyImportedCount > 0) { - return { showEmptyState, emptyStateTitle: "All recent sessions are already imported." }; - } - return { showEmptyState, emptyStateTitle: "No recent sessions to import." }; -} - function RefreshAction({ isRefreshing, onPress }: { isRefreshing: boolean; onPress: () => void }) { const { theme } = useUnistyles(); const pressableStyle = useCallback( diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 4a59a6f2f..aa8cc3ca3 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -74,6 +74,7 @@ import { HighlightedCodeBlock } from "@/components/highlighted-code-block"; import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks"; import { formatDuration, formatMessageTimestamp } from "@/utils/time"; import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard"; +import { getDefaultMarkdownClipboardEnvironment } from "@/utils/rich-clipboard-default-environment"; import { getAssistantImageLoadStateFromMetadata, getAssistantImageMetadata, @@ -1149,7 +1150,7 @@ export const TurnCopyButton = memo(function TurnCopyButton({ return; } - await writeMarkdownToRichClipboard(content); + await writeMarkdownToRichClipboard(content, getDefaultMarkdownClipboardEnvironment()); setCopied(true); if (copyTimeoutRef.current) { diff --git a/packages/app/src/components/provider-icon-name.test.ts b/packages/app/src/components/provider-icon-name.test.ts new file mode 100644 index 000000000..f2149d1a9 --- /dev/null +++ b/packages/app/src/components/provider-icon-name.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { resolveProviderIconName } from "./provider-icon-name"; + +describe("resolveProviderIconName", () => { + it("returns the built-in identifier for known provider ids", () => { + expect(resolveProviderIconName("kiro")).toEqual({ kind: "builtin", id: "kiro" }); + expect(resolveProviderIconName("claude")).toEqual({ kind: "builtin", id: "claude" }); + }); + + it("returns the catalog identifier for ACP catalog provider ids that ship an icon", () => { + expect(resolveProviderIconName("amp-acp")).toEqual({ kind: "catalog", id: "amp-acp" }); + }); + + it("falls back to the bot icon for unknown custom providers", () => { + expect(resolveProviderIconName("custom-claude-profile")).toEqual({ kind: "bot" }); + }); +}); diff --git a/packages/app/src/components/provider-icon-name.ts b/packages/app/src/components/provider-icon-name.ts new file mode 100644 index 000000000..2b04168da --- /dev/null +++ b/packages/app/src/components/provider-icon-name.ts @@ -0,0 +1,31 @@ +import { ACP_PROVIDER_CATALOG } from "@/data/acp-provider-catalog"; + +export type BuiltinProviderIconName = "claude" | "codex" | "copilot" | "kiro" | "opencode" | "pi"; + +export type ProviderIconName = + | { kind: "builtin"; id: BuiltinProviderIconName } + | { kind: "catalog"; id: string } + | { kind: "bot" }; + +const BUILTIN_PROVIDER_IDS: ReadonlySet = new Set([ + "claude", + "codex", + "copilot", + "kiro", + "opencode", + "pi", +]); + +const CATALOG_ICON_PROVIDER_IDS: ReadonlySet = new Set( + ACP_PROVIDER_CATALOG.flatMap((entry) => (entry.iconSvg ? [entry.id] : [])), +); + +export function resolveProviderIconName(provider: string): ProviderIconName { + if (BUILTIN_PROVIDER_IDS.has(provider as BuiltinProviderIconName)) { + return { kind: "builtin", id: provider as BuiltinProviderIconName }; + } + if (CATALOG_ICON_PROVIDER_IDS.has(provider)) { + return { kind: "catalog", id: provider }; + } + return { kind: "bot" }; +} diff --git a/packages/app/src/components/provider-icons.test.ts b/packages/app/src/components/provider-icons.test.ts deleted file mode 100644 index 681f44ecf..000000000 --- a/packages/app/src/components/provider-icons.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -vi.mock("lucide-react-native", () => ({ - Bot: function Bot() { - return null; - }, - PackagePlus: function PackagePlus() { - return null; - }, -})); - -import { Bot, PackagePlus } from "lucide-react-native"; -import { getProviderIcon } from "./provider-icons"; - -describe("getProviderIcon", () => { - it("keeps built-in provider icons", () => { - expect(getProviderIcon("kiro")).toBe(PackagePlus); - }); - - it("uses vendored ACP catalog icons for catalog provider ids", () => { - const icon = getProviderIcon("amp-acp"); - - expect(icon).not.toBe(Bot); - expect(getProviderIcon("amp-acp")).toBe(icon); - }); - - it("falls back to the robot icon for unknown custom providers", () => { - expect(getProviderIcon("custom-claude-profile")).toBe(Bot); - }); -}); diff --git a/packages/app/src/components/provider-icons.ts b/packages/app/src/components/provider-icons.ts index 941c861ad..fea4e41a8 100644 --- a/packages/app/src/components/provider-icons.ts +++ b/packages/app/src/components/provider-icons.ts @@ -7,6 +7,10 @@ import { CopilotIcon } from "@/components/icons/copilot-icon"; import { OpenCodeIcon } from "@/components/icons/opencode-icon"; import { PiIcon } from "@/components/icons/pi-icon"; import { ACP_PROVIDER_CATALOG } from "@/data/acp-provider-catalog"; +import { + resolveProviderIconName, + type BuiltinProviderIconName, +} from "@/components/provider-icon-name"; export interface ProviderIconProps { size: number; @@ -15,7 +19,7 @@ export interface ProviderIconProps { export type ProviderIconComponent = ComponentType; -const PROVIDER_ICONS: Record = { +const BUILTIN_PROVIDER_ICONS: Record = { claude: ClaudeIcon as unknown as ProviderIconComponent, codex: CodexIcon as unknown as ProviderIconComponent, copilot: CopilotIcon as unknown as ProviderIconComponent, @@ -42,22 +46,24 @@ function createCatalogIcon(provider: string, iconSvg: string): ProviderIconCompo return CatalogProviderIcon; } -function getCatalogProviderIcon(provider: string): ProviderIconComponent | undefined { +function getCatalogProviderIcon(provider: string): ProviderIconComponent { const cached = catalogIconComponents.get(provider); if (cached) { return cached; } - - const iconSvg = CATALOG_ICON_SVGS.get(provider); - if (!iconSvg) { - return undefined; - } - + const iconSvg = CATALOG_ICON_SVGS.get(provider) ?? ""; const icon = createCatalogIcon(provider, iconSvg); catalogIconComponents.set(provider, icon); return icon; } export function getProviderIcon(provider: string): ProviderIconComponent { - return PROVIDER_ICONS[provider] ?? getCatalogProviderIcon(provider) ?? Bot; + const name = resolveProviderIconName(provider); + if (name.kind === "builtin") { + return BUILTIN_PROVIDER_ICONS[name.id]; + } + if (name.kind === "catalog") { + return getCatalogProviderIcon(name.id); + } + return Bot; } diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 7f9d504d5..22fc777e4 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -27,7 +27,7 @@ import { type Ref, } from "react"; import { router, usePathname, type Href } from "expo-router"; -import { navigateToWorkspace } from "@/hooks/use-workspace-navigation"; +import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import type { Theme } from "@/styles/theme"; import { type GestureType } from "react-native-gesture-handler"; diff --git a/packages/app/src/components/sortable-inline-list.web.test.tsx b/packages/app/src/components/sortable-inline-list.web.test.tsx deleted file mode 100644 index 5196ea7ae..000000000 --- a/packages/app/src/components/sortable-inline-list.web.test.tsx +++ /dev/null @@ -1,123 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import React from "react"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { SortableInlineList } from "./sortable-inline-list.web"; - -interface DndContextProps { - onDragStart?: (event: { active: { id: string } }) => void; - onDragCancel?: () => void; -} - -let latestDndContextProps: DndContextProps | null = null; - -vi.mock("@dnd-kit/core", () => ({ - DndContext: ({ children, ...props }: React.PropsWithChildren) => { - latestDndContextProps = props; - return
{children}
; - }, - closestCenter: vi.fn(), - KeyboardSensor: vi.fn(), - PointerSensor: vi.fn(), - useSensor: vi.fn(() => ({})), - useSensors: vi.fn(() => []), -})); - -vi.mock("@dnd-kit/sortable", () => ({ - SortableContext: ({ children }: React.PropsWithChildren) => children, - arrayMove: (items: T[], from: number, to: number) => { - const next = [...items]; - const [item] = next.splice(from, 1); - if (item !== undefined) { - next.splice(to, 0, item); - } - return next; - }, - horizontalListSortingStrategy: {}, - sortableKeyboardCoordinates: vi.fn(), - useSortable: () => ({ - attributes: {}, - listeners: {}, - setNodeRef: vi.fn(), - setActivatorNodeRef: vi.fn(), - transform: null, - transition: undefined, - isDragging: false, - }), -})); - -let root: Root | null = null; -let container: HTMLElement | null = null; - -beforeEach(() => { - vi.stubGlobal("React", React); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - latestDndContextProps = null; - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); -}); - -afterEach(() => { - if (root) { - act(() => { - root?.unmount(); - }); - } - root = null; - container?.remove(); - container = null; - vi.unstubAllGlobals(); -}); - -const DATA: string[] = ["alpha", "beta"]; - -function keyExtractor(item: string): string { - return item; -} - -function renderItem({ item, isActive }: { item: string; isActive: boolean }) { - return ( -
- {item} -
- ); -} - -function renderList(): void { - act(() => { - root?.render( - , - ); - }); -} - -function getItemActiveState(item: string): string | null { - return ( - container?.querySelector(`[data-testid="item-${item}"]`)?.getAttribute("data-active") ?? null - ); -} - -describe("SortableInlineList web", () => { - it("clears active drag state when a drag is cancelled", () => { - renderList(); - - act(() => { - latestDndContextProps?.onDragStart?.({ active: { id: "alpha" } }); - }); - expect(getItemActiveState("alpha")).toBe("true"); - - act(() => { - latestDndContextProps?.onDragCancel?.(); - }); - expect(getItemActiveState("alpha")).toBe("false"); - }); -}); diff --git a/packages/app/src/components/sortable-inline-list.web.tsx b/packages/app/src/components/sortable-inline-list.web.tsx index 22c125cbc..133cd408d 100644 --- a/packages/app/src/components/sortable-inline-list.web.tsx +++ b/packages/app/src/components/sortable-inline-list.web.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState, type ReactElement } from "react"; +import { useCallback, useMemo, type ReactElement } from "react"; import { DndContext, closestCenter, @@ -7,18 +7,16 @@ import { type Modifier, useSensor, useSensors, - type DragEndEvent, - type DragStartEvent, } from "@dnd-kit/core"; import { SortableContext, sortableKeyboardCoordinates, horizontalListSortingStrategy, useSortable, - arrayMove, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import type { DraggableRenderItemInfo } from "./draggable-list.types"; +import { useDragReorderState } from "./drag-reorder"; const restrictToHorizontalAxis: Modifier = ({ transform }) => ({ ...transform, @@ -138,9 +136,19 @@ export function SortableInlineList({ activeId?: string | null; getItemData?: (item: T, index: number) => Record; }): ReactElement { - const [activeId, setActiveId] = useState(null); - const [dragItems, setDragItems] = useState(null); - const items = externalDndContext ? data : (dragItems ?? data); + const { + activeId: internalActiveId, + items: managedItems, + handlers, + } = useDragReorderState({ + data, + keyExtractor, + onDragEnd, + onDragBegin, + disabled, + }); + const items = externalDndContext ? data : managedItems; + const activeId = externalDndContext ? externalActiveId : internalActiveId; const sensors = useSensors( useSensor(PointerSensor, { @@ -153,46 +161,6 @@ export function SortableInlineList({ }), ); - const handleDragStart = useCallback( - (event: DragStartEvent) => { - if (disabled) { - return; - } - setDragItems(data); - setActiveId(String(event.active.id)); - onDragBegin?.(); - }, - [data, disabled, onDragBegin], - ); - - const clearDragState = useCallback(() => { - setActiveId(null); - setDragItems(null); - }, []); - - const handleDragEnd = useCallback( - (event: DragEndEvent) => { - const { active, over } = event; - - clearDragState(); - - if (disabled) { - return; - } - - if (over && active.id !== over.id) { - const oldIndex = items.findIndex((item, i) => keyExtractor(item, i) === active.id); - const newIndex = items.findIndex((item, i) => keyExtractor(item, i) === over.id); - - if (oldIndex >= 0 && newIndex >= 0 && oldIndex !== newIndex) { - const newItems = arrayMove(items, oldIndex, newIndex); - onDragEnd?.(newItems); - } - } - }, - [clearDragState, disabled, items, keyExtractor, onDragEnd], - ); - const ids = useMemo( () => items.map((item, index) => keyExtractor(item, index)), [items, keyExtractor], @@ -209,7 +177,7 @@ export function SortableInlineList({ item={item} index={index} renderItem={renderItem} - activeId={externalDndContext ? externalActiveId : activeId} + activeId={activeId} useDragHandle={useDragHandle} disabled={disabled} itemData={getItemData?.(item, index)} @@ -229,9 +197,9 @@ export function SortableInlineList({ sensors={sensors} collisionDetection={closestCenter} modifiers={DND_MODIFIERS} - onDragStart={handleDragStart} - onDragCancel={clearDragState} - onDragEnd={handleDragEnd} + onDragStart={handlers.onDragStart} + onDragCancel={handlers.onDragCancel} + onDragEnd={handlers.onDragEnd} > {renderedItems} diff --git a/packages/app/src/components/terminal-emulator.tsx b/packages/app/src/components/terminal-emulator.tsx index 22e9d33e2..952619945 100644 --- a/packages/app/src/components/terminal-emulator.tsx +++ b/packages/app/src/components/terminal-emulator.tsx @@ -41,6 +41,7 @@ import { isTerminalFileDrag, prepareDroppedPathsForTerminal, } from "../terminal/drop/terminal-file-drop"; +import { getDesktopHost } from "@/desktop/host"; export interface TerminalEmulatorHandle { writeOutput: (data: TerminalOutputData) => void; @@ -833,13 +834,14 @@ export default function TerminalEmulator({ event.stopPropagation(); clearTerminalDropActive(); - const paths = extractTerminalDropPaths(event.dataTransfer); + const bridge = getDesktopHost(); + const paths = extractTerminalDropPaths(event.dataTransfer, bridge); if (paths.length === 0) { return; } runtimeRef.current?.focus(); - mountCallbacksRef.current.onInput?.(prepareDroppedPathsForTerminal(paths)); + mountCallbacksRef.current.onInput?.(prepareDroppedPathsForTerminal(paths, bridge)); }; root.addEventListener("dragenter", handleDragEnter, { capture: true }); diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal.test.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal.test.tsx deleted file mode 100644 index 19a3bf034..000000000 --- a/packages/app/src/components/ui/isolated-bottom-sheet-modal.test.tsx +++ /dev/null @@ -1,189 +0,0 @@ -// @vitest-environment jsdom -import { cleanup, render } from "@testing-library/react"; -import React from "react"; -import type { ReactNode } from "react"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { - IsolatedBottomSheetModal, - useIsolatedBottomSheetVisibility, -} from "./isolated-bottom-sheet-modal"; - -const SNAP_POINTS_50: (string | number)[] = ["50%"]; -const SNAP_POINTS_60: (string | number)[] = ["60%"]; -const SNAP_POINTS_90: (string | number)[] = ["90%"]; - -function noop(): void {} - -interface CapturedModalProps { - onChange?: (index: number) => void; - onDismiss?: () => void; -} - -const { modalMethods, modalProps, shouldExposeModalRef } = vi.hoisted(() => ({ - modalMethods: { - present: vi.fn(), - close: vi.fn(), - snapToIndex: vi.fn(), - dismiss: vi.fn(), - }, - modalProps: vi.fn(), - shouldExposeModalRef: { current: true }, -})); - -vi.mock("@gorhom/bottom-sheet", async () => { - const React = await import("react"); - const MockBottomSheetModal = React.forwardRef( - (props: Record, ref: React.ForwardedRef) => { - modalProps(props); - React.useImperativeHandle(ref, () => (shouldExposeModalRef.current ? modalMethods : null)); - return React.createElement( - "div", - { "data-testid": "bottom-sheet" }, - props.children as ReactNode, - ); - }, - ); - - return { - BottomSheetModal: MockBottomSheetModal, - BottomSheetModalProvider: ({ children }: { children: ReactNode }) => - React.createElement("div", { "data-testid": "bottom-sheet-provider" }, children), - }; -}); - -vi.mock("@gorhom/portal", async () => { - const React = await import("react"); - return { - Portal: ({ children, hostName }: { children: ReactNode; hostName?: string }) => - React.createElement("div", { "data-host": hostName, "data-testid": "app-portal" }, children), - }; -}); - -function Harness({ visible, onClose }: { visible: boolean; onClose: () => void }) { - const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({ - visible, - onClose, - }); - - return ( - -
Sheet content
-
- ); -} - -function latestModalProps(): CapturedModalProps { - const props = modalProps.mock.lastCall?.[0]; - expect(props).toBeDefined(); - return props as CapturedModalProps; -} - -describe("IsolatedBottomSheetModal", () => { - afterEach(() => { - cleanup(); - vi.clearAllMocks(); - shouldExposeModalRef.current = true; - }); - - it("forces sheet isolation and uses the modal dismissal lifecycle", () => { - const onClose = vi.fn(); - const { getByTestId, rerender } = render(); - - expect(getByTestId("app-portal").getAttribute("data-host")).toBe("root"); - expect(modalProps).toHaveBeenLastCalledWith( - expect.objectContaining({ - enableDismissOnClose: true, - stackBehavior: "replace", - }), - ); - expect(modalMethods.present).not.toHaveBeenCalled(); - - rerender(); - expect(modalMethods.present).toHaveBeenCalledTimes(1); - - rerender(); - expect(modalMethods.dismiss).toHaveBeenCalledTimes(1); - expect(modalMethods.close).not.toHaveBeenCalled(); - - rerender(); - expect(modalMethods.present).toHaveBeenCalledTimes(2); - expect(modalMethods.snapToIndex).not.toHaveBeenCalled(); - }); - - it("only reports a user close when the sheet was visible", () => { - const onClose = vi.fn(); - const { rerender } = render(); - - latestModalProps().onChange?.(-1); - expect(onClose).toHaveBeenCalledTimes(1); - - rerender(); - latestModalProps().onChange?.(-1); - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it("reports a dismiss while visible as a close request", () => { - const onClose = vi.fn(); - render(); - - latestModalProps().onDismiss?.(); - - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it("deduplicates close notifications from change and dismiss callbacks", () => { - const onClose = vi.fn(); - render(); - - const props = latestModalProps(); - props.onChange?.(-1); - props.onDismiss?.(); - - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it("presents when the sheet ref becomes available after opening", () => { - shouldExposeModalRef.current = false; - const onClose = vi.fn(); - const { rerender } = render(); - - expect(modalMethods.present).not.toHaveBeenCalled(); - expect(modalMethods.snapToIndex).not.toHaveBeenCalled(); - - shouldExposeModalRef.current = true; - rerender(); - - expect(modalMethods.present).toHaveBeenCalledTimes(1); - expect(modalMethods.snapToIndex).not.toHaveBeenCalled(); - }); - - it("allows nested sheets inside a parent sheet without creating a sibling provider", () => { - const { getAllByTestId } = render( - - -
Nested model picker
-
-
, - ); - - expect(getAllByTestId("bottom-sheet-provider")).toHaveLength(1); - expect(modalProps).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - stackBehavior: "replace", - }), - ); - expect(modalProps).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - stackBehavior: "push", - }), - ); - }); -}); diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx similarity index 54% rename from packages/app/src/components/ui/isolated-bottom-sheet-modal.tsx rename to packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx index 6236974a0..6b3f20c91 100644 --- a/packages/app/src/components/ui/isolated-bottom-sheet-modal.tsx +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx @@ -5,8 +5,12 @@ import { } from "@gorhom/bottom-sheet"; import { Portal } from "@gorhom/portal"; import React, { createContext, useContext } from "react"; -import { forwardRef, useCallback, useEffect, useRef } from "react"; +import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react"; import type { ElementRef } from "react"; +import { + type BottomSheetController, + createBottomSheetVisibilityTracker, +} from "./visibility-tracker"; type GorhomBottomSheetModalMethods = ElementRef; @@ -68,88 +72,31 @@ export function useIsolatedBottomSheetVisibility({ isEnabled?: boolean; onClose: () => void; }) { - const sheetRef = useRef(null); - const visibleRef = useRef(visible); - const isEnabledRef = useRef(isEnabled); - const isPresentedRef = useRef(false); - const hasNotifiedCloseRef = useRef(false); + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; - visibleRef.current = visible; - isEnabledRef.current = isEnabled; - - const presentSheet = useCallback((sheet: IsolatedBottomSheetModalRef) => { - if (isPresentedRef.current) { - return; - } - - isPresentedRef.current = true; - hasNotifiedCloseRef.current = false; - sheet.present(); - }, []); - - const dismissSheet = useCallback((sheet: IsolatedBottomSheetModalRef) => { - if (!isPresentedRef.current) { - return; - } - - isPresentedRef.current = false; - sheet.dismiss(); - }, []); - - const notifyClose = useCallback(() => { - if (hasNotifiedCloseRef.current) { - return; - } - - hasNotifiedCloseRef.current = true; - onClose(); - }, [onClose]); - - const handleSheetDismiss = useCallback(() => { - isPresentedRef.current = false; - if (visibleRef.current) { - notifyClose(); - return; - } - hasNotifiedCloseRef.current = false; - }, [notifyClose]); - - const handleSheetChange = useCallback( - (index: number) => { - if (index === -1 && visibleRef.current) { - notifyClose(); - } - }, - [notifyClose], + const tracker = useMemo( + () => createBottomSheetVisibilityTracker({ onClose: () => onCloseRef.current() }), + [], ); const setSheetRef = useCallback( (instance: IsolatedBottomSheetModalRef | null) => { - sheetRef.current = instance; - if (instance && visibleRef.current && isEnabledRef.current !== false) { - presentSheet(instance); - } + tracker.attachController(instance as BottomSheetController | null); }, - [presentSheet], + [tracker], ); + const handleSheetChange = useCallback( + (index: number) => tracker.handleSheetIndexChange(index), + [tracker], + ); + + const handleSheetDismiss = useCallback(() => tracker.handleSheetDismiss(), [tracker]); + useEffect(() => { - if (isEnabled === false) return; - - const sheet = sheetRef.current; - if (visible) { - if (!sheet) { - return; - } - - presentSheet(sheet); - return; - } - - if (sheet) { - dismissSheet(sheet); - } - }, [dismissSheet, isEnabled, presentSheet, visible]); + tracker.syncDesired({ visible, isEnabled }); + }, [isEnabled, tracker, visible]); return { sheetRef: setSheetRef, diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.test.ts b/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.test.ts new file mode 100644 index 000000000..762403a1d --- /dev/null +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "vitest"; +import { + type BottomSheetController, + createBottomSheetVisibilityTracker, +} from "./visibility-tracker"; + +interface FakeBottomSheetEvent { + type: "present" | "dismiss"; +} + +class FakeBottomSheet implements BottomSheetController { + events: FakeBottomSheetEvent[] = []; + + present(): void { + this.events.push({ type: "present" }); + } + + dismiss(): void { + this.events.push({ type: "dismiss" }); + } +} + +function setup() { + const sheet = new FakeBottomSheet(); + let closeCount = 0; + const tracker = createBottomSheetVisibilityTracker({ + onClose: () => { + closeCount += 1; + }, + }); + return { + sheet, + tracker, + closeCount: () => closeCount, + }; +} + +describe("bottom sheet visibility tracker", () => { + it("presents once when the sheet becomes visible and dismisses when it goes back to hidden", () => { + const { sheet, tracker } = setup(); + tracker.attachController(sheet); + + tracker.syncDesired({ visible: false }); + expect(sheet.events).toEqual([]); + + tracker.syncDesired({ visible: true }); + expect(sheet.events).toEqual([{ type: "present" }]); + + tracker.syncDesired({ visible: true }); + expect(sheet.events).toEqual([{ type: "present" }]); + + tracker.syncDesired({ visible: false }); + expect(sheet.events).toEqual([{ type: "present" }, { type: "dismiss" }]); + }); + + it("waits to present until the sheet controller becomes available", () => { + const { sheet, tracker } = setup(); + tracker.syncDesired({ visible: true }); + expect(sheet.events).toEqual([]); + + tracker.attachController(sheet); + expect(sheet.events).toEqual([{ type: "present" }]); + }); + + it("does not present while disabled", () => { + const { sheet, tracker } = setup(); + tracker.attachController(sheet); + + tracker.syncDesired({ visible: true, isEnabled: false }); + expect(sheet.events).toEqual([]); + }); + + it("only reports a user close when the sheet was visible", () => { + const { sheet, tracker, closeCount } = setup(); + tracker.attachController(sheet); + tracker.syncDesired({ visible: true }); + + tracker.handleSheetIndexChange(-1); + expect(closeCount()).toBe(1); + + tracker.syncDesired({ visible: false }); + tracker.handleSheetIndexChange(-1); + expect(closeCount()).toBe(1); + }); + + it("reports a dismiss while visible as a close request", () => { + const { sheet, tracker, closeCount } = setup(); + tracker.attachController(sheet); + tracker.syncDesired({ visible: true }); + + tracker.handleSheetDismiss(); + + expect(closeCount()).toBe(1); + }); + + it("deduplicates close notifications from change and dismiss callbacks", () => { + const { sheet, tracker, closeCount } = setup(); + tracker.attachController(sheet); + tracker.syncDesired({ visible: true }); + + tracker.handleSheetIndexChange(-1); + tracker.handleSheetDismiss(); + + expect(closeCount()).toBe(1); + }); + + it("allows a new close notification after re-presenting the sheet", () => { + const { sheet, tracker, closeCount } = setup(); + tracker.attachController(sheet); + tracker.syncDesired({ visible: true }); + + tracker.handleSheetIndexChange(-1); + expect(closeCount()).toBe(1); + + tracker.syncDesired({ visible: false }); + tracker.syncDesired({ visible: true }); + + tracker.handleSheetIndexChange(-1); + expect(closeCount()).toBe(2); + }); +}); diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.ts b/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.ts new file mode 100644 index 000000000..a78a34e4a --- /dev/null +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.ts @@ -0,0 +1,77 @@ +export interface BottomSheetController { + present(): void; + dismiss(): void; +} + +export interface BottomSheetVisibilityInput { + visible: boolean; + isEnabled?: boolean; +} + +export interface BottomSheetVisibilityTracker { + attachController(controller: BottomSheetController | null): void; + syncDesired(input: BottomSheetVisibilityInput): void; + handleSheetIndexChange(index: number): void; + handleSheetDismiss(): void; +} + +export function createBottomSheetVisibilityTracker(opts: { + onClose: () => void; +}): BottomSheetVisibilityTracker { + let controller: BottomSheetController | null = null; + let visible = false; + let isEnabled: boolean | undefined; + let isPresented = false; + let hasNotifiedClose = false; + + function present(): void { + if (!controller || isPresented) return; + isPresented = true; + hasNotifiedClose = false; + controller.present(); + } + + function dismiss(): void { + if (!controller || !isPresented) return; + isPresented = false; + controller.dismiss(); + } + + function notifyClose(): void { + if (hasNotifiedClose) return; + hasNotifiedClose = true; + opts.onClose(); + } + + return { + attachController(next) { + controller = next; + if (next && visible && isEnabled !== false) { + present(); + } + }, + syncDesired(next) { + visible = next.visible; + isEnabled = next.isEnabled; + if (isEnabled === false) return; + if (visible) { + present(); + return; + } + dismiss(); + }, + handleSheetIndexChange(index) { + if (index === -1 && visible) { + notifyClose(); + } + }, + handleSheetDismiss() { + isPresented = false; + if (visible) { + notifyClose(); + return; + } + hasNotifiedClose = false; + }, + }; +} diff --git a/packages/app/src/desktop/attachments/desktop-attachment-bridge.ts b/packages/app/src/desktop/attachments/desktop-attachment-bridge.ts new file mode 100644 index 000000000..63c869a59 --- /dev/null +++ b/packages/app/src/desktop/attachments/desktop-attachment-bridge.ts @@ -0,0 +1,59 @@ +import type { AttachmentMetadata } from "@/attachments/types"; +import { + copyDesktopAttachmentFile, + deleteDesktopAttachmentFile, + garbageCollectDesktopAttachmentFiles, + writeDesktopAttachmentBase64, + writeDesktopAttachmentBytes, +} from "@/desktop/attachments/desktop-file-commands"; +import { + createBrowserObjectUrlMinter, + createDesktopFileReader, + createDesktopPreviewUrlResolver, + readDesktopFileBase64, +} from "@/desktop/attachments/desktop-preview-url"; + +export interface DesktopAttachmentFileResult { + path: string; + byteSize: number; +} + +export interface DesktopAttachmentBridge { + copyFile(input: { + attachmentId: string; + sourcePath: string; + extension?: string | null; + }): Promise; + writeBase64(input: { + attachmentId: string; + base64: string; + extension?: string | null; + }): Promise; + writeBytes(input: { + attachmentId: string; + bytes: Uint8Array; + extension?: string | null; + }): Promise; + deleteFile(input: { path: string }): Promise; + garbageCollect(input: { referencedIds: readonly string[] }): Promise; + readFileBase64(path: string): Promise; + resolvePreviewUrl(attachment: AttachmentMetadata): Promise; + releasePreviewUrl(input: { url: string }): Promise; +} + +export function createDesktopAttachmentBridge(): DesktopAttachmentBridge { + const previewUrls = createDesktopPreviewUrlResolver({ + reader: createDesktopFileReader(), + objectUrls: createBrowserObjectUrlMinter(), + }); + return { + copyFile: copyDesktopAttachmentFile, + writeBase64: writeDesktopAttachmentBase64, + writeBytes: writeDesktopAttachmentBytes, + deleteFile: deleteDesktopAttachmentFile, + garbageCollect: garbageCollectDesktopAttachmentFiles, + readFileBase64: readDesktopFileBase64, + resolvePreviewUrl: (attachment) => previewUrls.resolve(attachment), + releasePreviewUrl: (input) => previewUrls.release(input), + }; +} diff --git a/packages/app/src/desktop/attachments/desktop-attachment-store.test.ts b/packages/app/src/desktop/attachments/desktop-attachment-store.test.ts index 4903380e1..aef1099a6 100644 --- a/packages/app/src/desktop/attachments/desktop-attachment-store.test.ts +++ b/packages/app/src/desktop/attachments/desktop-attachment-store.test.ts @@ -1,63 +1,12 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { createDesktopAttachmentStore } from "./desktop-attachment-store"; - -const { - copyDesktopAttachmentFileMock, - writeDesktopAttachmentBase64Mock, - writeDesktopAttachmentBytesMock, - deleteDesktopAttachmentFileMock, - garbageCollectDesktopAttachmentFilesMock, - readDesktopFileBase64Mock, - resolveDesktopPreviewUrlMock, - releaseDesktopPreviewUrlMock, -} = vi.hoisted(() => ({ - copyDesktopAttachmentFileMock: vi.fn(async () => ({ - path: "/managed/att_1.png", - byteSize: 4, - })), - writeDesktopAttachmentBase64Mock: vi.fn(async () => ({ - path: "/managed/att_2.png", - byteSize: 4, - })), - writeDesktopAttachmentBytesMock: vi.fn(async () => ({ - path: "/managed/att_bytes.png", - byteSize: 4, - })), - deleteDesktopAttachmentFileMock: vi.fn(async () => true), - garbageCollectDesktopAttachmentFilesMock: vi.fn(async () => 0), - readDesktopFileBase64Mock: vi.fn(async () => "AAECAw=="), - resolveDesktopPreviewUrlMock: vi.fn(async () => "blob:test"), - releaseDesktopPreviewUrlMock: vi.fn(async () => {}), -})); - -vi.mock("./desktop-file-commands", () => ({ - copyDesktopAttachmentFile: copyDesktopAttachmentFileMock, - writeDesktopAttachmentBase64: writeDesktopAttachmentBase64Mock, - writeDesktopAttachmentBytes: writeDesktopAttachmentBytesMock, - deleteDesktopAttachmentFile: deleteDesktopAttachmentFileMock, - garbageCollectDesktopAttachmentFiles: garbageCollectDesktopAttachmentFilesMock, -})); - -vi.mock("./desktop-preview-url", () => ({ - readDesktopFileBase64: readDesktopFileBase64Mock, - resolveDesktopPreviewUrl: resolveDesktopPreviewUrlMock, - releaseDesktopPreviewUrl: releaseDesktopPreviewUrlMock, -})); +import { createFakeDesktopAttachmentBridge } from "./test-utils/fake-desktop-attachment-bridge"; describe("desktop attachment store", () => { - beforeEach(() => { - copyDesktopAttachmentFileMock.mockClear(); - writeDesktopAttachmentBase64Mock.mockClear(); - writeDesktopAttachmentBytesMock.mockClear(); - deleteDesktopAttachmentFileMock.mockClear(); - garbageCollectDesktopAttachmentFilesMock.mockClear(); - readDesktopFileBase64Mock.mockClear(); - resolveDesktopPreviewUrlMock.mockClear(); - releaseDesktopPreviewUrlMock.mockClear(); - }); - it("saves dropped file paths as desktop-file metadata", async () => { - const store = createDesktopAttachmentStore(); + const fake = createFakeDesktopAttachmentBridge(); + const store = createDesktopAttachmentStore(fake.bridge); + const attachment = await store.save({ id: "att_1", mimeType: "image/png", @@ -67,17 +16,25 @@ describe("desktop attachment store", () => { }, }); - expect(copyDesktopAttachmentFileMock).toHaveBeenCalledWith({ - attachmentId: "att_1", - sourcePath: "/Users/test/Desktop/image.png", - extension: ".png", + expect(fake.savedEntries).toEqual([ + { + attachmentId: "att_1", + path: "/managed/att_1.png", + byteSize: 4, + extension: ".png", + source: { kind: "copy", sourcePath: "/Users/test/Desktop/image.png" }, + }, + ]); + expect(attachment).toMatchObject({ + storageType: "desktop-file", + storageKey: "/managed/att_1.png", }); - expect(attachment.storageType).toBe("desktop-file"); - expect(attachment.storageKey).toBe("/managed/att_1.png"); }); it("saves blob/data-url sources via desktop filesystem writes", async () => { - const store = createDesktopAttachmentStore(); + const fake = createFakeDesktopAttachmentBridge(); + const store = createDesktopAttachmentStore(fake.bridge); + await store.save({ id: "att_2", source: { @@ -86,16 +43,22 @@ describe("desktop attachment store", () => { }, }); - expect(writeDesktopAttachmentBase64Mock).toHaveBeenCalledWith({ - attachmentId: "att_2", - base64: "AAECAw==", - extension: ".png", - }); + expect(fake.savedEntries).toEqual([ + { + attachmentId: "att_2", + path: "/managed/att_2.png", + byteSize: 4, + extension: ".png", + source: { kind: "base64", base64: "AAECAw==" }, + }, + ]); }); it("saves raw byte sources via desktop filesystem writes", async () => { - const store = createDesktopAttachmentStore(); + const fake = createFakeDesktopAttachmentBridge(); + const store = createDesktopAttachmentStore(fake.bridge); const bytes = new Uint8Array([0, 1, 2, 3]); + const attachment = await store.save({ id: "att_bytes", mimeType: "image/png", @@ -106,12 +69,15 @@ describe("desktop attachment store", () => { }, }); - expect(writeDesktopAttachmentBytesMock).toHaveBeenCalledWith({ - attachmentId: "att_bytes", - bytes, - extension: ".png", - }); - expect(writeDesktopAttachmentBase64Mock).not.toHaveBeenCalled(); + expect(fake.savedEntries).toEqual([ + { + attachmentId: "att_bytes", + path: "/managed/att_bytes.png", + byteSize: 4, + extension: ".png", + source: { kind: "bytes", bytes }, + }, + ]); expect(attachment).toMatchObject({ id: "att_bytes", mimeType: "image/png", @@ -123,7 +89,8 @@ describe("desktop attachment store", () => { }); it("delegates encode/preview/delete/gc to desktop command path", async () => { - const store = createDesktopAttachmentStore(); + const fake = createFakeDesktopAttachmentBridge(); + const store = createDesktopAttachmentStore(fake.bridge); const attachment = { id: "att_3", mimeType: "image/jpeg", @@ -138,14 +105,10 @@ describe("desktop attachment store", () => { await store.delete({ attachment }); await store.garbageCollect({ referencedIds: new Set(["att_3"]) }); - expect(readDesktopFileBase64Mock).toHaveBeenCalledWith("/managed/att_3.jpg"); - expect(resolveDesktopPreviewUrlMock).toHaveBeenCalledWith(attachment); - expect(releaseDesktopPreviewUrlMock).toHaveBeenCalledWith({ url: "blob:test" }); - expect(deleteDesktopAttachmentFileMock).toHaveBeenCalledWith({ - path: "/managed/att_3.jpg", - }); - expect(garbageCollectDesktopAttachmentFilesMock).toHaveBeenCalledWith({ - referencedIds: ["att_3"], - }); + expect(fake.readBase64Calls).toEqual(["/managed/att_3.jpg"]); + expect(fake.resolvedPreviewUrls).toEqual([attachment]); + expect(fake.releasedPreviewUrls).toEqual(["blob:test"]); + expect(fake.deletedPaths).toEqual(["/managed/att_3.jpg"]); + expect(fake.garbageCollections).toEqual([{ referencedIds: ["att_3"] }]); }); }); diff --git a/packages/app/src/desktop/attachments/desktop-attachment-store.ts b/packages/app/src/desktop/attachments/desktop-attachment-store.ts index 579590e75..967f31469 100644 --- a/packages/app/src/desktop/attachments/desktop-attachment-store.ts +++ b/packages/app/src/desktop/attachments/desktop-attachment-store.ts @@ -7,18 +7,7 @@ import { normalizeMimeType, parseDataUrl, } from "@/attachments/utils"; -import { - copyDesktopAttachmentFile, - deleteDesktopAttachmentFile, - garbageCollectDesktopAttachmentFiles, - writeDesktopAttachmentBase64, - writeDesktopAttachmentBytes, -} from "@/desktop/attachments/desktop-file-commands"; -import { - readDesktopFileBase64, - releaseDesktopPreviewUrl, - resolveDesktopPreviewUrl, -} from "@/desktop/attachments/desktop-preview-url"; +import type { DesktopAttachmentBridge } from "./desktop-attachment-bridge"; const IMAGE_EXTENSION_BY_MIME_TYPE: Record = { "image/png": ".png", @@ -77,12 +66,15 @@ function toDesktopMetadata(input: { }; } -async function saveDesktopAttachmentFromFileUri(input: { - id: string; - uri: string; - mimeType?: string; - fileName?: string | null; -}): Promise { +async function saveDesktopAttachmentFromFileUri( + bridge: DesktopAttachmentBridge, + input: { + id: string; + uri: string; + mimeType?: string; + fileName?: string | null; + }, +): Promise { const sourcePath = fileUriToPath(input.uri); const fileName = input.fileName ?? inferFileNameFromPath(sourcePath); const mimeType = normalizeMimeType(input.mimeType); @@ -92,7 +84,7 @@ async function saveDesktopAttachmentFromFileUri(input: { mimeType, }); - const result = await copyDesktopAttachmentFile({ + const result = await bridge.copyFile({ attachmentId: input.id, sourcePath, extension, @@ -107,18 +99,21 @@ async function saveDesktopAttachmentFromFileUri(input: { }); } -async function saveDesktopAttachmentFromBase64(input: { - id: string; - base64: string; - mimeType: string; - fileName?: string | null; -}): Promise { +async function saveDesktopAttachmentFromBase64( + bridge: DesktopAttachmentBridge, + input: { + id: string; + base64: string; + mimeType: string; + fileName?: string | null; + }, +): Promise { const extension = extensionForAttachment({ fileName: input.fileName, mimeType: input.mimeType, }); - const result = await writeDesktopAttachmentBase64({ + const result = await bridge.writeBase64({ attachmentId: input.id, base64: input.base64, extension, @@ -133,18 +128,21 @@ async function saveDesktopAttachmentFromBase64(input: { }); } -async function saveDesktopAttachmentFromBytes(input: { - id: string; - bytes: Uint8Array; - mimeType: string; - fileName?: string | null; -}): Promise { +async function saveDesktopAttachmentFromBytes( + bridge: DesktopAttachmentBridge, + input: { + id: string; + bytes: Uint8Array; + mimeType: string; + fileName?: string | null; + }, +): Promise { const extension = extensionForAttachment({ fileName: input.fileName, mimeType: input.mimeType, }); - const result = await writeDesktopAttachmentBytes({ + const result = await bridge.writeBytes({ attachmentId: input.id, bytes: input.bytes, extension, @@ -165,7 +163,7 @@ function assertDesktopAttachment(attachment: AttachmentMetadata): void { } } -export function createDesktopAttachmentStore(): AttachmentStore { +export function createDesktopAttachmentStore(bridge: DesktopAttachmentBridge): AttachmentStore { return { storageType: "desktop-file", @@ -174,7 +172,7 @@ export function createDesktopAttachmentStore(): AttachmentStore { const fileName = input.fileName ?? null; if (input.source.kind === "file_uri") { - return await saveDesktopAttachmentFromFileUri({ + return await saveDesktopAttachmentFromFileUri(bridge, { id, uri: input.source.uri, mimeType: input.mimeType, @@ -185,7 +183,7 @@ export function createDesktopAttachmentStore(): AttachmentStore { if (input.source.kind === "data_url") { const parsed = parseDataUrl(input.source.dataUrl); const mimeType = normalizeMimeType(input.mimeType ?? parsed.mimeType); - return await saveDesktopAttachmentFromBase64({ + return await saveDesktopAttachmentFromBase64(bridge, { id, base64: parsed.base64, mimeType, @@ -195,7 +193,7 @@ export function createDesktopAttachmentStore(): AttachmentStore { if (input.source.kind === "bytes") { const mimeType = normalizeMimeType(input.mimeType); - return await saveDesktopAttachmentFromBytes({ + return await saveDesktopAttachmentFromBytes(bridge, { id, bytes: input.source.bytes, mimeType, @@ -205,7 +203,7 @@ export function createDesktopAttachmentStore(): AttachmentStore { const mimeType = normalizeMimeType(input.mimeType ?? input.source.blob.type); const base64 = await blobToBase64(input.source.blob); - return await saveDesktopAttachmentFromBase64({ + return await saveDesktopAttachmentFromBase64(bridge, { id, base64, mimeType, @@ -215,26 +213,26 @@ export function createDesktopAttachmentStore(): AttachmentStore { async encodeBase64({ attachment }): Promise { assertDesktopAttachment(attachment); - return await readDesktopFileBase64(attachment.storageKey); + return await bridge.readFileBase64(attachment.storageKey); }, async resolvePreviewUrl({ attachment }): Promise { assertDesktopAttachment(attachment); - return await resolveDesktopPreviewUrl(attachment); + return await bridge.resolvePreviewUrl(attachment); }, async releasePreviewUrl({ attachment, url }): Promise { assertDesktopAttachment(attachment); - await releaseDesktopPreviewUrl({ url }); + await bridge.releasePreviewUrl({ url }); }, async delete({ attachment }): Promise { assertDesktopAttachment(attachment); - await deleteDesktopAttachmentFile({ path: attachment.storageKey }); + await bridge.deleteFile({ path: attachment.storageKey }); }, async garbageCollect({ referencedIds }): Promise { - await garbageCollectDesktopAttachmentFiles({ + await bridge.garbageCollect({ referencedIds: Array.from(referencedIds), }); }, diff --git a/packages/app/src/desktop/attachments/desktop-preview-url.test.ts b/packages/app/src/desktop/attachments/desktop-preview-url.test.ts index a18cd4ca7..6c0c4352a 100644 --- a/packages/app/src/desktop/attachments/desktop-preview-url.test.ts +++ b/packages/app/src/desktop/attachments/desktop-preview-url.test.ts @@ -1,66 +1,119 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -const { invokeDesktopCommandMock } = vi.hoisted(() => ({ - invokeDesktopCommandMock: vi.fn(async () => "AAECAw=="), -})); - -vi.mock("@/desktop/electron/invoke", () => ({ - invokeDesktopCommand: invokeDesktopCommandMock, -})); - +import { describe, expect, it } from "vitest"; +import type { AttachmentMetadata } from "@/attachments/types"; import { - __desktopPreviewUrlTestUtils, - releaseDesktopPreviewUrl, - resolveDesktopPreviewUrl, + createDesktopPreviewUrlResolver, + type DesktopFileReader, + type ObjectUrlMinter, } from "./desktop-preview-url"; +class FakeDesktopReader implements DesktopFileReader { + readonly reads: string[] = []; + constructor(private readonly files: Record) {} + + async readFileBase64(storageKey: string): Promise { + this.reads.push(storageKey); + const base64 = this.files[storageKey]; + if (base64 === undefined) { + throw new Error(`FakeDesktopReader: no file registered for ${storageKey}`); + } + return base64; + } +} + +interface FakeMintedUrl { + url: string; + mimeType: string; + base64: string; +} + +class FakeObjectUrls implements ObjectUrlMinter { + readonly minted: FakeMintedUrl[] = []; + readonly revoked: string[] = []; + private nextId = 1; + private readonly supportsCreate: boolean; + + constructor(options: { supportsCreate?: boolean } = {}) { + this.supportsCreate = options.supportsCreate ?? true; + } + + tryCreate(input: { mimeType: string; base64: string }): string | null { + if (!this.supportsCreate) { + return null; + } + const url = `blob:fake-${this.nextId++}`; + this.minted.push({ url, ...input }); + return url; + } + + revoke(url: string): void { + this.revoked.push(url); + } +} + +function attachment(overrides: Partial = {}): AttachmentMetadata { + return { + id: "att-1", + mimeType: "image/png", + storageType: "desktop-file", + storageKey: "/tmp/att-1.png", + fileName: null, + byteSize: null, + createdAt: 0, + ...overrides, + }; +} + describe("desktop preview URLs", () => { - const createObjectURL = vi.fn(() => "blob:desktop-preview-1"); - const revokeObjectURL = vi.fn(); + it("mints an object URL from the desktop file's base64 bytes", async () => { + const reader = new FakeDesktopReader({ "/tmp/att-1.png": "AAECAw==" }); + const objectUrls = new FakeObjectUrls(); + const resolver = createDesktopPreviewUrlResolver({ reader, objectUrls }); - beforeEach(() => { - vi.stubGlobal("URL", { - createObjectURL, - revokeObjectURL, - }); + const url = await resolver.resolve(attachment()); + + expect(url).toBe("blob:fake-1"); + expect(reader.reads).toEqual(["/tmp/att-1.png"]); + expect(objectUrls.minted).toEqual([ + { url: "blob:fake-1", mimeType: "image/png", base64: "AAECAw==" }, + ]); }); - afterEach(() => { - createObjectURL.mockClear(); - revokeObjectURL.mockClear(); - invokeDesktopCommandMock.mockClear(); - __desktopPreviewUrlTestUtils.clearActiveObjectUrls(); - vi.unstubAllGlobals(); + it("falls back to a data URL when the host cannot mint object URLs", async () => { + const reader = new FakeDesktopReader({ "/tmp/att-2.jpg": "AAECAw==" }); + const objectUrls = new FakeObjectUrls({ supportsCreate: false }); + const resolver = createDesktopPreviewUrlResolver({ reader, objectUrls }); + + const url = await resolver.resolve( + attachment({ id: "att-2", mimeType: "image/jpeg", storageKey: "/tmp/att-2.jpg" }), + ); + + expect(url).toBe("data:image/jpeg;base64,AAECAw=="); + expect(objectUrls.revoked).toEqual([]); }); - it("resolves renderer-safe blob URLs for desktop attachments", async () => { - const url = await resolveDesktopPreviewUrl({ - id: "att-1", - mimeType: "image/png", - storageType: "desktop-file", - storageKey: "/tmp/att-1.png", - createdAt: Date.now(), - }); + it("revokes only object URLs it minted", async () => { + const reader = new FakeDesktopReader({ "/tmp/att-3.jpg": "AAECAw==" }); + const objectUrls = new FakeObjectUrls(); + const resolver = createDesktopPreviewUrlResolver({ reader, objectUrls }); - expect(invokeDesktopCommandMock).toHaveBeenCalledWith("read_file_base64", { - path: "/tmp/att-1.png", - }); - expect(url).toBe("blob:desktop-preview-1"); - expect(url.startsWith("asset://")).toBe(false); - expect(createObjectURL).toHaveBeenCalledTimes(1); + const url = await resolver.resolve( + attachment({ id: "att-3", mimeType: "image/jpeg", storageKey: "/tmp/att-3.jpg" }), + ); + await resolver.release({ url }); + await resolver.release({ url: "blob:never-minted" }); + + expect(objectUrls.revoked).toEqual([url]); }); - it("releases blob URLs via URL.revokeObjectURL", async () => { - const url = await resolveDesktopPreviewUrl({ - id: "att-2", - mimeType: "image/jpeg", - storageType: "desktop-file", - storageKey: "/tmp/att-2.jpg", - createdAt: Date.now(), - }); + it("only revokes a minted URL once across repeated release calls", async () => { + const reader = new FakeDesktopReader({ "/tmp/att-4.png": "AAECAw==" }); + const objectUrls = new FakeObjectUrls(); + const resolver = createDesktopPreviewUrlResolver({ reader, objectUrls }); - await releaseDesktopPreviewUrl({ url }); + const url = await resolver.resolve(attachment({ id: "att-4", storageKey: "/tmp/att-4.png" })); + await resolver.release({ url }); + await resolver.release({ url }); - expect(revokeObjectURL).toHaveBeenCalledWith("blob:desktop-preview-1"); + expect(objectUrls.revoked).toEqual([url]); }); }); diff --git a/packages/app/src/desktop/attachments/desktop-preview-url.ts b/packages/app/src/desktop/attachments/desktop-preview-url.ts index d3efb3122..942b8033b 100644 --- a/packages/app/src/desktop/attachments/desktop-preview-url.ts +++ b/packages/app/src/desktop/attachments/desktop-preview-url.ts @@ -3,11 +3,19 @@ import { fileUriToPath } from "@/attachments/utils"; import { invokeDesktopCommand } from "@/desktop/electron/invoke"; import { Buffer } from "buffer"; -function base64ToUint8Array(base64: string): Uint8Array { - return new Uint8Array(Buffer.from(base64, "base64")); +export interface DesktopFileReader { + readFileBase64(storageKey: string): Promise; } -const activeObjectUrls = new Set(); +export interface ObjectUrlMinter { + tryCreate(input: { mimeType: string; base64: string }): string | null; + revoke(url: string): void; +} + +export interface DesktopPreviewUrlResolver { + resolve(attachment: AttachmentMetadata): Promise; + release(input: { url: string }): Promise; +} export async function readDesktopFileBase64(pathOrUri: string): Promise { return await invokeDesktopCommand("read_file_base64", { @@ -15,36 +23,55 @@ export async function readDesktopFileBase64(pathOrUri: string): Promise }); } -export async function resolveDesktopPreviewUrl(attachment: AttachmentMetadata): Promise { - const base64 = await readDesktopFileBase64(attachment.storageKey); - - if (typeof URL !== "undefined" && typeof URL.createObjectURL === "function") { - const bytes = base64ToUint8Array(base64); - const buffer = new ArrayBuffer(bytes.byteLength); - new Uint8Array(buffer).set(bytes); - const blob = new Blob([buffer], { type: attachment.mimeType }); - const objectUrl = URL.createObjectURL(blob); - activeObjectUrls.add(objectUrl); - return objectUrl; - } - - return `data:${attachment.mimeType};base64,${base64}`; +export function createDesktopFileReader(): DesktopFileReader { + return { readFileBase64: readDesktopFileBase64 }; } -export async function releaseDesktopPreviewUrl(input: { url: string }): Promise { - if (!activeObjectUrls.has(input.url)) { - return; - } - activeObjectUrls.delete(input.url); - - if (typeof URL !== "undefined" && typeof URL.revokeObjectURL === "function") { - URL.revokeObjectURL(input.url); - } +export function createBrowserObjectUrlMinter(): ObjectUrlMinter { + return { + tryCreate({ mimeType, base64 }) { + if (typeof URL === "undefined" || typeof URL.createObjectURL !== "function") { + return null; + } + const bytes = base64ToUint8Array(base64); + const buffer = new ArrayBuffer(bytes.byteLength); + new Uint8Array(buffer).set(bytes); + const blob = new Blob([buffer], { type: mimeType }); + return URL.createObjectURL(blob); + }, + revoke(url) { + if (typeof URL !== "undefined" && typeof URL.revokeObjectURL === "function") { + URL.revokeObjectURL(url); + } + }, + }; } -export const __desktopPreviewUrlTestUtils = { - base64ToUint8Array, - clearActiveObjectUrls: () => { - activeObjectUrls.clear(); - }, -}; +export function createDesktopPreviewUrlResolver(deps: { + reader: DesktopFileReader; + objectUrls: ObjectUrlMinter; +}): DesktopPreviewUrlResolver { + const tracked = new Set(); + return { + async resolve(attachment) { + const base64 = await deps.reader.readFileBase64(attachment.storageKey); + const url = deps.objectUrls.tryCreate({ mimeType: attachment.mimeType, base64 }); + if (url === null) { + return `data:${attachment.mimeType};base64,${base64}`; + } + tracked.add(url); + return url; + }, + async release({ url }) { + if (!tracked.has(url)) { + return; + } + tracked.delete(url); + deps.objectUrls.revoke(url); + }, + }; +} + +function base64ToUint8Array(base64: string): Uint8Array { + return new Uint8Array(Buffer.from(base64, "base64")); +} diff --git a/packages/app/src/desktop/attachments/test-utils/fake-desktop-attachment-bridge.ts b/packages/app/src/desktop/attachments/test-utils/fake-desktop-attachment-bridge.ts new file mode 100644 index 000000000..447424008 --- /dev/null +++ b/packages/app/src/desktop/attachments/test-utils/fake-desktop-attachment-bridge.ts @@ -0,0 +1,103 @@ +import type { AttachmentMetadata } from "@/attachments/types"; +import type { + DesktopAttachmentBridge, + DesktopAttachmentFileResult, +} from "@/desktop/attachments/desktop-attachment-bridge"; + +export interface FakeDesktopAttachmentEntry { + attachmentId: string; + path: string; + byteSize: number; + extension: string | null; + source: + | { kind: "copy"; sourcePath: string } + | { kind: "base64"; base64: string } + | { kind: "bytes"; bytes: Uint8Array }; +} + +export interface FakeDesktopAttachmentBridge { + bridge: DesktopAttachmentBridge; + savedEntries: FakeDesktopAttachmentEntry[]; + deletedPaths: string[]; + garbageCollections: Array<{ referencedIds: readonly string[] }>; + readBase64Calls: string[]; + resolvedPreviewUrls: AttachmentMetadata[]; + releasedPreviewUrls: string[]; +} + +export function createFakeDesktopAttachmentBridge(): FakeDesktopAttachmentBridge { + const savedEntries: FakeDesktopAttachmentEntry[] = []; + const deletedPaths: string[] = []; + const garbageCollections: Array<{ referencedIds: readonly string[] }> = []; + const readBase64Calls: string[] = []; + const resolvedPreviewUrls: AttachmentMetadata[] = []; + const releasedPreviewUrls: string[] = []; + + function buildPath(attachmentId: string, extension: string | null | undefined): string { + return `/managed/${attachmentId}${extension ?? ""}`; + } + + function record(entry: FakeDesktopAttachmentEntry): DesktopAttachmentFileResult { + savedEntries.push(entry); + return { path: entry.path, byteSize: entry.byteSize }; + } + + const bridge: DesktopAttachmentBridge = { + async copyFile({ attachmentId, sourcePath, extension }) { + return record({ + attachmentId, + path: buildPath(attachmentId, extension), + byteSize: 4, + extension: extension ?? null, + source: { kind: "copy", sourcePath }, + }); + }, + async writeBase64({ attachmentId, base64, extension }) { + return record({ + attachmentId, + path: buildPath(attachmentId, extension), + byteSize: 4, + extension: extension ?? null, + source: { kind: "base64", base64 }, + }); + }, + async writeBytes({ attachmentId, bytes, extension }) { + return record({ + attachmentId, + path: buildPath(attachmentId, extension), + byteSize: bytes.byteLength, + extension: extension ?? null, + source: { kind: "bytes", bytes }, + }); + }, + async deleteFile({ path }) { + deletedPaths.push(path); + return true; + }, + async garbageCollect({ referencedIds }) { + garbageCollections.push({ referencedIds }); + return 0; + }, + async readFileBase64(path) { + readBase64Calls.push(path); + return "AAECAw=="; + }, + async resolvePreviewUrl(attachment) { + resolvedPreviewUrls.push(attachment); + return "blob:test"; + }, + async releasePreviewUrl({ url }) { + releasedPreviewUrls.push(url); + }, + }; + + return { + bridge, + savedEntries, + deletedPaths, + garbageCollections, + readBase64Calls, + resolvedPreviewUrls, + releasedPreviewUrls, + }; +} diff --git a/packages/app/src/desktop/daemon/desktop-daemon-transport.test.ts b/packages/app/src/desktop/daemon/desktop-daemon-transport.test.ts index f999a1a13..a9fe2497c 100644 --- a/packages/app/src/desktop/daemon/desktop-daemon-transport.test.ts +++ b/packages/app/src/desktop/daemon/desktop-daemon-transport.test.ts @@ -1,135 +1,46 @@ -import { describe, expect, it, vi, beforeEach } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import { createDesktopLocalDaemonTransportFactory } from "./desktop-daemon-transport"; +import { createFakeLocalDaemonTransportRpc } from "./test-local-daemon-transport-rpc"; -const desktopDaemonMock = vi.hoisted(() => { - let eventHandler: - | ((payload: { - sessionId: string; - kind: "open" | "message" | "close" | "error"; - text?: string | null; - binaryBase64?: string | null; - code?: number | null; - reason?: string | null; - error?: string | null; - }) => void) - | null = null; - - const openLocalTransportSession = vi.fn<(...args: unknown[]) => Promise>(); - const listenToLocalTransportEvents = vi.fn( - async ( - handler: typeof eventHandler extends ((...args: infer A) => unknown) | null - ? (...args: A) => void - : never, - ) => { - eventHandler = handler; - return () => { - eventHandler = null; - }; - }, - ); - const sendLocalTransportMessage = vi.fn(async () => undefined); - const closeLocalTransportSession = vi.fn(async () => undefined); - - return { - openLocalTransportSession, - listenToLocalTransportEvents, - sendLocalTransportMessage, - closeLocalTransportSession, - emitEvent(payload: { - sessionId: string; - kind: "open" | "message" | "close" | "error"; - text?: string | null; - binaryBase64?: string | null; - code?: number | null; - reason?: string | null; - error?: string | null; - }) { - eventHandler?.(payload); - }, - }; -}); - -vi.mock("./desktop-daemon", () => ({ - openLocalTransportSession: desktopDaemonMock.openLocalTransportSession, - listenToLocalTransportEvents: desktopDaemonMock.listenToLocalTransportEvents, - sendLocalTransportMessage: desktopDaemonMock.sendLocalTransportMessage, - closeLocalTransportSession: desktopDaemonMock.closeLocalTransportSession, -})); +const LOCAL_URL = "paseo+local://socket?path=%2Ftmp%2Fpaseo.sock"; describe("desktop-daemon-transport", () => { - beforeEach(() => { - desktopDaemonMock.openLocalTransportSession.mockReset(); - desktopDaemonMock.listenToLocalTransportEvents.mockClear(); - desktopDaemonMock.sendLocalTransportMessage.mockClear(); - desktopDaemonMock.closeLocalTransportSession.mockClear(); - }); - it("emits open after the session resolves even if the rust open event raced earlier", async () => { - let resolveSession!: (sessionId: string) => void; - desktopDaemonMock.openLocalTransportSession.mockImplementation( - () => - new Promise((resolve) => { - resolveSession = resolve; - }), - ); - - const mod = await import("./desktop-daemon-transport"); - const transportFactory = mod.createDesktopLocalDaemonTransportFactory(); + const rpc = createFakeLocalDaemonTransportRpc(); + const transportFactory = createDesktopLocalDaemonTransportFactory(rpc); expect(transportFactory).not.toBeNull(); - const transport = transportFactory!({ - url: "paseo+local://socket?path=%2Ftmp%2Fpaseo.sock", - }); + const transport = transportFactory!({ url: LOCAL_URL }); const onOpen = vi.fn(); transport.onOpen(onOpen); - desktopDaemonMock.emitEvent({ - sessionId: "local-session-1", - kind: "open", - }); - + rpc.emitEvent({ sessionId: "local-session-1", kind: "open" }); expect(onOpen).not.toHaveBeenCalled(); - resolveSession("local-session-1"); + rpc.resolveOpen("local-session-1"); await Promise.resolve(); expect(onOpen).toHaveBeenCalledTimes(1); }); it("cleans up late async setup after the transport is closed", async () => { - let resolveSession!: (sessionId: string) => void; - let resolveListen!: (cleanup: () => void) => void; + const rpc = createFakeLocalDaemonTransportRpc(); const cleanup = vi.fn(); - desktopDaemonMock.openLocalTransportSession.mockImplementation( - () => - new Promise((resolve) => { - resolveSession = resolve; - }), - ); - desktopDaemonMock.listenToLocalTransportEvents.mockImplementation( - () => - new Promise<() => void>((resolve) => { - resolveListen = resolve; - }), - ); - - const mod = await import("./desktop-daemon-transport"); - const transportFactory = mod.createDesktopLocalDaemonTransportFactory(); + const transportFactory = createDesktopLocalDaemonTransportFactory(rpc); expect(transportFactory).not.toBeNull(); - const transport = transportFactory!({ - url: "paseo+local://socket?path=%2Ftmp%2Fpaseo.sock", - }); + const transport = transportFactory!({ url: LOCAL_URL }); transport.close(); - resolveSession("local-session-2"); - resolveListen(cleanup); + rpc.resolveOpen("local-session-2"); + rpc.resolveListen(cleanup); await Promise.resolve(); await Promise.resolve(); - expect(desktopDaemonMock.closeLocalTransportSession).toHaveBeenCalledWith("local-session-2"); + expect(rpc.closedSessions).toEqual(["local-session-2"]); expect(cleanup).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/app/src/desktop/daemon/desktop-daemon-transport.ts b/packages/app/src/desktop/daemon/desktop-daemon-transport.ts index b17ee6cc2..1d7894821 100644 --- a/packages/app/src/desktop/daemon/desktop-daemon-transport.ts +++ b/packages/app/src/desktop/daemon/desktop-daemon-transport.ts @@ -2,13 +2,11 @@ import type { DaemonTransport, DaemonTransportFactory, } from "@getpaseo/client/internal/daemon-client"; +import type { LocalTransportTarget } from "./desktop-daemon"; import { - closeLocalTransportSession, - listenToLocalTransportEvents, - openLocalTransportSession, - sendLocalTransportMessage, - type LocalTransportTarget, -} from "./desktop-daemon"; + defaultLocalDaemonTransportRpc, + type LocalDaemonTransportRpc, +} from "./local-daemon-transport-rpc"; const LOCAL_TRANSPORT_SCHEME = "paseo+local:"; @@ -52,7 +50,9 @@ function parseLocalDaemonTransportUrl(url: string): LocalTransportTarget { }; } -export function createDesktopLocalDaemonTransportFactory(): DaemonTransportFactory | null { +export function createDesktopLocalDaemonTransportFactory( + rpc: LocalDaemonTransportRpc = defaultLocalDaemonTransportRpc, +): DaemonTransportFactory | null { return ({ url }) => { const target = parseLocalDaemonTransportUrl(url); let sessionId: string | null = null; @@ -90,30 +90,31 @@ export function createDesktopLocalDaemonTransportFactory(): DaemonTransportFacto } }; - void listenToLocalTransportEvents((payload) => { - if (disposed || !sessionId || payload.sessionId !== sessionId) { - return; - } - if (payload.kind === "open") { - emitOpen(); - return; - } - if (payload.kind === "message") { - if (payload.text) { - emitMessage({ data: payload.text }); + void rpc + .listenToEvents((payload) => { + if (disposed || !sessionId || payload.sessionId !== sessionId) { return; } - if (payload.binaryBase64) { - emitMessage({ data: decodeBase64ToBytes(payload.binaryBase64) }); + if (payload.kind === "open") { + emitOpen(); + return; } - return; - } - if (payload.kind === "close") { - emitClose(payload); - return; - } - emitError(payload.error ?? "Local daemon transport error"); - }) + if (payload.kind === "message") { + if (payload.text) { + emitMessage({ data: payload.text }); + return; + } + if (payload.binaryBase64) { + emitMessage({ data: decodeBase64ToBytes(payload.binaryBase64) }); + } + return; + } + if (payload.kind === "close") { + emitClose(payload); + return; + } + emitError(payload.error ?? "Local daemon transport error"); + }) .then((cleanup) => { if (disposed) { cleanup(); @@ -126,10 +127,11 @@ export function createDesktopLocalDaemonTransportFactory(): DaemonTransportFacto emitError(error); }); - void openLocalTransportSession(target) + void rpc + .openSession(target) .then((id) => { if (disposed) { - void closeLocalTransportSession(id).catch((error) => emitError(error)); + void rpc.closeSession(id).catch((error) => emitError(error)); return; } sessionId = id; @@ -146,24 +148,20 @@ export function createDesktopLocalDaemonTransportFactory(): DaemonTransportFacto return; } if (typeof data === "string") { - void sendLocalTransportMessage({ sessionId, text: data }).catch((error) => - emitError(error), - ); + void rpc.sendMessage({ sessionId, text: data }).catch((error) => emitError(error)); return; } const binaryBase64 = encodeBinaryToBase64( data instanceof ArrayBuffer ? data : new Uint8Array(data), ); - void sendLocalTransportMessage({ sessionId, binaryBase64 }).catch((error) => - emitError(error), - ); + void rpc.sendMessage({ sessionId, binaryBase64 }).catch((error) => emitError(error)); }, close: () => { disposed = true; const currentSessionId = sessionId; sessionId = null; if (currentSessionId) { - void closeLocalTransportSession(currentSessionId).catch((error) => emitError(error)); + void rpc.closeSession(currentSessionId).catch((error) => emitError(error)); } unlisten?.(); unlisten = null; diff --git a/packages/app/src/desktop/daemon/local-daemon-transport-rpc.ts b/packages/app/src/desktop/daemon/local-daemon-transport-rpc.ts new file mode 100644 index 000000000..54961107e --- /dev/null +++ b/packages/app/src/desktop/daemon/local-daemon-transport-rpc.ts @@ -0,0 +1,31 @@ +import { + closeLocalTransportSession, + listenToLocalTransportEvents, + openLocalTransportSession, + sendLocalTransportMessage, + type LocalTransportTarget, +} from "./desktop-daemon"; + +export interface LocalDaemonTransportEvent { + sessionId: string; + kind: "open" | "message" | "close" | "error"; + text?: string | null; + binaryBase64?: string | null; + code?: number | null; + reason?: string | null; + error?: string | null; +} + +export interface LocalDaemonTransportRpc { + openSession(target: LocalTransportTarget): Promise; + listenToEvents(handler: (event: LocalDaemonTransportEvent) => void): Promise<() => void>; + sendMessage(input: { sessionId: string; text?: string; binaryBase64?: string }): Promise; + closeSession(sessionId: string): Promise; +} + +export const defaultLocalDaemonTransportRpc: LocalDaemonTransportRpc = { + openSession: openLocalTransportSession, + listenToEvents: listenToLocalTransportEvents, + sendMessage: sendLocalTransportMessage, + closeSession: closeLocalTransportSession, +}; diff --git a/packages/app/src/desktop/daemon/test-local-daemon-transport-rpc.ts b/packages/app/src/desktop/daemon/test-local-daemon-transport-rpc.ts new file mode 100644 index 000000000..8f2214b03 --- /dev/null +++ b/packages/app/src/desktop/daemon/test-local-daemon-transport-rpc.ts @@ -0,0 +1,86 @@ +import type { LocalTransportTarget } from "./desktop-daemon"; +import type { + LocalDaemonTransportEvent, + LocalDaemonTransportRpc, +} from "./local-daemon-transport-rpc"; + +export interface RecordedSend { + sessionId: string; + text?: string; + binaryBase64?: string; +} + +export interface FakeLocalDaemonTransportRpc extends LocalDaemonTransportRpc { + readonly openCalls: LocalTransportTarget[]; + readonly recordedSends: RecordedSend[]; + readonly closedSessions: string[]; + resolveOpen(sessionId: string): void; + rejectOpen(error: Error): void; + resolveListen(cleanup: () => void): void; + rejectListen(error: Error): void; + emitEvent(event: LocalDaemonTransportEvent): void; +} + +export function createFakeLocalDaemonTransportRpc(): FakeLocalDaemonTransportRpc { + const openCalls: LocalTransportTarget[] = []; + const recordedSends: RecordedSend[] = []; + const closedSessions: string[] = []; + let eventHandler: ((event: LocalDaemonTransportEvent) => void) | null = null; + let resolveOpenSession: ((sessionId: string) => void) | null = null; + let rejectOpenSession: ((error: Error) => void) | null = null; + let resolveListenPromise: ((cleanup: () => void) => void) | null = null; + let rejectListenPromise: ((error: Error) => void) | null = null; + + return { + openCalls, + recordedSends, + closedSessions, + openSession(target) { + openCalls.push(target); + return new Promise((resolve, reject) => { + resolveOpenSession = resolve; + rejectOpenSession = reject; + }); + }, + listenToEvents(handler) { + eventHandler = handler; + return new Promise<() => void>((resolve, reject) => { + resolveListenPromise = resolve; + rejectListenPromise = reject; + }); + }, + async sendMessage(input) { + recordedSends.push(input); + }, + async closeSession(sessionId) { + closedSessions.push(sessionId); + }, + resolveOpen(sessionId) { + if (!resolveOpenSession) { + throw new Error("openSession was not called"); + } + resolveOpenSession(sessionId); + }, + rejectOpen(error) { + if (!rejectOpenSession) { + throw new Error("openSession was not called"); + } + rejectOpenSession(error); + }, + resolveListen(cleanup) { + if (!resolveListenPromise) { + throw new Error("listenToEvents was not called"); + } + resolveListenPromise(cleanup); + }, + rejectListen(error) { + if (!rejectListenPromise) { + throw new Error("listenToEvents was not called"); + } + rejectListenPromise(error); + }, + emitEvent(event) { + eventHandler?.(event); + }, + }; +} diff --git a/packages/app/src/desktop/electron/idle.test.ts b/packages/app/src/desktop/electron/idle.test.ts index b3721d758..9308fca69 100644 --- a/packages/app/src/desktop/electron/idle.test.ts +++ b/packages/app/src/desktop/electron/idle.test.ts @@ -1,79 +1,54 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; +import { readDesktopSystemIdleTimeMs, type DesktopIpcInvoker } from "./idle"; -const { invokeDesktopCommandMock } = vi.hoisted(() => ({ - invokeDesktopCommandMock: vi.fn<() => Promise>(async () => 12_000), -})); +function fakeInvoker(result: () => Promise): DesktopIpcInvoker { + return () => result() as Promise; +} -vi.mock("@/desktop/electron/invoke", () => ({ - invokeDesktopCommand: invokeDesktopCommandMock, -})); +describe("readDesktopSystemIdleTimeMs", () => { + it("returns the millisecond value reported by the desktop idle command", async () => { + const invokedCommands: string[] = []; + const invoke: DesktopIpcInvoker = async (command: string) => { + invokedCommands.push(command); + return 4_200 as T; + }; -import { getDesktopSystemIdleTimeMs } from "./idle"; + const idleTimeMs = await readDesktopSystemIdleTimeMs(invoke); -describe("getDesktopSystemIdleTimeMs", () => { - afterEach(() => { - invokeDesktopCommandMock.mockReset(); - vi.restoreAllMocks(); - }); - - it("invokes the desktop idle command and returns the millisecond value", async () => { - invokeDesktopCommandMock.mockResolvedValueOnce(4_200); - - const idleTimeMs = await getDesktopSystemIdleTimeMs(); - - expect(invokeDesktopCommandMock).toHaveBeenCalledWith("desktop_get_system_idle_time"); expect(idleTimeMs).toBe(4_200); + expect(invokedCommands).toEqual(["desktop_get_system_idle_time"]); }); - it("returns null and logs once when IPC rejects", async () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const error = new Error("ipc failed"); - invokeDesktopCommandMock.mockRejectedValueOnce(error); - - const idleTimeMs = await getDesktopSystemIdleTimeMs(); + it("returns null when the desktop IPC rejects", async () => { + const idleTimeMs = await readDesktopSystemIdleTimeMs( + fakeInvoker(async () => { + throw new Error("ipc failed"); + }), + ); expect(idleTimeMs).toBeNull(); - expect(warn).toHaveBeenCalledTimes(1); - expect(warn).toHaveBeenCalledWith("[DesktopIdle] Failed to read system idle time", error); }); - it("returns null and logs once when IPC returns null", async () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - invokeDesktopCommandMock.mockResolvedValueOnce(null); - - const idleTimeMs = await getDesktopSystemIdleTimeMs(); + it("returns null when the desktop IPC returns null", async () => { + const idleTimeMs = await readDesktopSystemIdleTimeMs(fakeInvoker(async () => null)); expect(idleTimeMs).toBeNull(); - expect(warn).toHaveBeenCalledTimes(1); - expect(warn).toHaveBeenCalledWith("[DesktopIdle] Invalid system idle time", null); }); - it("returns null and logs once when IPC returns NaN", async () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - invokeDesktopCommandMock.mockResolvedValueOnce(Number.NaN); - - const idleTimeMs = await getDesktopSystemIdleTimeMs(); + it("returns null when the desktop IPC returns NaN", async () => { + const idleTimeMs = await readDesktopSystemIdleTimeMs(fakeInvoker(async () => Number.NaN)); expect(idleTimeMs).toBeNull(); - expect(warn).toHaveBeenCalledTimes(1); - expect(warn).toHaveBeenCalledWith("[DesktopIdle] Invalid system idle time", Number.NaN); }); - it("returns null and logs once when IPC returns a negative value", async () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - invokeDesktopCommandMock.mockResolvedValueOnce(-1); - - const idleTimeMs = await getDesktopSystemIdleTimeMs(); + it("returns null when the desktop IPC returns a negative value", async () => { + const idleTimeMs = await readDesktopSystemIdleTimeMs(fakeInvoker(async () => -1)); expect(idleTimeMs).toBeNull(); - expect(warn).toHaveBeenCalledTimes(1); - expect(warn).toHaveBeenCalledWith("[DesktopIdle] Invalid system idle time", -1); }); - it("returns 0 when IPC returns zero", async () => { - invokeDesktopCommandMock.mockResolvedValueOnce(0); - - const idleTimeMs = await getDesktopSystemIdleTimeMs(); + it("returns zero when the desktop IPC returns zero", async () => { + const idleTimeMs = await readDesktopSystemIdleTimeMs(fakeInvoker(async () => 0)); expect(idleTimeMs).toBe(0); }); diff --git a/packages/app/src/desktop/electron/idle.ts b/packages/app/src/desktop/electron/idle.ts index a3386f268..3582b033a 100644 --- a/packages/app/src/desktop/electron/idle.ts +++ b/packages/app/src/desktop/electron/idle.ts @@ -1,14 +1,16 @@ -import { invokeDesktopCommand } from "@/desktop/electron/invoke"; - const DESKTOP_SYSTEM_IDLE_COMMAND = "desktop_get_system_idle_time"; +export type DesktopIpcInvoker = (command: string) => Promise; + function isValidIdleTimeMs(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value) && value >= 0; } -export async function getDesktopSystemIdleTimeMs(): Promise { +export async function readDesktopSystemIdleTimeMs( + invoke: DesktopIpcInvoker, +): Promise { try { - const idleTimeMs = await invokeDesktopCommand(DESKTOP_SYSTEM_IDLE_COMMAND); + const idleTimeMs = await invoke(DESKTOP_SYSTEM_IDLE_COMMAND); if (!isValidIdleTimeMs(idleTimeMs)) { console.warn("[DesktopIdle] Invalid system idle time", idleTimeMs); return null; diff --git a/packages/app/src/desktop/permissions/desktop-permissions.test.ts b/packages/app/src/desktop/permissions/desktop-permissions.test.ts index 08fd2de29..ebdc2985b 100644 --- a/packages/app/src/desktop/permissions/desktop-permissions.test.ts +++ b/packages/app/src/desktop/permissions/desktop-permissions.test.ts @@ -1,104 +1,65 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import type { DesktopHostBridge } from "@/desktop/host"; +import { + createDesktopPermissions, + type DesktopPermissionEnvironment, + type NavigatorLike, + type NotificationConstructorLike, +} from "./desktop-permissions"; -type MockPlatform = "web" | "ios" | "android"; - -interface GlobalSnapshot { - Notification: unknown; - navigatorDescriptor?: PropertyDescriptor; - windowDescriptor?: PropertyDescriptor; - paseoDesktop: unknown; +interface FakeEnvironmentInput { + isWeb?: boolean; + desktopHost?: DesktopHostBridge | null; + notification?: NotificationConstructorLike | null; + navigator?: NavigatorLike | null; } -const originalGlobals: GlobalSnapshot = { - Notification: (globalThis as { Notification?: unknown }).Notification, - navigatorDescriptor: Object.getOwnPropertyDescriptor(globalThis, "navigator"), - windowDescriptor: Object.getOwnPropertyDescriptor(globalThis, "window"), - paseoDesktop: - typeof globalThis.window === "undefined" - ? undefined - : (globalThis.window as { paseoDesktop?: unknown }).paseoDesktop, -}; - -function ensureWindow(): { paseoDesktop?: unknown } { - const existingWindow = (globalThis as { window?: { paseoDesktop?: unknown } }).window; - if (existingWindow) { - return existingWindow; - } - - const nextWindow: { paseoDesktop?: unknown } = {}; - Object.defineProperty(globalThis, "window", { - configurable: true, - writable: true, - value: nextWindow, - }); - return nextWindow; -} - -function setNavigator(value: unknown): void { - Object.defineProperty(globalThis, "navigator", { - configurable: true, - writable: true, - value, - }); -} - -function restoreGlobals(): void { - (globalThis as { Notification?: unknown }).Notification = originalGlobals.Notification; - - if (originalGlobals.navigatorDescriptor) { - Object.defineProperty(globalThis, "navigator", originalGlobals.navigatorDescriptor); - } else { - delete (globalThis as { navigator?: unknown }).navigator; - } - - if (originalGlobals.windowDescriptor) { - Object.defineProperty(globalThis, "window", originalGlobals.windowDescriptor); - } else { - delete (globalThis as { window?: unknown }).window; - } - - if (typeof globalThis.window !== "undefined") { - (globalThis.window as { paseoDesktop?: unknown }).paseoDesktop = originalGlobals.paseoDesktop; - } -} - -async function loadModuleForPlatform(platform: MockPlatform) { - vi.resetModules(); - vi.doMock("react-native", () => ({ Platform: { OS: platform } })); - return import("./desktop-permissions"); +function fakeEnvironment(input: FakeEnvironmentInput = {}): DesktopPermissionEnvironment { + return { + isWeb: input.isWeb ?? true, + getDesktopHost: () => input.desktopHost ?? null, + getNotification: () => input.notification ?? null, + getNavigator: () => input.navigator ?? null, + }; } describe("desktop-permissions", () => { - afterEach(() => { - vi.doUnmock("react-native"); - vi.restoreAllMocks(); - vi.resetModules(); - restoreGlobals(); - }); + it("shows section only in desktop web runtime", () => { + expect( + createDesktopPermissions( + fakeEnvironment({ isWeb: false }), + ).shouldShowDesktopPermissionSection(), + ).toBe(false); - it("shows section only in desktop web runtime", async () => { - const { shouldShowDesktopPermissionSection } = await loadModuleForPlatform("web"); + expect( + createDesktopPermissions( + fakeEnvironment({ isWeb: true, desktopHost: null }), + ).shouldShowDesktopPermissionSection(), + ).toBe(false); - expect(shouldShowDesktopPermissionSection()).toBe(false); - - ensureWindow().paseoDesktop = {}; - expect(shouldShowDesktopPermissionSection()).toBe(true); + expect( + createDesktopPermissions( + fakeEnvironment({ isWeb: true, desktopHost: {} as DesktopHostBridge }), + ).shouldShowDesktopPermissionSection(), + ).toBe(true); }); it("reads notification and microphone status", async () => { - const MockNotification = { permission: "default" }; - (globalThis as { Notification?: unknown }).Notification = MockNotification; - setNavigator({ - permissions: { - query: vi.fn(async () => ({ state: "granted" })), - }, - mediaDevices: { - getUserMedia: vi.fn(), - }, - }); + const permissions = createDesktopPermissions( + fakeEnvironment({ + notification: { permission: "default" }, + navigator: { + permissions: { + query: vi.fn(async () => ({ state: "granted" })), + }, + mediaDevices: { + getUserMedia: vi.fn(), + }, + }, + }), + ); - const { getDesktopPermissionSnapshot } = await loadModuleForPlatform("web"); - const snapshot = await getDesktopPermissionSnapshot(); + const snapshot = await permissions.getDesktopPermissionSnapshot(); expect(snapshot.notifications.state).toBe("prompt"); expect(snapshot.microphone.state).toBe("granted"); @@ -106,42 +67,44 @@ describe("desktop-permissions", () => { }); it("queries microphone permission with correct Permissions instance binding", async () => { - const permissions = { + const permissionsApi = { query(this: unknown, _descriptor: { name: string }) { - if (this !== permissions) { + if (this !== permissionsApi) { throw new TypeError("Can only call Permissions.query on instances of Permissions"); } return Promise.resolve({ state: "granted" as const }); }, }; - setNavigator({ - permissions, - mediaDevices: { - getUserMedia: vi.fn(), - }, - }); + const permissions = createDesktopPermissions( + fakeEnvironment({ + navigator: { + permissions: permissionsApi, + mediaDevices: { getUserMedia: vi.fn() }, + }, + }), + ); - const { getDesktopPermissionSnapshot } = await loadModuleForPlatform("web"); - const snapshot = await getDesktopPermissionSnapshot(); + const snapshot = await permissions.getDesktopPermissionSnapshot(); expect(snapshot.microphone.state).toBe("granted"); }); it("returns a fallback message when runtime blocks Permissions.query", async () => { - setNavigator({ - permissions: { - query: vi.fn(async () => { - throw new TypeError("Can only call Permissions.query on instances of Permissions"); - }), - }, - mediaDevices: { - getUserMedia: vi.fn(), - }, - }); + const permissions = createDesktopPermissions( + fakeEnvironment({ + navigator: { + permissions: { + query: vi.fn(async () => { + throw new TypeError("Can only call Permissions.query on instances of Permissions"); + }), + }, + mediaDevices: { getUserMedia: vi.fn() }, + }, + }), + ); - const { getDesktopPermissionSnapshot } = await loadModuleForPlatform("web"); - const snapshot = await getDesktopPermissionSnapshot(); + const snapshot = await permissions.getDesktopPermissionSnapshot(); expect(snapshot.microphone.state).toBe("unknown"); expect(snapshot.microphone.detail).toContain( @@ -150,26 +113,29 @@ describe("desktop-permissions", () => { }); it("requests notification permission via the browser Notification API", async () => { - const MockNotification = { + const fakeNotification: NotificationConstructorLike = { permission: "default", requestPermission: vi.fn(async () => "granted"), }; - (globalThis as { Notification?: unknown }).Notification = MockNotification; - const { requestDesktopPermission } = await loadModuleForPlatform("web"); - const result = await requestDesktopPermission({ kind: "notifications" }); + const permissions = createDesktopPermissions( + fakeEnvironment({ notification: fakeNotification }), + ); + const result = await permissions.requestDesktopPermission({ kind: "notifications" }); expect(result.state).toBe("granted"); - expect(MockNotification.requestPermission).toHaveBeenCalledTimes(1); + expect(fakeNotification.requestPermission).toHaveBeenCalledTimes(1); }); it("reads browser Notification permission when available", async () => { - const MockNotification = { permission: "denied" }; - (globalThis as { Notification?: unknown }).Notification = MockNotification; - setNavigator({}); + const permissions = createDesktopPermissions( + fakeEnvironment({ + notification: { permission: "denied" }, + navigator: {}, + }), + ); - const { getDesktopPermissionSnapshot } = await loadModuleForPlatform("web"); - const snapshot = await getDesktopPermissionSnapshot(); + const snapshot = await permissions.getDesktopPermissionSnapshot(); expect(snapshot.notifications.state).toBe("denied"); }); @@ -179,17 +145,18 @@ describe("desktop-permissions", () => { const getUserMedia = vi.fn(async () => ({ getTracks: () => [{ stop }], })); - setNavigator({ - permissions: { - query: vi.fn(async () => ({ state: "granted" })), - }, - mediaDevices: { - getUserMedia, - }, - }); + const permissions = createDesktopPermissions( + fakeEnvironment({ + navigator: { + permissions: { + query: vi.fn(async () => ({ state: "granted" })), + }, + mediaDevices: { getUserMedia }, + }, + }), + ); - const { requestDesktopPermission } = await loadModuleForPlatform("web"); - const result = await requestDesktopPermission({ kind: "microphone" }); + const result = await permissions.requestDesktopPermission({ kind: "microphone" }); expect(result.state).toBe("granted"); expect(getUserMedia).toHaveBeenCalledWith({ audio: true }); @@ -197,16 +164,19 @@ describe("desktop-permissions", () => { }); it("maps microphone request denial to denied status", async () => { - setNavigator({ - mediaDevices: { - getUserMedia: vi.fn(async () => { - throw { name: "NotAllowedError", message: "denied" }; - }), - }, - }); + const permissions = createDesktopPermissions( + fakeEnvironment({ + navigator: { + mediaDevices: { + getUserMedia: vi.fn(async () => { + throw { name: "NotAllowedError", message: "denied" }; + }), + }, + }, + }), + ); - const { requestDesktopPermission } = await loadModuleForPlatform("web"); - const result = await requestDesktopPermission({ kind: "microphone" }); + const result = await permissions.requestDesktopPermission({ kind: "microphone" }); expect(result.state).toBe("denied"); }); diff --git a/packages/app/src/desktop/permissions/desktop-permissions.ts b/packages/app/src/desktop/permissions/desktop-permissions.ts index 12964e1ea..6aab5494e 100644 --- a/packages/app/src/desktop/permissions/desktop-permissions.ts +++ b/packages/app/src/desktop/permissions/desktop-permissions.ts @@ -1,5 +1,5 @@ -import { getDesktopHost } from "@/desktop/host"; -import { isWeb, isNative } from "@/constants/platform"; +import { type DesktopHostBridge, getDesktopHost } from "@/desktop/host"; +import { isNative, isWeb } from "@/constants/platform"; export type DesktopPermissionKind = "notifications" | "microphone"; @@ -22,7 +22,7 @@ export interface DesktopPermissionSnapshot { microphone: DesktopPermissionStatus; } -interface NotificationConstructorLike { +export interface NotificationConstructorLike { permission?: string; requestPermission?: () => Promise; } @@ -35,7 +35,7 @@ interface MediaStreamLike { getTracks?: () => MediaStreamTrackLike[]; } -interface NavigatorLike { +export interface NavigatorLike { mediaDevices?: { getUserMedia?: (constraints: { audio: boolean }) => Promise; }; @@ -44,8 +44,19 @@ interface NavigatorLike { }; } -export function shouldShowDesktopPermissionSection(): boolean { - return isWeb && getDesktopHost() !== null; +export interface DesktopPermissionEnvironment { + isWeb: boolean; + getDesktopHost: () => DesktopHostBridge | null; + getNotification: () => NotificationConstructorLike | null; + getNavigator: () => NavigatorLike | null; +} + +export interface DesktopPermissions { + shouldShowDesktopPermissionSection: () => boolean; + getDesktopPermissionSnapshot: () => Promise; + requestDesktopPermission: (input: { + kind: DesktopPermissionKind; + }) => Promise; } function status(input: DesktopPermissionStatus): DesktopPermissionStatus { @@ -82,31 +93,6 @@ function isPermissionsQueryRuntimeUnsupported(error: unknown): boolean { return false; } -function getWebNotificationConstructor(): NotificationConstructorLike | null { - if (isNative) { - return null; - } - const NotificationConstructor = (globalThis as { Notification?: unknown }).Notification; - if ( - NotificationConstructor == null || - (typeof NotificationConstructor !== "function" && typeof NotificationConstructor !== "object") - ) { - return null; - } - return NotificationConstructor as NotificationConstructorLike; -} - -function getNavigatorLike(): NavigatorLike | null { - if (isNative) { - return null; - } - const webNavigator = (globalThis as { navigator?: unknown }).navigator; - if (!isObject(webNavigator)) { - return null; - } - return webNavigator as NavigatorLike; -} - function mapNotificationPermissionString(permission: string): DesktopPermissionStatus { if (permission === "granted") { return status({ @@ -132,201 +118,253 @@ function mapNotificationPermissionString(permission: string): DesktopPermissionS }); } -async function getNotificationPermissionStatus(): Promise { - if (isNative) { - return status({ - state: "unavailable", - detail: "Desktop notification status is only available on web runtime.", - }); +export function createDesktopPermissions(env: DesktopPermissionEnvironment): DesktopPermissions { + function shouldShowDesktopPermissionSection(): boolean { + return env.isWeb && env.getDesktopHost() !== null; } - const desktopHost = getDesktopHost(); - if (desktopHost && typeof desktopHost.notification?.isSupported === "function") { - try { - const supported = await desktopHost.notification.isSupported(); - return status({ - state: supported ? "granted" : "unavailable", - detail: supported - ? "Desktop notifications are supported." - : "Desktop notifications are not supported on this platform.", - }); - } catch { - // Fall through to web API check - } - } - - const NotificationConstructor = getWebNotificationConstructor(); - if (NotificationConstructor && typeof NotificationConstructor.permission === "string") { - return mapNotificationPermissionString(NotificationConstructor.permission); - } - - return status({ - state: "unavailable", - detail: "Web Notification API is unavailable in this environment.", - }); -} - -async function getMicrophonePermissionStatus(): Promise { - if (isNative) { - return status({ - state: "unavailable", - detail: "Desktop microphone status is only available on web runtime.", - }); - } - - const webNavigator = getNavigatorLike(); - if (!webNavigator) { - return status({ - state: "unavailable", - detail: "Navigator is unavailable in this environment.", - }); - } - - const permissionsApi = webNavigator.permissions; - if (permissionsApi && typeof permissionsApi.query === "function") { - try { - const result = await permissionsApi.query({ name: "microphone" }); - if (result?.state === "granted") { - return status({ - state: "granted", - detail: "Microphone access is granted.", - }); - } - if (result?.state === "denied") { - return status({ - state: "denied", - detail: "Microphone access is denied in system settings.", - }); - } - if (result?.state === "prompt") { - return status({ - state: "prompt", - detail: "Microphone permission has not been granted yet.", - }); - } - return status({ - state: "unknown", - detail: `Unexpected microphone permission state: ${result?.state ?? "unknown"}`, - }); - } catch (error) { - if (isPermissionsQueryRuntimeUnsupported(error)) { - return status({ - state: "unknown", - detail: - "Microphone status API is unavailable in this runtime. Use Request to check access.", - }); - } - return status({ - state: "unknown", - detail: `Failed to query microphone status: ${getErrorMessage(error)}`, - }); - } - } - - if (typeof webNavigator.mediaDevices?.getUserMedia !== "function") { - return status({ - state: "unavailable", - detail: "Microphone capture is unavailable in this environment.", - }); - } - - return status({ - state: "unknown", - detail: "Permission status API is unavailable. Use Request to check access.", - }); -} - -async function requestNotificationPermissionStatus(): Promise { - if (isNative) { - return status({ - state: "unavailable", - detail: "Desktop notification requests are only available on web runtime.", - }); - } - - const NotificationConstructor = getWebNotificationConstructor(); - if (NotificationConstructor && typeof NotificationConstructor.requestPermission === "function") { - try { - const permission = await NotificationConstructor.requestPermission(); - return mapNotificationPermissionString(permission); - } catch (error) { - return status({ - state: "unknown", - detail: `Failed to request notification permission: ${getErrorMessage(error)}`, - }); - } - } - - return status({ - state: "unavailable", - detail: "Web Notification API requestPermission() is unavailable.", - }); -} - -async function requestMicrophonePermissionStatus(): Promise { - if (isNative) { - return status({ - state: "unavailable", - detail: "Desktop microphone requests are only available on web runtime.", - }); - } - - const webNavigator = getNavigatorLike(); - if (!webNavigator || typeof webNavigator.mediaDevices?.getUserMedia !== "function") { - return status({ - state: "unavailable", - detail: "Microphone capture API is unavailable in this environment.", - }); - } - - try { - const stream = await webNavigator.mediaDevices.getUserMedia({ audio: true }); - const tracks = stream && typeof stream.getTracks === "function" ? stream.getTracks() : []; - tracks.forEach((track) => { - if (typeof track.stop === "function") { - track.stop(); - } - }); - return await getMicrophonePermissionStatus(); - } catch (error) { - const errorName = getErrorName(error); - if (errorName === "NotAllowedError" || errorName === "PermissionDeniedError") { - return status({ - state: "denied", - detail: "Microphone permission was denied by the user or system.", - }); - } - if (errorName === "NotFoundError" || errorName === "DevicesNotFoundError") { + async function getNotificationPermissionStatus(): Promise { + if (!env.isWeb) { return status({ state: "unavailable", - detail: "No microphone device was found.", + detail: "Desktop notification status is only available on web runtime.", }); } + + const desktopHost = env.getDesktopHost(); + if (desktopHost && typeof desktopHost.notification?.isSupported === "function") { + try { + const supported = await desktopHost.notification.isSupported(); + return status({ + state: supported ? "granted" : "unavailable", + detail: supported + ? "Desktop notifications are supported." + : "Desktop notifications are not supported on this platform.", + }); + } catch { + // Fall through to web API check + } + } + + const NotificationConstructor = env.getNotification(); + if (NotificationConstructor && typeof NotificationConstructor.permission === "string") { + return mapNotificationPermissionString(NotificationConstructor.permission); + } + return status({ - state: "unknown", - detail: `Failed to request microphone permission: ${getErrorMessage(error)}`, + state: "unavailable", + detail: "Web Notification API is unavailable in this environment.", }); } -} -export async function requestDesktopPermission(input: { - kind: DesktopPermissionKind; -}): Promise { - if (input.kind === "notifications") { - return await requestNotificationPermissionStatus(); + async function getMicrophonePermissionStatus(): Promise { + if (!env.isWeb) { + return status({ + state: "unavailable", + detail: "Desktop microphone status is only available on web runtime.", + }); + } + + const webNavigator = env.getNavigator(); + if (!webNavigator) { + return status({ + state: "unavailable", + detail: "Navigator is unavailable in this environment.", + }); + } + + const permissionsApi = webNavigator.permissions; + if (permissionsApi && typeof permissionsApi.query === "function") { + try { + const result = await permissionsApi.query({ name: "microphone" }); + if (result?.state === "granted") { + return status({ + state: "granted", + detail: "Microphone access is granted.", + }); + } + if (result?.state === "denied") { + return status({ + state: "denied", + detail: "Microphone access is denied in system settings.", + }); + } + if (result?.state === "prompt") { + return status({ + state: "prompt", + detail: "Microphone permission has not been granted yet.", + }); + } + return status({ + state: "unknown", + detail: `Unexpected microphone permission state: ${result?.state ?? "unknown"}`, + }); + } catch (error) { + if (isPermissionsQueryRuntimeUnsupported(error)) { + return status({ + state: "unknown", + detail: + "Microphone status API is unavailable in this runtime. Use Request to check access.", + }); + } + return status({ + state: "unknown", + detail: `Failed to query microphone status: ${getErrorMessage(error)}`, + }); + } + } + + if (typeof webNavigator.mediaDevices?.getUserMedia !== "function") { + return status({ + state: "unavailable", + detail: "Microphone capture is unavailable in this environment.", + }); + } + + return status({ + state: "unknown", + detail: "Permission status API is unavailable. Use Request to check access.", + }); } - return await requestMicrophonePermissionStatus(); -} -export async function getDesktopPermissionSnapshot(): Promise { - const [notifications, microphone] = await Promise.all([ - getNotificationPermissionStatus(), - getMicrophonePermissionStatus(), - ]); + async function requestNotificationPermissionStatus(): Promise { + if (!env.isWeb) { + return status({ + state: "unavailable", + detail: "Desktop notification requests are only available on web runtime.", + }); + } + + const NotificationConstructor = env.getNotification(); + if ( + NotificationConstructor && + typeof NotificationConstructor.requestPermission === "function" + ) { + try { + const permission = await NotificationConstructor.requestPermission(); + return mapNotificationPermissionString(permission); + } catch (error) { + return status({ + state: "unknown", + detail: `Failed to request notification permission: ${getErrorMessage(error)}`, + }); + } + } + + return status({ + state: "unavailable", + detail: "Web Notification API requestPermission() is unavailable.", + }); + } + + async function requestMicrophonePermissionStatus(): Promise { + if (!env.isWeb) { + return status({ + state: "unavailable", + detail: "Desktop microphone requests are only available on web runtime.", + }); + } + + const webNavigator = env.getNavigator(); + if (!webNavigator || typeof webNavigator.mediaDevices?.getUserMedia !== "function") { + return status({ + state: "unavailable", + detail: "Microphone capture API is unavailable in this environment.", + }); + } + + try { + const stream = await webNavigator.mediaDevices.getUserMedia({ audio: true }); + const tracks = stream && typeof stream.getTracks === "function" ? stream.getTracks() : []; + tracks.forEach((track) => { + if (typeof track.stop === "function") { + track.stop(); + } + }); + return await getMicrophonePermissionStatus(); + } catch (error) { + const errorName = getErrorName(error); + if (errorName === "NotAllowedError" || errorName === "PermissionDeniedError") { + return status({ + state: "denied", + detail: "Microphone permission was denied by the user or system.", + }); + } + if (errorName === "NotFoundError" || errorName === "DevicesNotFoundError") { + return status({ + state: "unavailable", + detail: "No microphone device was found.", + }); + } + return status({ + state: "unknown", + detail: `Failed to request microphone permission: ${getErrorMessage(error)}`, + }); + } + } + + async function requestDesktopPermission(input: { + kind: DesktopPermissionKind; + }): Promise { + if (input.kind === "notifications") { + return await requestNotificationPermissionStatus(); + } + return await requestMicrophonePermissionStatus(); + } + + async function getDesktopPermissionSnapshot(): Promise { + const [notifications, microphone] = await Promise.all([ + getNotificationPermissionStatus(), + getMicrophonePermissionStatus(), + ]); + + return { + checkedAt: Date.now(), + notifications, + microphone, + }; + } return { - checkedAt: Date.now(), - notifications, - microphone, + shouldShowDesktopPermissionSection, + getDesktopPermissionSnapshot, + requestDesktopPermission, }; } + +function getRealNotification(): NotificationConstructorLike | null { + if (isNative) { + return null; + } + const NotificationConstructor = (globalThis as { Notification?: unknown }).Notification; + if ( + NotificationConstructor == null || + (typeof NotificationConstructor !== "function" && typeof NotificationConstructor !== "object") + ) { + return null; + } + return NotificationConstructor as NotificationConstructorLike; +} + +function getRealNavigator(): NavigatorLike | null { + if (isNative) { + return null; + } + const webNavigator = (globalThis as { navigator?: unknown }).navigator; + if (!isObject(webNavigator)) { + return null; + } + return webNavigator as NavigatorLike; +} + +const realDesktopPermissions = createDesktopPermissions({ + isWeb, + getDesktopHost, + getNotification: getRealNotification, + getNavigator: getRealNavigator, +}); + +export const shouldShowDesktopPermissionSection = + realDesktopPermissions.shouldShowDesktopPermissionSection; +export const getDesktopPermissionSnapshot = realDesktopPermissions.getDesktopPermissionSnapshot; +export const requestDesktopPermission = realDesktopPermissions.requestDesktopPermission; diff --git a/packages/app/src/desktop/updates/desktop-app-updater.test.ts b/packages/app/src/desktop/updates/desktop-app-updater.test.ts new file mode 100644 index 000000000..3c1c120fd --- /dev/null +++ b/packages/app/src/desktop/updates/desktop-app-updater.test.ts @@ -0,0 +1,262 @@ +import { describe, expect, it } from "vitest"; +import { + createDesktopAppUpdater, + formatStatusText, + type DesktopAppUpdater, + type DesktopAppUpdaterErrorReport, +} from "./desktop-app-updater"; +import { + buildFakeCheckResult, + buildFakeInstallResult, + createFakeDesktopAppUpdaterPort, + type FakeDesktopAppUpdaterPort, +} from "./test-utils/fake-desktop-app-updater-port"; + +function createUpdater( + overrides: { + port?: FakeDesktopAppUpdaterPort; + now?: () => number; + reportInstallError?: (report: DesktopAppUpdaterErrorReport) => void; + } = {}, +): { + updater: DesktopAppUpdater; + port: FakeDesktopAppUpdaterPort; + reportedInstallErrors: DesktopAppUpdaterErrorReport[]; +} { + const port = overrides.port ?? createFakeDesktopAppUpdaterPort(); + const reportedInstallErrors: DesktopAppUpdaterErrorReport[] = []; + const updater = createDesktopAppUpdater({ + port, + now: overrides.now ?? (() => 1_700_000_000_000), + reportInstallError: + overrides.reportInstallError ?? + ((report) => { + reportedInstallErrors.push(report); + }), + }); + return { updater, port, reportedInstallErrors }; +} + +describe("desktop app updater — check", () => { + it("forwards the requested release channel to the port", async () => { + const { updater, port } = createUpdater(); + port.nextCheckResult(buildFakeCheckResult()); + + await updater.checkForUpdates({ releaseChannel: "beta" }); + + expect(port.recordedChecks).toEqual([{ releaseChannel: "beta" }]); + }); + + it("moves to 'checking' during a non-silent check", async () => { + const { updater, port } = createUpdater(); + const deferred = port.deferNextCheck(); + + const pending = updater.checkForUpdates({ releaseChannel: "stable" }); + expect(updater.getSnapshot().status).toBe("checking"); + expect(updater.getSnapshot().isChecking).toBe(true); + + deferred.resolve(buildFakeCheckResult()); + await pending; + }); + + it("stays on the current status during a silent check", async () => { + const { updater, port } = createUpdater(); + port.nextCheckResult(buildFakeCheckResult({ hasUpdate: true, readyToInstall: true })); + await updater.checkForUpdates({ releaseChannel: "stable" }); + expect(updater.getSnapshot().status).toBe("available"); + + const deferred = port.deferNextCheck(); + const pending = updater.checkForUpdates({ releaseChannel: "stable", silent: true }); + expect(updater.getSnapshot().status).toBe("available"); + + deferred.resolve(buildFakeCheckResult({ hasUpdate: true, readyToInstall: true })); + await pending; + }); + + it("reports 'available' when the check resolves with a downloaded update", async () => { + const { updater, port } = createUpdater({ now: () => 42 }); + port.nextCheckResult( + buildFakeCheckResult({ hasUpdate: true, readyToInstall: true, latestVersion: "1.2.3" }), + ); + + await updater.checkForUpdates({ releaseChannel: "stable" }); + + expect(updater.getSnapshot()).toMatchObject({ + status: "available", + availableUpdate: { latestVersion: "1.2.3" }, + lastCheckedAt: 42, + }); + }); + + it("reports 'pending' when the check resolves with an update that is not yet downloaded", async () => { + const { updater, port } = createUpdater(); + port.nextCheckResult(buildFakeCheckResult({ hasUpdate: true, readyToInstall: false })); + + await updater.checkForUpdates({ releaseChannel: "stable" }); + + expect(updater.getSnapshot()).toMatchObject({ + status: "pending", + availableUpdate: null, + }); + }); + + it("reports 'up-to-date' when the check resolves with no update", async () => { + const { updater, port } = createUpdater(); + port.nextCheckResult(buildFakeCheckResult({ hasUpdate: false, readyToInstall: false })); + + await updater.checkForUpdates({ releaseChannel: "stable" }); + + expect(updater.getSnapshot().status).toBe("up-to-date"); + }); + + it("reports 'error' when a non-silent check throws", async () => { + const { updater, port } = createUpdater(); + port.failNextCheck(new Error("network down")); + + await updater.checkForUpdates({ releaseChannel: "stable" }); + + expect(updater.getSnapshot()).toMatchObject({ + status: "error", + errorMessage: "network down", + }); + }); + + it("does not move to 'error' when a silent check throws", async () => { + const { updater, port } = createUpdater(); + port.nextCheckResult(buildFakeCheckResult({ hasUpdate: true, readyToInstall: true })); + await updater.checkForUpdates({ releaseChannel: "stable" }); + const statusBeforeSilent = updater.getSnapshot().status; + + port.failNextCheck(new Error("boom")); + await updater.checkForUpdates({ releaseChannel: "stable", silent: true }); + + expect(updater.getSnapshot().status).toBe(statusBeforeSilent); + }); + + it("ignores the older result when a newer check supersedes it mid-flight", async () => { + const { updater, port } = createUpdater(); + const firstDeferred = port.deferNextCheck(); + port.nextCheckResult(buildFakeCheckResult({ hasUpdate: true, readyToInstall: true })); + + const firstPending = updater.checkForUpdates({ releaseChannel: "stable" }); + const secondPending = updater.checkForUpdates({ releaseChannel: "stable" }); + await secondPending; + expect(updater.getSnapshot().status).toBe("available"); + + firstDeferred.resolve(buildFakeCheckResult({ hasUpdate: false, readyToInstall: false })); + await firstPending; + + expect(updater.getSnapshot().status).toBe("available"); + }); +}); + +describe("desktop app updater — install", () => { + it("forwards the requested release channel to the port", async () => { + const { updater, port } = createUpdater(); + port.nextInstallResult(buildFakeInstallResult({ installed: true })); + + await updater.installUpdate({ releaseChannel: "beta" }); + + expect(port.recordedInstalls).toEqual([{ releaseChannel: "beta" }]); + }); + + it("moves to 'installed' when the install reports installation succeeded", async () => { + const { updater, port } = createUpdater(); + port.nextInstallResult( + buildFakeInstallResult({ installed: true, message: "Restart to finish" }), + ); + + await updater.installUpdate({ releaseChannel: "stable" }); + + expect(updater.getSnapshot()).toMatchObject({ + status: "installed", + installMessage: "Restart to finish", + isInstalling: false, + }); + }); + + it("moves to 'up-to-date' when the install reports nothing to install", async () => { + const { updater, port } = createUpdater(); + port.nextInstallResult(buildFakeInstallResult({ installed: false })); + + await updater.installUpdate({ releaseChannel: "stable" }); + + expect(updater.getSnapshot().status).toBe("up-to-date"); + }); + + it("reports the install error and moves to 'error' when the install throws", async () => { + const { updater, port, reportedInstallErrors } = createUpdater(); + const error = new Error("install failed"); + port.failNextInstall(error); + + await updater.installUpdate({ releaseChannel: "stable" }); + + expect(updater.getSnapshot()).toMatchObject({ + status: "error", + errorMessage: "install failed", + isInstalling: false, + }); + expect(reportedInstallErrors).toEqual([ + { + error, + message: "Unable to install the desktop app update.", + logLabel: "[DesktopUpdater] Failed to install app update", + }, + ]); + }); +}); + +describe("desktop app updater — subscribe", () => { + it("notifies subscribers when the status changes", async () => { + const { updater, port } = createUpdater(); + port.nextCheckResult(buildFakeCheckResult({ hasUpdate: true, readyToInstall: true })); + + const notifications: string[] = []; + const unsubscribe = updater.subscribe(() => { + notifications.push(updater.getSnapshot().status); + }); + + await updater.checkForUpdates({ releaseChannel: "stable" }); + unsubscribe(); + + expect(notifications).toEqual(["checking", "available"]); + }); +}); + +describe("formatStatusText", () => { + const formatVersion = (version: string | null | undefined) => + version ? `v${version.replace(/^v/i, "")}` : "\u2014"; + + it("uses the latest version in the 'available' message when present", () => { + expect( + formatStatusText({ + status: "available", + availableUpdate: buildFakeCheckResult({ latestVersion: "1.2.3" }), + installMessage: null, + formatVersion, + }), + ).toBe("Update ready: v1.2.3"); + }); + + it("falls back to a generic 'available' message when no version is reported", () => { + expect( + formatStatusText({ + status: "available", + availableUpdate: null, + installMessage: null, + formatVersion, + }), + ).toBe("An app update is ready to install."); + }); + + it("uses the install message in the 'installed' state when present", () => { + expect( + formatStatusText({ + status: "installed", + availableUpdate: null, + installMessage: "Restart now", + formatVersion, + }), + ).toBe("Restart now"); + }); +}); diff --git a/packages/app/src/desktop/updates/desktop-app-updater.ts b/packages/app/src/desktop/updates/desktop-app-updater.ts new file mode 100644 index 000000000..1bcb7b5f6 --- /dev/null +++ b/packages/app/src/desktop/updates/desktop-app-updater.ts @@ -0,0 +1,275 @@ +import type { + DesktopAppUpdateCheckResult, + DesktopAppUpdateInstallResult, + DesktopReleaseChannel, +} from "@/desktop/updates/desktop-updates"; + +export type DesktopAppUpdateStatus = + | "idle" + | "checking" + | "pending" + | "up-to-date" + | "available" + | "installing" + | "installed" + | "error"; + +export const PENDING_RECHECK_MS = 10_000; + +export interface DesktopAppUpdaterSnapshot { + status: DesktopAppUpdateStatus; + availableUpdate: DesktopAppUpdateCheckResult | null; + errorMessage: string | null; + installMessage: string | null; + lastCheckedAt: number | null; + isChecking: boolean; + isInstalling: boolean; +} + +export interface DesktopAppUpdaterPort { + checkDesktopAppUpdate(input: { + releaseChannel: DesktopReleaseChannel; + }): Promise; + installDesktopAppUpdate(input: { + releaseChannel: DesktopReleaseChannel; + }): Promise; +} + +export interface DesktopAppUpdaterErrorReport { + error: unknown; + message: string; + logLabel: string; +} + +export interface DesktopAppUpdaterDeps { + port: DesktopAppUpdaterPort; + now(): number; + reportInstallError?(report: DesktopAppUpdaterErrorReport): void; +} + +export interface DesktopAppUpdater { + getSnapshot(): DesktopAppUpdaterSnapshot; + subscribe(listener: () => void): () => void; + checkForUpdates(options?: { + releaseChannel: DesktopReleaseChannel; + silent?: boolean; + }): Promise; + installUpdate(options: { + releaseChannel: DesktopReleaseChannel; + }): Promise; +} + +interface InternalState { + status: DesktopAppUpdateStatus; + availableUpdate: DesktopAppUpdateCheckResult | null; + errorMessage: string | null; + installMessage: string | null; + lastCheckedAt: number | null; + isInstalling: boolean; + requestVersion: number; +} + +const INITIAL_STATE: InternalState = { + status: "idle", + availableUpdate: null, + errorMessage: null, + installMessage: null, + lastCheckedAt: null, + isInstalling: false, + requestVersion: 0, +}; + +function buildSnapshot(state: InternalState): DesktopAppUpdaterSnapshot { + return { + status: state.status, + availableUpdate: state.availableUpdate, + errorMessage: state.errorMessage, + installMessage: state.installMessage, + lastCheckedAt: state.lastCheckedAt, + isChecking: state.status === "checking", + isInstalling: state.status === "installing" || state.isInstalling, + }; +} + +function getErrorMessage(error: unknown): string { + if (error instanceof Error && typeof error.message === "string") { + return error.message; + } + return String(error); +} + +export function formatStatusText(input: { + status: DesktopAppUpdateStatus; + availableUpdate: DesktopAppUpdateCheckResult | null; + installMessage: string | null; + formatVersion: (version: string | null | undefined) => string; +}): string { + const { status, availableUpdate, installMessage, formatVersion } = input; + + if (status === "checking") { + return "Checking for app updates..."; + } + + if (status === "installing") { + return "Installing app update..."; + } + + if (status === "up-to-date") { + return "App is up to date."; + } + + if (status === "pending") { + return "We'll let you know when the update is ready."; + } + + if (status === "available") { + if (availableUpdate?.latestVersion) { + return `Update ready: ${formatVersion(availableUpdate.latestVersion)}`; + } + return "An app update is ready to install."; + } + + if (status === "installed") { + return installMessage ?? "App update installed. Restart required."; + } + + if (status === "error") { + return "Failed to update app."; + } + + return "Update status has not been checked yet."; +} + +export function createDesktopAppUpdater(deps: DesktopAppUpdaterDeps): DesktopAppUpdater { + let state: InternalState = { ...INITIAL_STATE }; + let cachedSnapshot: DesktopAppUpdaterSnapshot = buildSnapshot(state); + const listeners = new Set<() => void>(); + + function commit(next: InternalState): void { + state = next; + cachedSnapshot = buildSnapshot(state); + for (const listener of listeners) { + listener(); + } + } + + async function checkForUpdates(options?: { + releaseChannel: DesktopReleaseChannel; + silent?: boolean; + }): Promise { + if (!options) { + return null; + } + const { releaseChannel, silent = false } = options; + const requestVersion = state.requestVersion + 1; + + commit({ + ...state, + requestVersion, + status: silent ? state.status : "checking", + errorMessage: null, + }); + + try { + const result = await deps.port.checkDesktopAppUpdate({ releaseChannel }); + if (requestVersion !== state.requestVersion) { + return result; + } + + const nextLastCheckedAt = deps.now(); + let nextStatus: DesktopAppUpdateStatus; + let nextAvailable: DesktopAppUpdateCheckResult | null; + + if (result.readyToInstall) { + nextStatus = "available"; + nextAvailable = result; + } else if (result.hasUpdate) { + nextStatus = "pending"; + nextAvailable = null; + } else { + nextStatus = "up-to-date"; + nextAvailable = null; + } + + commit({ + ...state, + status: nextStatus, + availableUpdate: nextAvailable, + installMessage: null, + lastCheckedAt: nextLastCheckedAt, + }); + + return result; + } catch (error) { + if (requestVersion !== state.requestVersion) { + return null; + } + + const message = getErrorMessage(error); + if (silent) { + console.warn("[DesktopUpdater] Silent update check failed", message); + commit({ ...state }); + } else { + commit({ + ...state, + status: "error", + errorMessage: message, + }); + } + return null; + } + } + + async function installUpdate(options: { + releaseChannel: DesktopReleaseChannel; + }): Promise { + commit({ + ...state, + status: "installing", + errorMessage: null, + isInstalling: true, + }); + + try { + const result = await deps.port.installDesktopAppUpdate({ + releaseChannel: options.releaseChannel, + }); + const nextLastCheckedAt = deps.now(); + commit({ + ...state, + status: result.installed ? "installed" : "up-to-date", + availableUpdate: null, + installMessage: result.message, + lastCheckedAt: nextLastCheckedAt, + isInstalling: false, + }); + return result; + } catch (error) { + const message = getErrorMessage(error); + deps.reportInstallError?.({ + error, + message: "Unable to install the desktop app update.", + logLabel: "[DesktopUpdater] Failed to install app update", + }); + commit({ + ...state, + status: "error", + errorMessage: message, + isInstalling: false, + }); + return null; + } + } + + return { + getSnapshot: () => cachedSnapshot, + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + checkForUpdates, + installUpdate, + }; +} diff --git a/packages/app/src/desktop/updates/resolve-update-callout.test.ts b/packages/app/src/desktop/updates/resolve-update-callout.test.ts new file mode 100644 index 000000000..4dfdc6cbb --- /dev/null +++ b/packages/app/src/desktop/updates/resolve-update-callout.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; +import { + resolveUpdateCalloutDescriptor, + type ResolveUpdateCalloutInput, +} from "./resolve-update-callout"; + +function input(overrides: Partial = {}): ResolveUpdateCalloutInput { + return { + isDesktopApp: true, + status: "available", + isInstalling: false, + availableUpdate: { latestVersion: "1.2.3" }, + errorMessage: null, + ...overrides, + }; +} + +describe("resolveUpdateCalloutDescriptor", () => { + it("returns null when not running as a desktop app", () => { + expect(resolveUpdateCalloutDescriptor(input({ isDesktopApp: false }))).toBeNull(); + }); + + it("returns null for idle / checking / up-to-date / pending statuses", () => { + for (const status of ["idle", "checking", "up-to-date", "pending"] as const) { + expect(resolveUpdateCalloutDescriptor(input({ status }))).toBeNull(); + } + }); + + it("builds an update-available descriptor with changelog + install actions", () => { + const descriptor = resolveUpdateCalloutDescriptor(input()); + + expect(descriptor).not.toBeNull(); + expect(descriptor?.id).toBe("desktop-update"); + expect(descriptor?.priority).toBe(200); + expect(descriptor?.testID).toBe("update-callout"); + expect(descriptor?.title).toBe("Update available"); + expect(descriptor?.variant).toBe("default"); + expect(descriptor?.showGiftIcon).toBe(true); + expect(descriptor?.body).toEqual({ kind: "available", versionLabel: "v1.2.3" }); + expect(descriptor?.actions).toEqual([ + { role: "changelog", label: "What's new" }, + { role: "install", label: "Install & restart", variant: "primary", disabled: false }, + ]); + expect(descriptor?.dismissalKey).toBe("desktop-update:available:1.2.3"); + }); + + it("normalizes a leading v in the latest version", () => { + const descriptor = resolveUpdateCalloutDescriptor( + input({ availableUpdate: { latestVersion: "v2.0.0" } }), + ); + expect(descriptor?.body).toEqual({ kind: "available", versionLabel: "v2.0.0" }); + }); + + it("omits the version label when no latest version is known", () => { + const descriptor = resolveUpdateCalloutDescriptor( + input({ availableUpdate: { latestVersion: null } }), + ); + expect(descriptor?.body).toEqual({ kind: "available", versionLabel: null }); + expect(descriptor?.dismissalKey).toBe("desktop-update:available:unknown"); + }); + + it("disables the install action and labels it Installing... while installing", () => { + const descriptor = resolveUpdateCalloutDescriptor( + input({ status: "installing", isInstalling: true }), + ); + + expect(descriptor?.title).toBe("Installing update"); + expect(descriptor?.body).toEqual({ kind: "installing" }); + expect(descriptor?.showGiftIcon).toBe(false); + expect(descriptor?.variant).toBe("default"); + expect(descriptor?.actions).toEqual([ + { role: "changelog", label: "What's new" }, + { role: "install", label: "Installing...", variant: "primary", disabled: true }, + ]); + expect(descriptor?.dismissalKey).toBe("desktop-update:installing:1.2.3"); + }); + + it("shows a retry action and surfaces the error message on error", () => { + const descriptor = resolveUpdateCalloutDescriptor( + input({ status: "error", errorMessage: "Download failed", availableUpdate: null }), + ); + + expect(descriptor?.title).toBe("Update failed"); + expect(descriptor?.body).toEqual({ kind: "error", message: "Download failed" }); + expect(descriptor?.variant).toBe("error"); + expect(descriptor?.showGiftIcon).toBe(false); + expect(descriptor?.actions).toEqual([ + { role: "changelog", label: "What's new" }, + { role: "retry", label: "Retry", variant: "primary" }, + ]); + expect(descriptor?.dismissalKey).toBe("desktop-update:error:unknown"); + }); + + it("falls back to a generic error message when none is provided", () => { + const descriptor = resolveUpdateCalloutDescriptor( + input({ status: "error", errorMessage: null, availableUpdate: null }), + ); + expect(descriptor?.body).toEqual({ kind: "error", message: "Something went wrong." }); + }); + + it("encodes status and version into the dismissal key", () => { + expect( + resolveUpdateCalloutDescriptor(input({ availableUpdate: { latestVersion: "1.2.4" } })) + ?.dismissalKey, + ).toBe("desktop-update:available:1.2.4"); + }); +}); diff --git a/packages/app/src/desktop/updates/resolve-update-callout.ts b/packages/app/src/desktop/updates/resolve-update-callout.ts new file mode 100644 index 000000000..b87e83efe --- /dev/null +++ b/packages/app/src/desktop/updates/resolve-update-callout.ts @@ -0,0 +1,93 @@ +import type { DesktopAppUpdateStatus } from "@/desktop/updates/use-desktop-app-updater"; + +export type UpdateCalloutBody = + | { kind: "available"; versionLabel: string | null } + | { kind: "installing" } + | { kind: "error"; message: string }; + +export type UpdateCalloutActionRole = "changelog" | "install" | "retry"; + +export interface UpdateCalloutActionDescriptor { + role: UpdateCalloutActionRole; + label: string; + variant?: "primary" | "secondary"; + disabled?: boolean; +} + +export interface UpdateCalloutDescriptor { + id: "desktop-update"; + dismissalKey: string; + priority: number; + title: string; + body: UpdateCalloutBody; + showGiftIcon: boolean; + variant: "default" | "error"; + actions: UpdateCalloutActionDescriptor[]; + testID: "update-callout"; +} + +export interface ResolveUpdateCalloutInput { + isDesktopApp: boolean; + status: DesktopAppUpdateStatus; + isInstalling: boolean; + availableUpdate: { latestVersion?: string | null } | null; + errorMessage: string | null; +} + +function formatVersionLabel(latestVersion: string | null | undefined): string | null { + if (!latestVersion) return null; + return `v${latestVersion.replace(/^v/i, "")}`; +} + +export function resolveUpdateCalloutDescriptor( + input: ResolveUpdateCalloutInput, +): UpdateCalloutDescriptor | null { + if (!input.isDesktopApp) return null; + if (input.status !== "available" && input.status !== "installing" && input.status !== "error") { + return null; + } + + const isError = input.status === "error"; + const isInstalling = input.isInstalling; + const isAvailable = !isInstalling && !isError; + + const latestVersion = input.availableUpdate?.latestVersion ?? null; + const dismissalKey = `desktop-update:${input.status}:${latestVersion ?? "unknown"}`; + + let title: string; + let body: UpdateCalloutBody; + if (isInstalling) { + title = "Installing update"; + body = { kind: "installing" }; + } else if (isError) { + title = "Update failed"; + body = { kind: "error", message: input.errorMessage ?? "Something went wrong." }; + } else { + title = "Update available"; + body = { kind: "available", versionLabel: formatVersionLabel(latestVersion) }; + } + + const actions: UpdateCalloutActionDescriptor[] = [{ role: "changelog", label: "What's new" }]; + if (isError) { + actions.push({ role: "retry", label: "Retry", variant: "primary" }); + } else { + actions.push({ + role: "install", + label: isInstalling ? "Installing..." : "Install & restart", + variant: "primary", + disabled: isInstalling, + }); + } + + return { + id: "desktop-update", + dismissalKey, + priority: 200, + title, + body, + showGiftIcon: isAvailable, + variant: isError ? "error" : "default", + actions, + testID: "update-callout", + }; +} diff --git a/packages/app/src/desktop/updates/test-utils/fake-desktop-app-updater-port.ts b/packages/app/src/desktop/updates/test-utils/fake-desktop-app-updater-port.ts new file mode 100644 index 000000000..7fa08b08b --- /dev/null +++ b/packages/app/src/desktop/updates/test-utils/fake-desktop-app-updater-port.ts @@ -0,0 +1,124 @@ +import type { + DesktopAppUpdateCheckResult, + DesktopAppUpdateInstallResult, + DesktopReleaseChannel, +} from "@/desktop/updates/desktop-updates"; +import type { DesktopAppUpdaterPort } from "@/desktop/updates/desktop-app-updater"; + +export interface FakeDesktopAppUpdaterPort extends DesktopAppUpdaterPort { + readonly recordedChecks: Array<{ releaseChannel: DesktopReleaseChannel }>; + readonly recordedInstalls: Array<{ releaseChannel: DesktopReleaseChannel }>; + nextCheckResult(result: DesktopAppUpdateCheckResult): void; + deferNextCheck(): { + resolve(result: DesktopAppUpdateCheckResult): void; + reject(error: unknown): void; + }; + failNextCheck(error: unknown): void; + nextInstallResult(result: DesktopAppUpdateInstallResult): void; + failNextInstall(error: unknown): void; +} + +type CheckOutcome = + | { kind: "result"; result: DesktopAppUpdateCheckResult } + | { kind: "error"; error: unknown } + | { kind: "deferred"; promise: Promise }; + +type InstallOutcome = + | { kind: "result"; result: DesktopAppUpdateInstallResult } + | { kind: "error"; error: unknown }; + +function buildCheckResult( + overrides: Partial = {}, +): DesktopAppUpdateCheckResult { + return { + hasUpdate: false, + readyToInstall: false, + currentVersion: null, + latestVersion: null, + body: null, + date: null, + ...overrides, + }; +} + +function buildInstallResult( + overrides: Partial = {}, +): DesktopAppUpdateInstallResult { + return { + installed: false, + version: null, + message: "Update completed.", + ...overrides, + }; +} + +export function createFakeDesktopAppUpdaterPort(): FakeDesktopAppUpdaterPort { + const recordedChecks: Array<{ releaseChannel: DesktopReleaseChannel }> = []; + const recordedInstalls: Array<{ releaseChannel: DesktopReleaseChannel }> = []; + const checkOutcomes: CheckOutcome[] = []; + const installOutcomes: InstallOutcome[] = []; + + return { + recordedChecks, + recordedInstalls, + nextCheckResult(result) { + checkOutcomes.push({ kind: "result", result }); + }, + deferNextCheck() { + let resolve!: (value: DesktopAppUpdateCheckResult) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + checkOutcomes.push({ kind: "deferred", promise }); + return { resolve, reject }; + }, + failNextCheck(error) { + checkOutcomes.push({ kind: "error", error }); + }, + nextInstallResult(result) { + installOutcomes.push({ kind: "result", result }); + }, + failNextInstall(error) { + installOutcomes.push({ kind: "error", error }); + }, + async checkDesktopAppUpdate(input) { + recordedChecks.push(input); + const outcome = checkOutcomes.shift(); + if (!outcome) { + return buildCheckResult(); + } + if (outcome.kind === "result") { + return outcome.result; + } + if (outcome.kind === "error") { + throw outcome.error; + } + return outcome.promise; + }, + async installDesktopAppUpdate(input) { + recordedInstalls.push(input); + const outcome = installOutcomes.shift(); + if (!outcome) { + return buildInstallResult(); + } + if (outcome.kind === "result") { + return outcome.result; + } + throw outcome.error; + }, + }; +} + +export function buildFakeCheckResult( + overrides: Partial = {}, +): DesktopAppUpdateCheckResult { + return buildCheckResult(overrides); +} + +export function buildFakeInstallResult( + overrides: Partial = {}, +): DesktopAppUpdateInstallResult { + return buildInstallResult(overrides); +} diff --git a/packages/app/src/desktop/updates/update-callout-source.test.tsx b/packages/app/src/desktop/updates/update-callout-source.test.tsx deleted file mode 100644 index 207b32302..000000000 --- a/packages/app/src/desktop/updates/update-callout-source.test.tsx +++ /dev/null @@ -1,259 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import React, { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { SidebarCalloutSlot } from "@/components/sidebar-callout-slot"; -import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context"; - -const { theme } = vi.hoisted(() => ({ - theme: { - spacing: { 0: 0, 1: 4, 2: 8, 3: 12, 4: 16 }, - iconSize: { sm: 14, md: 18 }, - borderWidth: { 1: 1 }, - borderRadius: { md: 6 }, - fontSize: { xs: 11, sm: 13 }, - fontWeight: { medium: "500", semibold: "600" }, - shadow: { md: {} }, - colors: { - surface0: "#000", - foreground: "#fff", - foregroundMuted: "#aaa", - border: "#555", - destructive: "#ff4444", - }, - }, -})); - -const asyncStorage = vi.hoisted(() => ({ - values: new Map(), - getItem: vi.fn(async (key: string) => asyncStorage.values.get(key) ?? null), - setItem: vi.fn(async (key: string, value: string) => { - asyncStorage.values.set(key, value); - }), -})); - -vi.mock("@react-native-async-storage/async-storage", () => ({ - default: asyncStorage, -})); - -vi.mock("react-native-unistyles", () => ({ - StyleSheet: { - create: (factory: unknown) => - typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory, - }, - useUnistyles: () => ({ theme }), -})); - -vi.mock("lucide-react-native", () => { - const X = (props: Record) => React.createElement("span", props); - const Gift = (props: Record) => - React.createElement("span", { ...props, "data-icon": "Gift" }); - return { Gift, X }; -}); - -vi.mock("@/utils/open-external-url", () => ({ - openExternalUrl: vi.fn(async () => {}), -})); - -const updaterState = vi.hoisted(() => ({ - value: { - isDesktopApp: true, - status: "available" as - | "idle" - | "checking" - | "pending" - | "up-to-date" - | "available" - | "installing" - | "installed" - | "error", - availableUpdate: { - hasUpdate: true, - readyToInstall: true, - currentVersion: "1.2.2", - latestVersion: "1.2.3", - body: null, - date: null, - } as unknown, - errorMessage: null as string | null, - lastCheckedAt: null as number | null, - isChecking: false, - isInstalling: false, - statusText: "", - checkForUpdates: vi.fn(async () => null), - installUpdate: vi.fn(async () => null), - }, -})); - -vi.mock("@/desktop/updates/use-desktop-app-updater", () => ({ - useDesktopAppUpdater: () => updaterState.value, -})); - -vi.stubGlobal("React", React); -vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - -import { UpdateCalloutSource } from "./update-callout-source"; - -function resetUpdaterState(): void { - updaterState.value = { - isDesktopApp: true, - status: "available", - availableUpdate: { - hasUpdate: true, - readyToInstall: true, - currentVersion: "1.2.2", - latestVersion: "1.2.3", - body: null, - date: null, - }, - errorMessage: null, - lastCheckedAt: null, - isChecking: false, - isInstalling: false, - statusText: "", - checkForUpdates: vi.fn(async () => null), - installUpdate: vi.fn(async () => null), - }; -} - -function UpdateCalloutHarness({ withSlot = true }: { withSlot?: boolean }) { - return ( - - - {withSlot ? : null} - - ); -} - -async function renderHarness(root: Root, withSlot = true): Promise { - await act(async () => { - root.render(); - await Promise.resolve(); - await Promise.resolve(); - }); -} - -describe("UpdateCalloutSource", () => { - let root: Root | null = null; - let container: HTMLElement | null = null; - - beforeEach(() => { - resetUpdaterState(); - asyncStorage.values.clear(); - asyncStorage.getItem.mockClear(); - asyncStorage.setItem.mockClear(); - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - }); - - afterEach(() => { - if (root) { - act(() => { - root?.unmount(); - }); - } - root = null; - container?.remove(); - container = null; - }); - - it("registers an update-available sidebar callout with changelog + install actions", async () => { - await renderHarness(root!); - - expect(container?.querySelector('[data-testid="update-callout"]')).not.toBeNull(); - expect(container?.textContent).toContain("Update available"); - expect(container?.textContent).toContain("1.2.3"); - expect(container?.textContent).toContain( - "Upgrading the app will stop running agents and close terminal sessions.", - ); - expect(container?.querySelector('[data-icon="Gift"]')).not.toBeNull(); - expect( - container?.querySelector('[data-testid="update-callout-action-0"]')?.textContent, - ).toContain("What's new"); - expect( - container?.querySelector('[data-testid="update-callout-action-1"]')?.textContent, - ).toContain("Install & restart"); - }); - - it("renders only through the sidebar slot", async () => { - await renderHarness(root!, false); - - expect(container?.querySelector('[data-testid="update-callout"]')).toBeNull(); - }); - - it("disables the install action and shows Installing... while installing", async () => { - updaterState.value = { - ...updaterState.value, - status: "installing", - isInstalling: true, - }; - await renderHarness(root!); - - expect( - container?.querySelector('[data-testid="update-callout-action-1"]')?.textContent, - ).toContain("Installing"); - }); - - it("shows a retry action on error and surfaces the error message", async () => { - updaterState.value = { - ...updaterState.value, - status: "error", - errorMessage: "Download failed", - availableUpdate: null, - }; - await renderHarness(root!); - - expect(container?.textContent).toContain("Update failed"); - expect(container?.textContent).toContain("Download failed"); - expect( - container?.querySelector('[data-testid="update-callout-action-1"]')?.textContent, - ).toContain("Retry"); - }); - - it("renders nothing when not running as a desktop app", async () => { - updaterState.value = { ...updaterState.value, isDesktopApp: false }; - await renderHarness(root!); - - expect(container?.querySelector('[data-testid="update-callout"]')).toBeNull(); - }); - - it("renders nothing when status is idle / checking / up-to-date / pending", async () => { - for (const status of ["idle", "checking", "up-to-date", "pending"] as const) { - resetUpdaterState(); - updaterState.value = { ...updaterState.value, status }; - const innerContainer = document.createElement("div"); - document.body.appendChild(innerContainer); - const innerRoot = createRoot(innerContainer); - await act(async () => { - innerRoot.render(); - await Promise.resolve(); - await Promise.resolve(); - }); - expect(innerContainer.querySelector('[data-testid="update-callout"]')).toBeNull(); - act(() => { - innerRoot.unmount(); - }); - innerContainer.remove(); - } - }); - - it("dismisses the registered update callout", async () => { - await renderHarness(root!); - - const dismiss = container?.querySelector( - '[data-testid="update-callout-dismiss"]', - ) as HTMLElement | null; - expect(dismiss).not.toBeNull(); - act(() => { - dismiss?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - }); - expect(container?.querySelector('[data-testid="update-callout"]')).toBeNull(); - expect(asyncStorage.setItem).toHaveBeenCalledWith( - "@paseo:sidebar-callout-dismissals", - JSON.stringify(["desktop-update:available:1.2.3"]), - ); - }); -}); diff --git a/packages/app/src/desktop/updates/update-callout-source.tsx b/packages/app/src/desktop/updates/update-callout-source.tsx index 1c4143ada..e3329a3dd 100644 --- a/packages/app/src/desktop/updates/update-callout-source.tsx +++ b/packages/app/src/desktop/updates/update-callout-source.tsx @@ -6,6 +6,11 @@ import { SidebarCalloutDescriptionText, } from "@/components/sidebar-callout"; import { useSidebarCallouts } from "@/contexts/sidebar-callout-context"; +import { + resolveUpdateCalloutDescriptor, + type UpdateCalloutActionDescriptor, + type UpdateCalloutBody, +} from "@/desktop/updates/resolve-update-callout"; import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater"; import { useStableEvent } from "@/hooks/use-stable-event"; import { openExternalUrl } from "@/utils/open-external-url"; @@ -13,47 +18,22 @@ import { openExternalUrl } from "@/utils/open-external-url"; const CHECK_INTERVAL_MS = 30 * 60 * 1000; const CHANGELOG_URL = "https://paseo.sh/changelog"; -function resolveUpdateCalloutTitle(args: { isInstalling: boolean; isError: boolean }): string { - if (args.isInstalling) return "Installing update"; - if (args.isError) return "Update failed"; - return "Update available"; +function renderBody(body: UpdateCalloutBody): ReactNode { + if (body.kind === "installing") return "Installing and restarting..."; + if (body.kind === "error") return body.message; + return ; } -function resolveUpdateCalloutDescription(args: { - isInstalling: boolean; - isError: boolean; - errorMessage: string | null; - latestVersion: string | undefined; -}): ReactNode { - if (args.isInstalling) return "Installing and restarting..."; - if (args.isError) return args.errorMessage ?? "Something went wrong."; - if (args.latestVersion) { - return ( - - ); - } - return ; -} - -function buildUpdateCalloutActions(args: { - isInstalling: boolean; - isError: boolean; - openChangelog: () => void; - retry: () => void; - install: () => void; -}): SidebarCalloutAction[] { - const actions: SidebarCalloutAction[] = [{ label: "What's new", onPress: args.openChangelog }]; - if (args.isError) { - actions.push({ label: "Retry", onPress: args.retry, variant: "primary" }); - } else { - actions.push({ - label: args.isInstalling ? "Installing..." : "Install & restart", - onPress: args.install, - variant: "primary", - disabled: args.isInstalling, - }); - } - return actions; +function materializeActions( + actions: readonly UpdateCalloutActionDescriptor[], + handlers: { changelog: () => void; install: () => void; retry: () => void }, +): SidebarCalloutAction[] { + return actions.map((action) => ({ + label: action.label, + onPress: handlers[action.role], + variant: action.variant, + disabled: action.disabled, + })); } export function UpdateCalloutSource() { @@ -96,46 +76,34 @@ export function UpdateCalloutSource() { }, [isDesktopApp, checkForUpdates]); useEffect(() => { - if (!isDesktopApp) { - return; - } - if (status !== "available" && status !== "installing" && status !== "error") { - return; - } - - const isError = status === "error"; - const isAvailable = !isInstalling && !isError; - - const title = resolveUpdateCalloutTitle({ isInstalling, isError }); - const description = resolveUpdateCalloutDescription({ + const descriptor = resolveUpdateCalloutDescriptor({ + isDesktopApp, + status, isInstalling, - isError, + availableUpdate, errorMessage, - latestVersion: availableUpdate?.latestVersion ?? undefined, - }); - const actions = buildUpdateCalloutActions({ - isInstalling, - isError, - openChangelog, - retry, - install, }); + if (!descriptor) return; return callouts.show({ - id: "desktop-update", - dismissalKey: `desktop-update:${status}:${availableUpdate?.latestVersion ?? "unknown"}`, - priority: 200, - title, - description, - icon: isAvailable ? ( + id: descriptor.id, + dismissalKey: descriptor.dismissalKey, + priority: descriptor.priority, + title: descriptor.title, + description: renderBody(descriptor.body), + icon: descriptor.showGiftIcon ? ( ) : undefined, - variant: isError ? "error" : "default", - actions, - testID: "update-callout", + variant: descriptor.variant, + actions: materializeActions(descriptor.actions, { + changelog: openChangelog, + install, + retry, + }), + testID: descriptor.testID, }); }, [ - availableUpdate?.latestVersion, + availableUpdate, callouts, errorMessage, install, diff --git a/packages/app/src/desktop/updates/use-desktop-app-updater.test.tsx b/packages/app/src/desktop/updates/use-desktop-app-updater.test.tsx deleted file mode 100644 index 5efbbe980..000000000 --- a/packages/app/src/desktop/updates/use-desktop-app-updater.test.tsx +++ /dev/null @@ -1,94 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import React from "react"; -import { act, renderHook, waitFor } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { useDesktopAppUpdater } from "./use-desktop-app-updater"; - -const desktopUpdatesMock = vi.hoisted(() => ({ - shouldShowDesktopUpdateSection: vi.fn(() => true), - checkDesktopAppUpdate: vi.fn(async () => ({ - hasUpdate: false, - readyToInstall: false, - latestVersion: null, - })), - installDesktopAppUpdate: vi.fn(async () => ({ - installed: false, - message: null, - })), - formatVersionWithPrefix: vi.fn((version: string | null) => version ?? "\u2014"), -})); - -const settingsState = vi.hoisted(() => ({ - releaseChannel: "stable" as "stable" | "beta", -})); - -vi.mock("@/desktop/updates/desktop-updates", () => desktopUpdatesMock); - -vi.mock("@/desktop/settings/desktop-settings", () => ({ - useDesktopSettings: () => ({ - settings: { - releaseChannel: settingsState.releaseChannel, - daemon: { manageBuiltInDaemon: true, keepRunningAfterQuit: true }, - }, - isLoading: false, - error: null, - updateSettings: vi.fn(async () => {}), - }), -})); - -vi.mock("@/contexts/toast-context", () => ({ - useToast: () => ({ error: vi.fn(), show: vi.fn(), copied: vi.fn() }), -})); - -function createQueryClient(): QueryClient { - return new QueryClient({ - defaultOptions: { - queries: { retry: false }, - mutations: { retry: false }, - }, - }); -} - -function renderUpdaterHook() { - const queryClient = createQueryClient(); - const wrapper = ({ children }: { children: React.ReactNode }) => - React.createElement(QueryClientProvider, { client: queryClient }, children); - - return renderHook(() => useDesktopAppUpdater(), { wrapper }); -} - -describe("useDesktopAppUpdater", () => { - beforeEach(() => { - settingsState.releaseChannel = "stable"; - desktopUpdatesMock.checkDesktopAppUpdate.mockClear(); - desktopUpdatesMock.installDesktopAppUpdate.mockClear(); - }); - - it("uses the effective desktop release channel when checking for updates", async () => { - settingsState.releaseChannel = "beta"; - - renderUpdaterHook(); - - await waitFor(() => { - expect(desktopUpdatesMock.checkDesktopAppUpdate).toHaveBeenCalledWith({ - releaseChannel: "beta", - }); - }); - }); - - it("uses the effective desktop release channel when installing updates", async () => { - settingsState.releaseChannel = "beta"; - const { result } = renderUpdaterHook(); - - await act(async () => { - await result.current.installUpdate(); - }); - - expect(desktopUpdatesMock.installDesktopAppUpdate).toHaveBeenCalledWith({ - releaseChannel: "beta", - }); - }); -}); diff --git a/packages/app/src/desktop/updates/use-desktop-app-updater.ts b/packages/app/src/desktop/updates/use-desktop-app-updater.ts index c67f146ee..5522213f1 100644 --- a/packages/app/src/desktop/updates/use-desktop-app-updater.ts +++ b/packages/app/src/desktop/updates/use-desktop-app-updater.ts @@ -1,5 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { useMutation } from "@tanstack/react-query"; +import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react"; import { checkDesktopAppUpdate, formatVersionWithPrefix, @@ -10,18 +9,14 @@ import { } from "@/desktop/updates/desktop-updates"; import { useDesktopSettings } from "@/desktop/settings/desktop-settings"; import { useDesktopIpcErrorReporter } from "@/desktop/hooks/desktop-ipc-error"; +import { + PENDING_RECHECK_MS, + createDesktopAppUpdater, + formatStatusText, + type DesktopAppUpdateStatus, +} from "@/desktop/updates/desktop-app-updater"; -export type DesktopAppUpdateStatus = - | "idle" - | "checking" - | "pending" - | "up-to-date" - | "available" - | "installing" - | "installed" - | "error"; - -const PENDING_RECHECK_MS = 10_000; +export type { DesktopAppUpdateStatus }; export interface UseDesktopAppUpdaterReturn { isDesktopApp: boolean; @@ -36,142 +31,57 @@ export interface UseDesktopAppUpdaterReturn { installUpdate: () => Promise; } -function getErrorMessage(error: unknown): string { - if (error instanceof Error && typeof error.message === "string") { - return error.message; - } - return String(error); -} - -function formatStatusText(input: { - status: DesktopAppUpdateStatus; - availableUpdate: DesktopAppUpdateCheckResult | null; - installMessage: string | null; -}): string { - const { status, availableUpdate, installMessage } = input; - - if (status === "checking") { - return "Checking for app updates..."; - } - - if (status === "installing") { - return "Installing app update..."; - } - - if (status === "up-to-date") { - return "App is up to date."; - } - - if (status === "pending") { - return "We'll let you know when the update is ready."; - } - - if (status === "available") { - if (availableUpdate?.latestVersion) { - return `Update ready: ${formatVersionWithPrefix(availableUpdate.latestVersion)}`; - } - return "An app update is ready to install."; - } - - if (status === "installed") { - return installMessage ?? "App update installed. Restart required."; - } - - if (status === "error") { - return "Failed to update app."; - } - - return "Update status has not been checked yet."; -} - export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn { const isDesktopApp = shouldShowDesktopUpdateSection(); const { settings: desktopSettings } = useDesktopSettings(); const releaseChannel = desktopSettings.releaseChannel; const reportError = useDesktopIpcErrorReporter(); - const requestVersionRef = useRef(0); - const [status, setStatus] = useState("idle"); - const [availableUpdate, setAvailableUpdate] = useState(null); - const [errorMessage, setErrorMessage] = useState(null); - const [installMessage, setInstallMessage] = useState(null); - const [lastCheckedAt, setLastCheckedAt] = useState(null); - const { mutateAsync: installAppUpdate, isPending: isInstallingAppUpdate } = useMutation< - DesktopAppUpdateInstallResult, - Error - >({ - mutationFn: () => installDesktopAppUpdate({ releaseChannel }), - onError: (error) => { - reportError({ - error, - message: "Unable to install the desktop app update.", - logLabel: "[DesktopUpdater] Failed to install app update", - }); - }, - }); + + const updater = useMemo( + () => + createDesktopAppUpdater({ + port: { + checkDesktopAppUpdate, + installDesktopAppUpdate, + }, + now: () => Date.now(), + reportInstallError: reportError, + }), + [reportError], + ); + + const snapshot = useSyncExternalStore( + updater.subscribe, + updater.getSnapshot, + updater.getSnapshot, + ); const checkForUpdates = useCallback( async (options: { silent?: boolean } = {}) => { if (!isDesktopApp) { return null; } - - const requestVersion = requestVersionRef.current + 1; - requestVersionRef.current = requestVersion; - - if (!options.silent) { - setStatus("checking"); - } - setErrorMessage(null); - - try { - const result = await checkDesktopAppUpdate({ releaseChannel }); - if (requestVersion !== requestVersionRef.current) { - return result; - } - - setInstallMessage(null); - setLastCheckedAt(Date.now()); - - if (result.readyToInstall) { - setAvailableUpdate(result); - setStatus("available"); - } else if (result.hasUpdate) { - setAvailableUpdate(null); - setStatus("pending"); - } else { - setAvailableUpdate(null); - setStatus("up-to-date"); - } - - return result; - } catch (error) { - if (requestVersion !== requestVersionRef.current) { - return null; - } - - const message = getErrorMessage(error); - if (options.silent) { - console.warn("[DesktopUpdater] Silent update check failed", message); - } else { - setStatus("error"); - setErrorMessage(message); - } - return null; - } + return updater.checkForUpdates({ releaseChannel, silent: options.silent }); }, - [isDesktopApp, releaseChannel], + [isDesktopApp, releaseChannel, updater], ); + const installUpdate = useCallback(async () => { + if (!isDesktopApp) { + return null; + } + return updater.installUpdate({ releaseChannel }); + }, [isDesktopApp, releaseChannel, updater]); + useEffect(() => { if (!isDesktopApp) { return; } - void checkForUpdates({ silent: true }); }, [checkForUpdates, isDesktopApp]); useEffect(() => { - if (!isDesktopApp || status !== "pending") { + if (!isDesktopApp || snapshot.status !== "pending") { return undefined; } @@ -182,52 +92,22 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn { return () => { clearInterval(intervalId); }; - }, [checkForUpdates, isDesktopApp, status]); - - const installUpdate = useCallback(async () => { - if (!isDesktopApp) { - return null; - } - - setStatus("installing"); - setErrorMessage(null); - - try { - const result = await installAppUpdate(); - setLastCheckedAt(Date.now()); - - if (result.installed) { - setAvailableUpdate(null); - setInstallMessage(result.message); - setStatus("installed"); - } else { - setAvailableUpdate(null); - setInstallMessage(result.message); - setStatus("up-to-date"); - } - - return result; - } catch (error) { - const message = getErrorMessage(error); - setStatus("error"); - setErrorMessage(message); - return null; - } - }, [installAppUpdate, isDesktopApp]); + }, [checkForUpdates, isDesktopApp, snapshot.status]); return { isDesktopApp, - status, + status: snapshot.status, statusText: formatStatusText({ - status, - availableUpdate, - installMessage, + status: snapshot.status, + availableUpdate: snapshot.availableUpdate, + installMessage: snapshot.installMessage, + formatVersion: formatVersionWithPrefix, }), - availableUpdate, - errorMessage, - lastCheckedAt, - isChecking: status === "checking", - isInstalling: status === "installing" || isInstallingAppUpdate, + availableUpdate: snapshot.availableUpdate, + errorMessage: snapshot.errorMessage, + lastCheckedAt: snapshot.lastCheckedAt, + isChecking: snapshot.isChecking, + isInstalling: snapshot.isInstalling, checkForUpdates, installUpdate, }; diff --git a/packages/app/src/git/checkout-status-cache.test.ts b/packages/app/src/git/checkout-status-cache.test.ts new file mode 100644 index 000000000..334d67a4e --- /dev/null +++ b/packages/app/src/git/checkout-status-cache.test.ts @@ -0,0 +1,124 @@ +import { QueryClient } from "@tanstack/react-query"; +import { describe, expect, it, vi } from "vitest"; +import type { CheckoutStatusUpdate } from "@getpaseo/protocol/messages"; +import { checkoutStatusQueryKey } from "@/git/query-keys"; +import { + applyCheckoutStatusUpdate, + type CheckoutStatusPayload, + peekOrFetchCheckoutStatus, +} from "./checkout-status-cache"; + +const serverId = "server-1"; +const cwd = "/repo"; + +function checkoutStatus(overrides: Partial = {}): CheckoutStatusPayload { + return { + cwd, + error: null, + requestId: "checkout-status-1", + isGit: true, + isPaseoOwnedWorktree: false, + repoRoot: cwd, + currentBranch: "main", + isDirty: false, + baseRef: "origin/main", + aheadBehind: { ahead: 0, behind: 0 }, + aheadOfOrigin: 0, + behindOfOrigin: 0, + hasRemote: true, + remoteUrl: "git@github.com:getpaseo/paseo.git", + ...overrides, + } as CheckoutStatusPayload; +} + +function checkoutStatusUpdate(payload: CheckoutStatusPayload): CheckoutStatusUpdate { + return { type: "checkout_status_update", payload }; +} + +function createQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); +} + +describe("peekOrFetchCheckoutStatus", () => { + it("fetches from the client and writes the result to the cache on a cold read", async () => { + const queryClient = createQueryClient(); + const fetched = checkoutStatus({ requestId: "cold-read" }); + const client = { getCheckoutStatus: vi.fn(async () => fetched) }; + + const result = await peekOrFetchCheckoutStatus({ queryClient, client, serverId, cwd }); + + expect(result).toEqual(fetched); + expect(client.getCheckoutStatus).toHaveBeenCalledExactlyOnceWith(cwd); + expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, cwd))).toEqual(fetched); + }); + + it("returns the cached snapshot without calling the client when the cache already has data", async () => { + const queryClient = createQueryClient(); + const cached = checkoutStatus({ requestId: "cached" }); + queryClient.setQueryData(checkoutStatusQueryKey(serverId, cwd), cached); + const client = { + getCheckoutStatus: vi.fn(async () => checkoutStatus({ requestId: "uncached" })), + }; + + const result = await peekOrFetchCheckoutStatus({ queryClient, client, serverId, cwd }); + + expect(result).toEqual(cached); + expect(client.getCheckoutStatus).not.toHaveBeenCalled(); + }); +}); + +describe("applyCheckoutStatusUpdate", () => { + it("writes the pushed payload into the cache key for the matching cwd", () => { + const queryClient = createQueryClient(); + const pushed = checkoutStatus({ + requestId: "server-push", + currentBranch: "pushed-branch", + isDirty: true, + aheadBehind: { ahead: 2, behind: 1 }, + aheadOfOrigin: 2, + behindOfOrigin: 1, + }); + + applyCheckoutStatusUpdate({ + queryClient, + serverId, + cwd, + message: checkoutStatusUpdate(pushed), + }); + + expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, cwd))).toEqual(pushed); + }); + + it("ignores updates whose payload cwd does not match the subscribed cwd", () => { + const queryClient = createQueryClient(); + const otherCwd = "/other-repo"; + const otherCached = checkoutStatus({ + cwd: otherCwd, + repoRoot: otherCwd, + requestId: "other-cached", + currentBranch: "other-main", + }); + queryClient.setQueryData(checkoutStatusQueryKey(serverId, otherCwd), otherCached); + + applyCheckoutStatusUpdate({ + queryClient, + serverId, + cwd, + message: checkoutStatusUpdate( + checkoutStatus({ + cwd: otherCwd, + repoRoot: otherCwd, + requestId: "server-push", + currentBranch: "pushed-branch", + }), + ), + }); + + expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, cwd))).toBeUndefined(); + expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, otherCwd))).toEqual( + otherCached, + ); + }); +}); diff --git a/packages/app/src/git/checkout-status-cache.ts b/packages/app/src/git/checkout-status-cache.ts new file mode 100644 index 000000000..471db1e4b --- /dev/null +++ b/packages/app/src/git/checkout-status-cache.ts @@ -0,0 +1,48 @@ +import type { QueryClient } from "@tanstack/react-query"; +import type { CheckoutStatusResponse, CheckoutStatusUpdate } from "@getpaseo/protocol/messages"; +import { checkoutStatusQueryKey } from "@/git/query-keys"; + +export type CheckoutStatusPayload = CheckoutStatusResponse["payload"]; + +export interface CheckoutStatusClient { + getCheckoutStatus: (cwd: string) => Promise; +} + +export async function peekOrFetchCheckoutStatus({ + queryClient, + client, + serverId, + cwd, +}: { + queryClient: QueryClient; + client: CheckoutStatusClient; + serverId: string; + cwd: string; +}): Promise { + const queryKey = checkoutStatusQueryKey(serverId, cwd); + const cached = queryClient.getQueryData(queryKey); + if (cached) { + return cached; + } + + const snapshot = await client.getCheckoutStatus(cwd); + queryClient.setQueryData(queryKey, snapshot); + return snapshot; +} + +export function applyCheckoutStatusUpdate({ + queryClient, + serverId, + cwd, + message, +}: { + queryClient: QueryClient; + serverId: string; + cwd: string; + message: CheckoutStatusUpdate; +}): void { + if (message.payload.cwd !== cwd) { + return; + } + queryClient.setQueryData(checkoutStatusQueryKey(serverId, cwd), message.payload); +} diff --git a/packages/app/src/git/use-status-query.test.ts b/packages/app/src/git/use-status-query.test.ts deleted file mode 100644 index a2f90fa1d..000000000 --- a/packages/app/src/git/use-status-query.test.ts +++ /dev/null @@ -1,318 +0,0 @@ -import React, { act } from "react"; -import { createRoot } from "react-dom/client"; -import { JSDOM } from "jsdom"; -import { - QueryClient, - QueryClientProvider, - focusManager, - onlineManager, -} from "@tanstack/react-query"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { CheckoutStatusResponse } from "@getpaseo/protocol/messages"; -import { checkoutStatusQueryKey } from "@/git/query-keys"; -import { useCheckoutStatusQuery } from "./use-status-query"; - -type CheckoutStatusPayload = CheckoutStatusResponse["payload"]; - -const { mockRuntime, mockClient, checkoutStatusUpdateHandlers } = vi.hoisted(() => { - const hoistedHandlers = new Set<(message: unknown) => void>(); - const hoistedClient = { - getCheckoutStatus: vi.fn(), - on: vi.fn((type: string, handler: (message: unknown) => void) => { - if (type !== "checkout_status_update") { - return () => {}; - } - hoistedHandlers.add(handler); - return () => { - hoistedHandlers.delete(handler); - }; - }), - }; - - return { - mockClient: hoistedClient, - checkoutStatusUpdateHandlers: hoistedHandlers, - mockRuntime: { - client: hoistedClient, - isConnected: true, - }, - }; -}); - -vi.mock("@/runtime/host-runtime", () => ({ - useHostRuntimeClient: () => mockRuntime.client, - useHostRuntimeIsConnected: () => mockRuntime.isConnected, -})); - -const serverId = "server-1"; -const cwd = "/repo"; - -function checkoutStatus(overrides: Partial = {}): CheckoutStatusPayload { - return { - cwd, - error: null, - requestId: "checkout-status-1", - isGit: true, - isPaseoOwnedWorktree: false, - repoRoot: cwd, - currentBranch: "main", - isDirty: false, - baseRef: "origin/main", - aheadBehind: { ahead: 0, behind: 0 }, - aheadOfOrigin: 0, - behindOfOrigin: 0, - hasRemote: true, - remoteUrl: "git@github.com:getpaseo/paseo.git", - ...overrides, - } as CheckoutStatusPayload; -} - -function createTestQueryClient(): QueryClient { - return new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, - }); -} - -function renderCheckoutStatusHook({ - queryClient = createTestQueryClient(), - hookCwd = cwd, -}: { - queryClient?: QueryClient; - hookCwd?: string; -} = {}) { - let latest: ReturnType | null = null; - - function Probe() { - latest = useCheckoutStatusQuery({ serverId, cwd: hookCwd }); - return null; - } - - const container = document.getElementById("root"); - if (!container) { - throw new Error("Missing root container"); - } - - const root = createRoot(container); - - return { - get latest() { - if (!latest) { - throw new Error("Expected hook result"); - } - return latest; - }, - async mount() { - await act(async () => { - root.render( - React.createElement( - QueryClientProvider, - { client: queryClient }, - React.createElement(Probe), - ), - ); - }); - }, - async unmount() { - await act(async () => { - root.unmount(); - }); - }, - }; -} - -async function waitForExpectation(assertion: () => void): Promise { - let lastError: unknown; - - for (let attempt = 0; attempt < 60; attempt += 1) { - try { - assertion(); - return; - } catch (error) { - lastError = error; - await act(async () => { - vi.advanceTimersByTime(10); - await Promise.resolve(); - }); - } - } - - throw lastError; -} - -async function emitCheckoutStatusUpdate(payload: CheckoutStatusPayload): Promise { - await act(async () => { - for (const handler of checkoutStatusUpdateHandlers) { - handler({ - type: "checkout_status_update", - payload, - }); - } - await Promise.resolve(); - }); -} - -describe("useCheckoutStatusQuery", () => { - beforeEach(() => { - vi.useFakeTimers(); - - const dom = new JSDOM("
", { - url: "http://localhost", - }); - - Object.defineProperty(globalThis, "document", { - value: dom.window.document, - configurable: true, - }); - Object.defineProperty(globalThis, "window", { - value: dom.window, - configurable: true, - }); - Object.defineProperty(globalThis, "navigator", { - value: dom.window.navigator, - configurable: true, - }); - Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { - value: true, - configurable: true, - }); - - mockRuntime.client = mockClient; - mockRuntime.isConnected = true; - mockClient.getCheckoutStatus.mockReset(); - mockClient.on.mockClear(); - checkoutStatusUpdateHandlers.clear(); - }); - - afterEach(() => { - focusManager.setFocused(undefined); - onlineManager.setOnline(true); - vi.useRealTimers(); - }); - - it("does not schedule polling after the cold read", async () => { - mockClient.getCheckoutStatus.mockResolvedValue(checkoutStatus()); - const hook = renderCheckoutStatusHook(); - - await hook.mount(); - await waitForExpectation(() => { - expect(hook.latest.status?.currentBranch).toBe("main"); - }); - - await act(async () => { - vi.advanceTimersByTime(60_000); - }); - - expect(mockClient.getCheckoutStatus).toHaveBeenCalledTimes(1); - }); - - it("returns the cached snapshot without calling the daemon", async () => { - const queryClient = createTestQueryClient(); - const cached = checkoutStatus({ requestId: "cached" }); - queryClient.setQueryData(checkoutStatusQueryKey(serverId, cwd), cached); - mockClient.getCheckoutStatus.mockResolvedValue(checkoutStatus({ requestId: "uncached" })); - const hook = renderCheckoutStatusHook({ queryClient }); - - await hook.mount(); - - expect(hook.latest.status).toEqual(cached); - expect(mockClient.getCheckoutStatus).not.toHaveBeenCalled(); - }); - - it("fetches once on a cold read and uses cache on remount", async () => { - const queryClient = createTestQueryClient(); - const fetched = checkoutStatus({ requestId: "cold-read" }); - mockClient.getCheckoutStatus.mockResolvedValue(fetched); - const firstHook = renderCheckoutStatusHook({ queryClient }); - - await firstHook.mount(); - await waitForExpectation(() => { - expect(firstHook.latest.status).toEqual(fetched); - }); - await firstHook.unmount(); - - const secondHook = renderCheckoutStatusHook({ queryClient }); - await secondHook.mount(); - - expect(secondHook.latest.status).toEqual(fetched); - expect(mockClient.getCheckoutStatus).toHaveBeenCalledTimes(1); - }); - - it("does not refetch checkout status on focus or reconnect", async () => { - mockClient.getCheckoutStatus.mockResolvedValue(checkoutStatus()); - const hook = renderCheckoutStatusHook(); - - await hook.mount(); - await waitForExpectation(() => { - expect(hook.latest.status?.currentBranch).toBe("main"); - }); - - await act(async () => { - focusManager.setFocused(false); - focusManager.setFocused(true); - onlineManager.setOnline(false); - onlineManager.setOnline(true); - await Promise.resolve(); - }); - - expect(mockClient.getCheckoutStatus).toHaveBeenCalledTimes(1); - }); - - it("server-pushed checkout status update writes into the React Query cache at checkoutStatusQueryKey(serverId, cwd)", async () => { - const queryClient = createTestQueryClient(); - mockClient.getCheckoutStatus.mockResolvedValue(checkoutStatus({ requestId: "initial" })); - const hook = renderCheckoutStatusHook({ queryClient }); - - await hook.mount(); - await waitForExpectation(() => { - expect(hook.latest.status?.requestId).toBe("initial"); - }); - - mockClient.getCheckoutStatus.mockClear(); - const pushed = checkoutStatus({ - requestId: "server-push", - currentBranch: "pushed-branch", - isDirty: true, - aheadBehind: { ahead: 2, behind: 1 }, - aheadOfOrigin: 2, - behindOfOrigin: 1, - }); - await emitCheckoutStatusUpdate(pushed); - - expect(mockClient.getCheckoutStatus).not.toHaveBeenCalled(); - expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, cwd))).toEqual(pushed); - }); - - it("server push only updates the matching cwd's cache key, not other workspaces", async () => { - const queryClient = createTestQueryClient(); - const otherCwd = "/other-repo"; - const otherCached = checkoutStatus({ - cwd: otherCwd, - repoRoot: otherCwd, - requestId: "other-cached", - currentBranch: "other-main", - }); - queryClient.setQueryData(checkoutStatusQueryKey(serverId, otherCwd), otherCached); - mockClient.getCheckoutStatus.mockResolvedValue(checkoutStatus({ requestId: "initial" })); - const hook = renderCheckoutStatusHook({ queryClient }); - - await hook.mount(); - await waitForExpectation(() => { - expect(hook.latest.status?.requestId).toBe("initial"); - }); - - const pushed = checkoutStatus({ - requestId: "server-push", - currentBranch: "pushed-branch", - }); - await emitCheckoutStatusUpdate(pushed); - - expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, cwd))).toEqual(pushed); - expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, otherCwd))).toEqual( - otherCached, - ); - }); -}); diff --git a/packages/app/src/git/use-status-query.ts b/packages/app/src/git/use-status-query.ts index 7f463cc07..115260a51 100644 --- a/packages/app/src/git/use-status-query.ts +++ b/packages/app/src/git/use-status-query.ts @@ -1,8 +1,15 @@ -import { type QueryClient, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect } from "react"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; -import type { CheckoutStatusResponse } from "@getpaseo/protocol/messages"; import { checkoutStatusQueryKey } from "@/git/query-keys"; +import { + applyCheckoutStatusUpdate, + type CheckoutStatusClient, + type CheckoutStatusPayload, + peekOrFetchCheckoutStatus, +} from "./checkout-status-cache"; + +export type { CheckoutStatusPayload } from "./checkout-status-cache"; export const CHECKOUT_STATUS_STALE_TIME = 15_000; @@ -11,12 +18,6 @@ interface UseCheckoutStatusQueryOptions { cwd: string; } -export type CheckoutStatusPayload = CheckoutStatusResponse["payload"]; - -interface CheckoutStatusClient { - getCheckoutStatus: (cwd: string) => Promise; -} - function fetchCheckoutStatus( client: CheckoutStatusClient, cwd: string, @@ -24,28 +25,6 @@ function fetchCheckoutStatus( return client.getCheckoutStatus(cwd); } -async function peekOrFetchSnapshot({ - queryClient, - client, - serverId, - cwd, -}: { - queryClient: QueryClient; - client: CheckoutStatusClient; - serverId: string; - cwd: string; -}): Promise { - const queryKey = checkoutStatusQueryKey(serverId, cwd); - const cached = queryClient.getQueryData(queryKey); - if (cached) { - return cached; - } - - const snapshot = await fetchCheckoutStatus(client, cwd); - queryClient.setQueryData(queryKey, snapshot); - return snapshot; -} - export function useCheckoutStatusQuery({ serverId, cwd }: UseCheckoutStatusQueryOptions) { const queryClient = useQueryClient(); const client = useHostRuntimeClient(serverId); @@ -57,10 +36,7 @@ export function useCheckoutStatusQuery({ serverId, cwd }: UseCheckoutStatusQuery } return client.on("checkout_status_update", (message) => { - if (message.payload.cwd !== cwd) { - return; - } - queryClient.setQueryData(checkoutStatusQueryKey(serverId, cwd), message.payload); + applyCheckoutStatusUpdate({ queryClient, serverId, cwd, message }); }); }, [client, isConnected, cwd, queryClient, serverId]); @@ -70,7 +46,7 @@ export function useCheckoutStatusQuery({ serverId, cwd }: UseCheckoutStatusQuery if (!client) { throw new Error("Daemon client not available"); } - return await peekOrFetchSnapshot({ queryClient, client, serverId, cwd }); + return await peekOrFetchCheckoutStatus({ queryClient, client, serverId, cwd }); }, enabled: !!client && isConnected && !!cwd, staleTime: Infinity, diff --git a/packages/app/src/hooks/client-activity-tracker.test.ts b/packages/app/src/hooks/client-activity-tracker.test.ts new file mode 100644 index 000000000..b4506f569 --- /dev/null +++ b/packages/app/src/hooks/client-activity-tracker.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, it } from "vitest"; +import { + ACTIVITY_HEARTBEAT_THROTTLE_MS, + createClientActivityTracker, + type ClientActivityTracker, + type ClientActivityTrackerInput, + type HeartbeatClient, + type HeartbeatPayload, +} from "./client-activity-tracker"; + +const START_MS = new Date("2026-04-19T10:00:00.000Z").getTime(); + +function createTestClock(initial = START_MS) { + let t = initial; + return { + now: () => t, + set(ms: number) { + t = ms; + }, + advance(ms: number) { + t += ms; + }, + }; +} + +interface FakeHeartbeatClient extends HeartbeatClient { + isConnected: boolean; + recordedHeartbeats: HeartbeatPayload[]; + latest(): HeartbeatPayload; + reset(): void; +} + +function createFakeHeartbeatClient(): FakeHeartbeatClient { + const recorded: HeartbeatPayload[] = []; + return { + isConnected: true, + recordedHeartbeats: recorded, + sendHeartbeat(payload) { + recorded.push(payload); + }, + latest() { + const last = recorded.at(-1); + if (!last) throw new Error("Expected a heartbeat"); + return last; + }, + reset() { + recorded.length = 0; + }, + }; +} + +function buildTracker( + overrides: Partial & { + client?: FakeHeartbeatClient; + clock?: ReturnType; + } = {}, +): { + tracker: ClientActivityTracker; + client: FakeHeartbeatClient; + clock: ReturnType; +} { + const client = overrides.client ?? createFakeHeartbeatClient(); + const clock = overrides.clock ?? createTestClock(); + const tracker = createClientActivityTracker({ + client, + deviceType: overrides.deviceType ?? "web", + initialFocusedAgentId: overrides.initialFocusedAgentId ?? "agent-1", + initialAppVisible: overrides.initialAppVisible ?? true, + now: clock.now, + onAppResumed: overrides.onAppResumed, + }); + return { tracker, client, clock }; +} + +describe("client activity tracker", () => { + it("includes the latest user-activity time in the next heartbeat", () => { + const { tracker, client, clock } = buildTracker(); + + clock.advance(5_250); + tracker.recordUserActivity(); + tracker.maybeSendImmediateHeartbeat(); + + expect(client.latest()).toMatchObject({ + deviceType: "web", + focusedAgentId: "agent-1", + appVisible: true, + lastActivityAt: new Date(START_MS + 5_250).toISOString(), + }); + }); + + it("throttles repeated immediate heartbeats within the throttle window", () => { + const { tracker, client, clock } = buildTracker(); + + tracker.recordUserActivity(); + tracker.maybeSendImmediateHeartbeat(); + expect(client.recordedHeartbeats).toHaveLength(1); + + clock.advance(ACTIVITY_HEARTBEAT_THROTTLE_MS - 1); + tracker.recordUserActivity(); + tracker.maybeSendImmediateHeartbeat(); + expect(client.recordedHeartbeats).toHaveLength(1); + + clock.advance(1); + tracker.recordUserActivity(); + tracker.maybeSendImmediateHeartbeat(); + expect(client.recordedHeartbeats).toHaveLength(2); + }); + + it("sends one immediate heartbeat when the focused agent changes", () => { + const { tracker, client, clock } = buildTracker(); + tracker.sendHeartbeat(); // simulate the on-connect heartbeat + expect(client.recordedHeartbeats).toHaveLength(1); + client.reset(); + + clock.advance(5_000); + tracker.setFocusedAgentId("agent-2"); + + expect(client.recordedHeartbeats).toHaveLength(1); + expect(client.latest()).toMatchObject({ + focusedAgentId: "agent-2", + lastActivityAt: new Date(START_MS + 5_000).toISOString(), + }); + }); + + it("ignores focused-agent updates that do not change the value", () => { + const { tracker, client } = buildTracker({ initialFocusedAgentId: "agent-1" }); + + tracker.setFocusedAgentId("agent-1"); + + expect(client.recordedHeartbeats).toHaveLength(0); + }); + + it("drives lastActivityAt forward from system idle polling", () => { + const { tracker, client, clock } = buildTracker(); + + clock.advance(5_000); + tracker.notifySystemIdleMs(0); + clock.advance(5_000); + tracker.notifySystemIdleMs(0); + clock.advance(5_000); + tracker.notifySystemIdleMs(0); + tracker.sendHeartbeat(); + + expect(client.latest().lastActivityAt).toBe(new Date(START_MS + 15_000).toISOString()); + }); + + it("sets lastActivityAt to now minus the system idle time", () => { + const { tracker, client, clock } = buildTracker(); + + clock.advance(15_000); + tracker.notifySystemIdleMs(2_000); + tracker.sendHeartbeat(); + + expect(client.latest().lastActivityAt).toBe(new Date(START_MS + 13_000).toISOString()); + }); + + it("ignores failed system idle polls", () => { + const { tracker, client, clock } = buildTracker(); + tracker.sendHeartbeat(); + const before = client.latest().lastActivityAt; + + clock.advance(15_000); + tracker.notifySystemIdleMs(null); + tracker.sendHeartbeat(); + + expect(client.latest().lastActivityAt).toBe(before); + }); + + it("never moves lastActivityAt backward from a system idle poll", () => { + const { tracker, client, clock } = buildTracker(); + + clock.advance(5_000); + tracker.recordUserActivity(); + const userActivityAt = new Date(clock.now()).toISOString(); + + clock.advance(10_000); + tracker.notifySystemIdleMs(20_000); // would imply activity 5_000 ms before start + tracker.sendHeartbeat(); + + expect(client.latest().lastActivityAt).toBe(userActivityAt); + }); + + it("emits an appVisibilityChangedAt and runs onAppResumed when becoming visible after backgrounding", () => { + let resumed: number | null = null; + const { tracker, client, clock } = buildTracker({ + initialAppVisible: true, + onAppResumed: (awayMs) => { + resumed = awayMs; + }, + }); + + clock.advance(2_000); + const hideTransition = tracker.notifyAppVisibility(false); + expect(hideTransition.changed).toBe(true); + + clock.advance(8_000); + const showTransition = tracker.notifyAppVisibility(true); + expect(showTransition.changed).toBe(true); + expect(resumed).toBe(8_000); + + tracker.sendHeartbeat(); + expect(client.latest()).toMatchObject({ + appVisible: true, + appVisibilityChangedAt: new Date(START_MS + 10_000).toISOString(), + lastActivityAt: new Date(START_MS + 10_000).toISOString(), + }); + }); + + it("treats no-op visibility transitions as no change", () => { + const { tracker } = buildTracker({ initialAppVisible: true }); + const result = tracker.notifyAppVisibility(true); + expect(result.changed).toBe(false); + }); + + it("does not call onAppResumed when transitioning visible→visible or from initial-visible to hidden", () => { + let resumed: number | null = null; + const { tracker } = buildTracker({ + initialAppVisible: true, + onAppResumed: (awayMs) => { + resumed = awayMs; + }, + }); + + tracker.notifyAppVisibility(false); + expect(resumed).toBeNull(); + }); + + it("skips heartbeats while the client is disconnected", () => { + const client = createFakeHeartbeatClient(); + client.isConnected = false; + const { tracker, clock } = buildTracker({ client }); + + tracker.sendHeartbeat(); + tracker.recordUserActivity(); + tracker.maybeSendImmediateHeartbeat(); + clock.advance(10_000); + tracker.setFocusedAgentId("agent-2"); + + expect(client.recordedHeartbeats).toHaveLength(0); + }); +}); diff --git a/packages/app/src/hooks/client-activity-tracker.ts b/packages/app/src/hooks/client-activity-tracker.ts new file mode 100644 index 000000000..594a75486 --- /dev/null +++ b/packages/app/src/hooks/client-activity-tracker.ts @@ -0,0 +1,104 @@ +export const HEARTBEAT_INTERVAL_MS = 15_000; +export const ACTIVITY_HEARTBEAT_THROTTLE_MS = 5_000; +export const DESKTOP_IDLE_POLL_INTERVAL_MS = 5_000; + +export interface HeartbeatPayload { + deviceType: "web" | "mobile"; + focusedAgentId: string | null; + lastActivityAt: string; + appVisible: boolean; + appVisibilityChangedAt?: string; +} + +export interface HeartbeatClient { + readonly isConnected: boolean; + sendHeartbeat(payload: HeartbeatPayload): void; +} + +export interface ClientActivityTrackerInput { + client: HeartbeatClient; + deviceType: "web" | "mobile"; + initialFocusedAgentId: string | null; + initialAppVisible: boolean; + now: () => number; + onAppResumed?: (awayMs: number) => void; +} + +export interface ClientActivityTracker { + recordUserActivity(): void; + maybeSendImmediateHeartbeat(): void; + setFocusedAgentId(id: string | null): void; + notifyAppVisibility(visible: boolean): { changed: boolean }; + notifySystemIdleMs(idleMs: number | null): void; + sendHeartbeat(): void; +} + +export function createClientActivityTracker( + input: ClientActivityTrackerInput, +): ClientActivityTracker { + const { client, deviceType, now, onAppResumed } = input; + let lastActivityAtMs = now(); + let appVisible = input.initialAppVisible; + let appVisibilityChangedAtMs = now(); + let backgroundedAtMs: number | null = appVisible ? null : now(); + let focusedAgentId = input.initialFocusedAgentId; + let lastImmediateHeartbeatAtMs = 0; + + function sendHeartbeat(): void { + if (!client.isConnected) return; + client.sendHeartbeat({ + deviceType, + focusedAgentId, + lastActivityAt: new Date(lastActivityAtMs).toISOString(), + appVisible, + appVisibilityChangedAt: new Date(appVisibilityChangedAtMs).toISOString(), + }); + } + + function recordUserActivity(): void { + lastActivityAtMs = now(); + } + + function maybeSendImmediateHeartbeat(): void { + if (!client.isConnected) return; + const t = now(); + if (t - lastImmediateHeartbeatAtMs < ACTIVITY_HEARTBEAT_THROTTLE_MS) return; + lastImmediateHeartbeatAtMs = t; + sendHeartbeat(); + } + + return { + recordUserActivity, + maybeSendImmediateHeartbeat, + setFocusedAgentId(id) { + if (focusedAgentId === id) return; + focusedAgentId = id; + recordUserActivity(); + sendHeartbeat(); + }, + notifyAppVisibility(nextVisible) { + if (appVisible === nextVisible) return { changed: false }; + appVisible = nextVisible; + appVisibilityChangedAtMs = now(); + if (!nextVisible) { + backgroundedAtMs = now(); + return { changed: true }; + } + const at = backgroundedAtMs; + backgroundedAtMs = null; + if (at !== null) { + onAppResumed?.(Math.max(0, now() - at)); + } + recordUserActivity(); + return { changed: true }; + }, + notifySystemIdleMs(idleMs) { + if (idleMs === null) return; + const systemLastActivityAtMs = now() - idleMs; + if (systemLastActivityAtMs > lastActivityAtMs) { + lastActivityAtMs = systemLastActivityAtMs; + } + }, + sendHeartbeat, + }; +} diff --git a/packages/app/src/hooks/hardware-keyboard-submit-controller.test.ts b/packages/app/src/hooks/hardware-keyboard-submit-controller.test.ts new file mode 100644 index 000000000..a7acd8ccc --- /dev/null +++ b/packages/app/src/hooks/hardware-keyboard-submit-controller.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from "vitest"; +import { + createHardwareKeyboardSubmitController, + type HardwareKeyboardSubmitListenerPort, +} from "./hardware-keyboard-submit-controller"; + +interface FakeKeyboard extends HardwareKeyboardSubmitListenerPort { + emit(): void; + readonly isEnabled: boolean; + readonly listenerCount: number; +} + +function createFakeKeyboard(): FakeKeyboard { + const handlers = new Set<() => void>(); + let enabled = false; + return { + addListener(handler) { + handlers.add(handler); + return { remove: () => handlers.delete(handler) }; + }, + setEnabled(value) { + enabled = value; + }, + emit() { + handlers.forEach((handler) => handler()); + }, + get isEnabled() { + return enabled; + }, + get listenerCount() { + return handlers.size; + }, + }; +} + +describe("hardware-keyboard-submit-controller", () => { + it("dispatches to onSubmit when the keyboard emits while enabled", () => { + const keyboard = createFakeKeyboard(); + const controller = createHardwareKeyboardSubmitController(keyboard); + let calls = 0; + controller.setOnSubmit(() => { + calls += 1; + }); + + controller.enable(); + keyboard.emit(); + + expect(calls).toBe(1); + expect(keyboard.isEnabled).toBe(true); + }); + + it("does not subscribe or enable when never enabled", () => { + const keyboard = createFakeKeyboard(); + const controller = createHardwareKeyboardSubmitController(keyboard); + let calls = 0; + controller.setOnSubmit(() => { + calls += 1; + }); + + keyboard.emit(); + + expect(calls).toBe(0); + expect(keyboard.listenerCount).toBe(0); + expect(keyboard.isEnabled).toBe(false); + }); + + it("disables native hardware submit and unsubscribes on disable", () => { + const keyboard = createFakeKeyboard(); + const controller = createHardwareKeyboardSubmitController(keyboard); + controller.setOnSubmit(() => {}); + + controller.enable(); + expect(keyboard.isEnabled).toBe(true); + expect(keyboard.listenerCount).toBe(1); + + controller.disable(); + expect(keyboard.isEnabled).toBe(false); + expect(keyboard.listenerCount).toBe(0); + }); + + it("dispatches the latest onSubmit handler", () => { + const keyboard = createFakeKeyboard(); + const controller = createHardwareKeyboardSubmitController(keyboard); + const received: string[] = []; + controller.setOnSubmit(() => received.push("first")); + + controller.enable(); + controller.setOnSubmit(() => received.push("second")); + keyboard.emit(); + + expect(received).toEqual(["second"]); + }); + + it("does not dispatch after disable", () => { + const keyboard = createFakeKeyboard(); + const controller = createHardwareKeyboardSubmitController(keyboard); + let calls = 0; + controller.setOnSubmit(() => { + calls += 1; + }); + + controller.enable(); + controller.disable(); + keyboard.emit(); + + expect(calls).toBe(0); + }); + + it("ignores repeated enable calls", () => { + const keyboard = createFakeKeyboard(); + const controller = createHardwareKeyboardSubmitController(keyboard); + let calls = 0; + controller.setOnSubmit(() => { + calls += 1; + }); + + controller.enable(); + controller.enable(); + keyboard.emit(); + + expect(calls).toBe(1); + expect(keyboard.listenerCount).toBe(1); + }); + + it("ignores disable without a prior enable", () => { + const keyboard = createFakeKeyboard(); + const controller = createHardwareKeyboardSubmitController(keyboard); + + controller.disable(); + + expect(keyboard.isEnabled).toBe(false); + }); +}); diff --git a/packages/app/src/hooks/hardware-keyboard-submit-controller.ts b/packages/app/src/hooks/hardware-keyboard-submit-controller.ts new file mode 100644 index 000000000..0854f858b --- /dev/null +++ b/packages/app/src/hooks/hardware-keyboard-submit-controller.ts @@ -0,0 +1,34 @@ +export interface HardwareKeyboardSubmitListenerPort { + addListener(handler: () => void): { remove: () => void }; + setEnabled(enabled: boolean): void; +} + +export interface HardwareKeyboardSubmitController { + setOnSubmit(handler: () => void): void; + enable(): void; + disable(): void; +} + +export function createHardwareKeyboardSubmitController( + port: HardwareKeyboardSubmitListenerPort, +): HardwareKeyboardSubmitController { + let subscription: { remove: () => void } | null = null; + let onSubmit: () => void = () => {}; + + return { + setOnSubmit(handler) { + onSubmit = handler; + }, + enable() { + if (subscription) return; + subscription = port.addListener(() => onSubmit()); + port.setEnabled(true); + }, + disable() { + if (!subscription) return; + port.setEnabled(false); + subscription.remove(); + subscription = null; + }, + }; +} diff --git a/packages/app/src/hooks/hover-safe-zone-tracker.test.ts b/packages/app/src/hooks/hover-safe-zone-tracker.test.ts new file mode 100644 index 000000000..b4fdc3df0 --- /dev/null +++ b/packages/app/src/hooks/hover-safe-zone-tracker.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; +import { createHoverSafeZoneTracker, type RectLike } from "@/hooks/hover-safe-zone-tracker"; + +const TRIGGER: RectLike = { left: 0, right: 100, top: 20, bottom: 60 }; +const CONTENT: RectLike = { left: 120, right: 240, top: 20, bottom: 120 }; + +interface TrackerHandle { + pointerMoved(x: number, y: number): void; + pointerLeftWindow(): void; + windowBlurred(): void; + readonly enters: number; + readonly leaves: number; +} + +function createHandle( + rects: { trigger: RectLike | null; content: RectLike | null } = { + trigger: TRIGGER, + content: CONTENT, + }, +): TrackerHandle { + let enters = 0; + let leaves = 0; + const tracker = createHoverSafeZoneTracker({ + getTriggerRect: () => rects.trigger, + getContentRect: () => rects.content, + onEnterSafeZone: () => { + enters += 1; + }, + onLeaveSafeZone: () => { + leaves += 1; + }, + }); + return { + pointerMoved: tracker.pointerMoved, + pointerLeftWindow: tracker.pointerLeftWindow, + windowBlurred: tracker.windowBlurred, + get enters() { + return enters; + }, + get leaves() { + return leaves; + }, + }; +} + +describe("hover safe-zone tracker", () => { + it("tracks transitions across trigger, bridge, content, and outside space", () => { + const handle = createHandle(); + + // Bridge between trigger and content. + handle.pointerMoved(110, 40); + expect(handle.enters).toBe(1); + expect(handle.leaves).toBe(0); + + // Outside everything — fires leave once. + handle.pointerMoved(300, 40); + expect(handle.leaves).toBe(1); + + // Back into the bridge — fires enter again. + handle.pointerMoved(130, 40); + expect(handle.enters).toBe(2); + }); + + it("refreshes the safe-zone enter callback while moving inside", () => { + const handle = createHandle(); + + handle.pointerMoved(110, 40); + handle.pointerMoved(130, 40); + + expect(handle.enters).toBe(2); + expect(handle.leaves).toBe(0); + }); + + it("treats leaving the browser window as leaving the safe zone", () => { + const handle = createHandle(); + + handle.pointerLeftWindow(); + expect(handle.leaves).toBe(1); + + // Already outside — blur does not fire a second leave. + handle.windowBlurred(); + expect(handle.leaves).toBe(1); + }); + + it("falls back to trigger-or-content membership when a rect is missing", () => { + const handle = createHandle({ trigger: TRIGGER, content: null }); + + // Inside the trigger. + handle.pointerMoved(50, 40); + expect(handle.enters).toBe(1); + + // Inside the (now-missing) bridge — counts as outside. + handle.pointerMoved(110, 40); + expect(handle.leaves).toBe(1); + }); + + it("treats overlapping trigger and content as having no bridge", () => { + const handle = createHandle({ + trigger: { left: 0, right: 200, top: 0, bottom: 50 }, + content: { left: 100, right: 300, top: 60, bottom: 100 }, + }); + + // Outside both rects, in what would be a bridge — should be outside. + handle.pointerMoved(150, 55); + expect(handle.enters).toBe(0); + expect(handle.leaves).toBe(1); + }); +}); diff --git a/packages/app/src/hooks/hover-safe-zone-tracker.ts b/packages/app/src/hooks/hover-safe-zone-tracker.ts new file mode 100644 index 000000000..d097413c5 --- /dev/null +++ b/packages/app/src/hooks/hover-safe-zone-tracker.ts @@ -0,0 +1,74 @@ +export interface RectLike { + left: number; + right: number; + top: number; + bottom: number; +} + +export interface HoverSafeZoneTrackerInput { + getTriggerRect: () => RectLike | null; + getContentRect: () => RectLike | null; + onEnterSafeZone: () => void; + onLeaveSafeZone: () => void; +} + +export interface HoverSafeZoneTracker { + pointerMoved(x: number, y: number): void; + pointerLeftWindow(): void; + windowBlurred(): void; +} + +// Tracks the pointer's position relative to a hover card's "safe zone": the +// trigger, the content, and the rectangular bridge between them. The bridge +// lets the pointer cross the visual gap without dropping the hover. Fires +// `onEnterSafeZone` on every move that lands inside (so consumers can refresh +// timers) and `onLeaveSafeZone` once per inside→outside transition. +export function createHoverSafeZoneTracker(input: HoverSafeZoneTrackerInput): HoverSafeZoneTracker { + const { getTriggerRect, getContentRect, onEnterSafeZone, onLeaveSafeZone } = input; + // The pointer opened the card, so we start inside. + let wasInside = true; + + function leave(): void { + if (!wasInside) return; + wasInside = false; + onLeaveSafeZone(); + } + + return { + pointerMoved(x, y) { + if (isInsideSafeZone(getTriggerRect(), getContentRect(), x, y)) { + wasInside = true; + onEnterSafeZone(); + return; + } + leave(); + }, + pointerLeftWindow: leave, + windowBlurred: leave, + }; +} + +function isInsideRect(rect: RectLike | null, x: number, y: number): boolean { + if (!rect) return false; + return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; +} + +function isInsideSafeZone( + trigger: RectLike | null, + content: RectLike | null, + x: number, + y: number, +): boolean { + if (isInsideRect(trigger, x, y)) return true; + if (isInsideRect(content, x, y)) return true; + if (!trigger || !content) return false; + + // Bridge: the horizontal strip connecting trigger and content, stretched + // vertically to span both. If they overlap horizontally there's no bridge. + const bridgeLeft = Math.min(trigger.right, content.right); + const bridgeRight = Math.max(trigger.left, content.left); + if (bridgeLeft >= bridgeRight) return false; + const bridgeTop = Math.min(trigger.top, content.top); + const bridgeBottom = Math.max(trigger.bottom, content.bottom); + return x >= bridgeLeft && x <= bridgeRight && y >= bridgeTop && y <= bridgeBottom; +} diff --git a/packages/app/src/hooks/image-attachment-picker.native.test.ts b/packages/app/src/hooks/image-attachment-picker.native.test.ts deleted file mode 100644 index fd1fa52a1..000000000 --- a/packages/app/src/hooks/image-attachment-picker.native.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -vi.mock("expo-image-manipulator", () => ({ - SaveFormat: { PNG: "png" }, - ImageManipulator: { - manipulate: (_source: string) => ({ - async renderAsync() { - return { - release() {}, - async saveAsync(options: { format?: string }) { - return { - uri: - options.format === "png" - ? "file:///cache/ImageManipulator/safe-picked.png" - : "file:///cache/ImageManipulator/unsafe-picked.jpg", - width: 100, - height: 100, - }; - }, - }; - }, - release() {}, - }), - }, -})); - -import { normalizePickedImageAssets } from "./image-attachment-picker.native"; - -describe("native image attachment picker", () => { - it("preserves native picked JPEG and PNG attachment inputs", async () => { - const result = await normalizePickedImageAssets([ - { - uri: "file:///photos/IMG_0001.JPG", - mimeType: "image/jpeg", - fileName: "picked.jpeg", - }, - { - uri: "file:///photos/screenshot.png", - mimeType: "image/png", - fileName: "screenshot.png", - }, - ]); - - expect(result).toEqual([ - { - source: { kind: "file_uri", uri: "file:///photos/IMG_0001.JPG" }, - mimeType: "image/jpeg", - fileName: "picked.jpg", - }, - { - source: { kind: "file_uri", uri: "file:///photos/screenshot.png" }, - mimeType: "image/png", - fileName: "screenshot.png", - }, - ]); - }); - - it("turns a native picked HEIC-like asset into a PNG attachment input", async () => { - const result = await normalizePickedImageAssets([ - { - uri: "file:///photos/IMG_0001.HEIC", - mimeType: "image/png", - fileName: "picked.png", - }, - ]); - - expect(result).toEqual([ - { - source: { kind: "file_uri", uri: "file:///cache/ImageManipulator/safe-picked.png" }, - mimeType: "image/png", - fileName: "picked.png", - }, - ]); - }); -}); diff --git a/packages/app/src/hooks/image-attachment-picker.native.ts b/packages/app/src/hooks/image-attachment-picker.native.ts index 23d9bb351..dfb36c575 100644 --- a/packages/app/src/hooks/image-attachment-picker.native.ts +++ b/packages/app/src/hooks/image-attachment-picker.native.ts @@ -1,87 +1,16 @@ import { ImageManipulator, SaveFormat } from "expo-image-manipulator"; +import { + normalizePickedImageAssetsWith, + type ExpoImagePickerAssetLike, + type PickedImageAttachmentInput, +} from "./picked-image-normalizer"; -export type PickedImageSource = { kind: "file_uri"; uri: string } | { kind: "blob"; blob: Blob }; - -export interface PickedImageAttachmentInput { - source: PickedImageSource; - mimeType?: string | null; - fileName?: string | null; -} - -export interface ExpoImagePickerAssetLike { - uri: string; - mimeType?: string | null; - fileName?: string | null; - file?: File | null; -} - -interface SupportedPickedImageFormat { - mimeType: "image/jpeg" | "image/png"; - extension: "jpg" | "png"; -} - -const JPEG_FORMAT: SupportedPickedImageFormat = { - mimeType: "image/jpeg", - extension: "jpg", -}; - -const PNG_FORMAT: SupportedPickedImageFormat = { - mimeType: "image/png", - extension: "png", -}; - -function extensionFromPath(path: string | null | undefined): string | null { - const match = path?.match(/\.([a-z0-9]+)(?:[?#].*)?$/i); - return match?.[1]?.toLowerCase() ?? null; -} - -function supportedFormatForExtension(extension: string | null): SupportedPickedImageFormat | null { - if (extension === "jpg" || extension === "jpeg") { - return JPEG_FORMAT; - } - if (extension === "png") { - return PNG_FORMAT; - } - return null; -} - -function supportedFormatForMimeType( - mimeType: string | null | undefined, -): SupportedPickedImageFormat | null { - const normalizedMimeType = mimeType?.toLowerCase(); - if (normalizedMimeType === "image/jpeg" || normalizedMimeType === "image/jpg") { - return JPEG_FORMAT; - } - if (normalizedMimeType === "image/png") { - return PNG_FORMAT; - } - return null; -} - -function pickedAssetSupportedFormat( - asset: ExpoImagePickerAssetLike, -): SupportedPickedImageFormat | null { - const uriExtension = extensionFromPath(asset.uri); - if (uriExtension) { - return supportedFormatForExtension(uriExtension); - } - - return ( - supportedFormatForExtension(extensionFromPath(asset.fileName)) ?? - supportedFormatForMimeType(asset.mimeType) - ); -} - -function replaceFileExtension( - fileName: string | null | undefined, - extension: SupportedPickedImageFormat["extension"], -): string | null { - if (!fileName) { - return null; - } - - return fileName.replace(/\.[^./\\]+$/, "") + `.${extension}`; -} +export type { + ExportPickedImageAsPng, + ExpoImagePickerAssetLike, + PickedImageAttachmentInput, + PickedImageSource, +} from "./picked-image-normalizer"; async function exportPickedImageAsPng(uri: string): Promise { const context = ImageManipulator.manipulate(uri); @@ -102,28 +31,9 @@ async function exportPickedImageAsPng(uri: string): Promise { export async function normalizePickedImageAssets( assets: readonly ExpoImagePickerAssetLike[], ): Promise { - return await Promise.all( - assets.map(async (asset) => { - const supportedFormat = pickedAssetSupportedFormat(asset); - if (supportedFormat) { - return { - source: { kind: "file_uri", uri: asset.uri }, - mimeType: supportedFormat.mimeType, - fileName: replaceFileExtension(asset.fileName, supportedFormat.extension), - }; - } - - const convertedUri = await exportPickedImageAsPng(asset.uri); - - return { - source: { kind: "file_uri", uri: convertedUri }, - mimeType: PNG_FORMAT.mimeType, - fileName: replaceFileExtension(asset.fileName, PNG_FORMAT.extension), - }; - }), - ); + return normalizePickedImageAssetsWith(assets, exportPickedImageAsPng); } -export async function openImagePathsWithDesktopDialog(): Promise { +export async function openImagePathsWithDesktopDialog(_dialog?: unknown): Promise { throw new Error("Desktop dialog API is not available on native."); } diff --git a/packages/app/src/hooks/image-attachment-picker.test.ts b/packages/app/src/hooks/image-attachment-picker.test.ts index 3ae194dc0..821312db2 100644 --- a/packages/app/src/hooks/image-attachment-picker.test.ts +++ b/packages/app/src/hooks/image-attachment-picker.test.ts @@ -1,23 +1,29 @@ -import { describe, expect, it, vi, beforeEach } from "vitest"; - -const desktopHostState = vi.hoisted(() => ({ - api: null as unknown, -})); - -vi.mock("@/desktop/host", () => ({ - getDesktopHost: () => desktopHostState.api, -})); - +import { describe, expect, it } from "vitest"; +import type { DesktopDialogBridge, DesktopDialogOpenOptions } from "@/desktop/host"; import { normalizePickedImageAssets, openImagePathsWithDesktopDialog, } from "./image-attachment-picker"; -describe("image-attachment-picker", () => { - beforeEach(() => { - desktopHostState.api = null; - }); +function fakeDialogReturning(selection: string | string[] | null): { + dialog: DesktopDialogBridge; + recordedOptions: DesktopDialogOpenOptions[]; +} { + const recordedOptions: DesktopDialogOpenOptions[] = []; + return { + dialog: { + open: async (options?: DesktopDialogOpenOptions) => { + if (options) { + recordedOptions.push(options); + } + return selection; + }, + }, + recordedOptions, + }; +} +describe("image-attachment-picker", () => { it("normalizes a picked File into a blob source", async () => { const file = new File(["hello"], "picked.png", { type: "image/png" }); @@ -70,27 +76,24 @@ describe("image-attachment-picker", () => { }); it("uses the desktop dialog api when available", async () => { - const open = vi.fn().mockResolvedValue(["/tmp/one.png", "/tmp/two.jpg"]); - desktopHostState.api = { - dialog: { open }, - }; + const { dialog, recordedOptions } = fakeDialogReturning(["/tmp/one.png", "/tmp/two.jpg"]); - const result = await openImagePathsWithDesktopDialog(); + const result = await openImagePathsWithDesktopDialog(dialog); - expect(open).toHaveBeenCalledWith( - expect.objectContaining({ - multiple: true, - directory: false, - title: "Attach images", - }), - ); + expect(recordedOptions).toHaveLength(1); + expect(recordedOptions[0]).toMatchObject({ + multiple: true, + directory: false, + title: "Attach images", + }); expect(result).toEqual(["/tmp/one.png", "/tmp/two.jpg"]); }); it("throws when desktop dialog API is not available", async () => { - desktopHostState.api = {}; - - await expect(openImagePathsWithDesktopDialog()).rejects.toThrow( + await expect(openImagePathsWithDesktopDialog(null)).rejects.toThrow( + "Desktop dialog API is not available.", + ); + await expect(openImagePathsWithDesktopDialog({})).rejects.toThrow( "Desktop dialog API is not available.", ); }); diff --git a/packages/app/src/hooks/image-attachment-picker.ts b/packages/app/src/hooks/image-attachment-picker.ts index e11d3111c..b3478ae54 100644 --- a/packages/app/src/hooks/image-attachment-picker.ts +++ b/packages/app/src/hooks/image-attachment-picker.ts @@ -1,4 +1,4 @@ -import { getDesktopHost } from "@/desktop/host"; +import type { DesktopDialogBridge } from "@/desktop/host"; import { isAbsolutePath } from "@/utils/path"; export type PickedImageSource = { kind: "file_uri"; uri: string } | { kind: "blob"; blob: Blob }; @@ -79,8 +79,9 @@ function normalizeDesktopDialogSelection(selection: string | string[] | null): s return Array.isArray(selection) ? selection : [selection]; } -export async function openImagePathsWithDesktopDialog(): Promise { - const desktop = getDesktopHost(); +export async function openImagePathsWithDesktopDialog( + dialog: DesktopDialogBridge | null | undefined, +): Promise { const options = { directory: false, multiple: true, @@ -88,7 +89,7 @@ export async function openImagePathsWithDesktopDialog(): Promise { title: "Attach images", }; - const dialogOpen = desktop?.dialog?.open; + const dialogOpen = dialog?.open; if (typeof dialogOpen !== "function") { throw new Error("Desktop dialog API is not available."); } diff --git a/packages/app/src/hooks/open-project.ts b/packages/app/src/hooks/open-project.ts new file mode 100644 index 000000000..bc0dbd585 --- /dev/null +++ b/packages/app/src/hooks/open-project.ts @@ -0,0 +1,43 @@ +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { normalizeWorkspaceDescriptor, type WorkspaceDescriptor } from "@/stores/session-store"; +import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store"; + +export interface OpenProjectDirectlyInput { + serverId: string; + projectPath: string; + isConnected: boolean; + client: Pick | null; + mergeWorkspaces: (serverId: string, workspaces: Iterable) => void; + setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void; + openDraftTab: (workspaceKey: string) => string | null; + navigateToWorkspace: (serverId: string, workspaceId: string) => void; +} + +export async function openProjectDirectly(input: OpenProjectDirectlyInput): Promise { + const normalizedServerId = input.serverId.trim(); + const trimmedPath = input.projectPath.trim(); + if (!normalizedServerId || !trimmedPath || !input.client || !input.isConnected) { + return false; + } + + const payload = await input.client.openProject(trimmedPath); + if (payload.error || !payload.workspace) { + return false; + } + + const workspace = normalizeWorkspaceDescriptor(payload.workspace); + input.mergeWorkspaces(normalizedServerId, [workspace]); + input.setHasHydratedWorkspaces(normalizedServerId, true); + + const workspaceKey = buildWorkspaceTabPersistenceKey({ + serverId: normalizedServerId, + workspaceId: workspace.id, + }); + if (!workspaceKey) { + return false; + } + + input.openDraftTab(workspaceKey); + input.navigateToWorkspace(normalizedServerId, workspace.id); + return true; +} diff --git a/packages/app/src/hooks/picked-image-normalizer.test.ts b/packages/app/src/hooks/picked-image-normalizer.test.ts new file mode 100644 index 000000000..a00144af2 --- /dev/null +++ b/packages/app/src/hooks/picked-image-normalizer.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { + normalizePickedImageAssetsWith, + type ExportPickedImageAsPng, +} from "./picked-image-normalizer"; + +function fakeExportAsPng(): { + exportAsPng: ExportPickedImageAsPng; + recordedUris: string[]; +} { + const recordedUris: string[] = []; + return { + exportAsPng: async (uri: string) => { + recordedUris.push(uri); + return "file:///cache/ImageManipulator/safe-picked.png"; + }, + recordedUris, + }; +} + +describe("native image attachment picker", () => { + it("preserves native picked JPEG and PNG attachment inputs", async () => { + const { exportAsPng, recordedUris } = fakeExportAsPng(); + + const result = await normalizePickedImageAssetsWith( + [ + { + uri: "file:///photos/IMG_0001.JPG", + mimeType: "image/jpeg", + fileName: "picked.jpeg", + }, + { + uri: "file:///photos/screenshot.png", + mimeType: "image/png", + fileName: "screenshot.png", + }, + ], + exportAsPng, + ); + + expect(result).toEqual([ + { + source: { kind: "file_uri", uri: "file:///photos/IMG_0001.JPG" }, + mimeType: "image/jpeg", + fileName: "picked.jpg", + }, + { + source: { kind: "file_uri", uri: "file:///photos/screenshot.png" }, + mimeType: "image/png", + fileName: "screenshot.png", + }, + ]); + expect(recordedUris).toEqual([]); + }); + + it("turns a native picked HEIC-like asset into a PNG attachment input", async () => { + const { exportAsPng, recordedUris } = fakeExportAsPng(); + + const result = await normalizePickedImageAssetsWith( + [ + { + uri: "file:///photos/IMG_0001.HEIC", + mimeType: "image/png", + fileName: "picked.png", + }, + ], + exportAsPng, + ); + + expect(result).toEqual([ + { + source: { kind: "file_uri", uri: "file:///cache/ImageManipulator/safe-picked.png" }, + mimeType: "image/png", + fileName: "picked.png", + }, + ]); + expect(recordedUris).toEqual(["file:///photos/IMG_0001.HEIC"]); + }); +}); diff --git a/packages/app/src/hooks/picked-image-normalizer.ts b/packages/app/src/hooks/picked-image-normalizer.ts new file mode 100644 index 000000000..a662d10ff --- /dev/null +++ b/packages/app/src/hooks/picked-image-normalizer.ts @@ -0,0 +1,110 @@ +export type PickedImageSource = { kind: "file_uri"; uri: string } | { kind: "blob"; blob: Blob }; + +export interface PickedImageAttachmentInput { + source: PickedImageSource; + mimeType?: string | null; + fileName?: string | null; +} + +export interface ExpoImagePickerAssetLike { + uri: string; + mimeType?: string | null; + fileName?: string | null; + file?: File | null; +} + +export type ExportPickedImageAsPng = (uri: string) => Promise; + +interface SupportedPickedImageFormat { + mimeType: "image/jpeg" | "image/png"; + extension: "jpg" | "png"; +} + +const JPEG_FORMAT: SupportedPickedImageFormat = { + mimeType: "image/jpeg", + extension: "jpg", +}; + +const PNG_FORMAT: SupportedPickedImageFormat = { + mimeType: "image/png", + extension: "png", +}; + +function extensionFromPath(path: string | null | undefined): string | null { + const match = path?.match(/\.([a-z0-9]+)(?:[?#].*)?$/i); + return match?.[1]?.toLowerCase() ?? null; +} + +function supportedFormatForExtension(extension: string | null): SupportedPickedImageFormat | null { + if (extension === "jpg" || extension === "jpeg") { + return JPEG_FORMAT; + } + if (extension === "png") { + return PNG_FORMAT; + } + return null; +} + +function supportedFormatForMimeType( + mimeType: string | null | undefined, +): SupportedPickedImageFormat | null { + const normalizedMimeType = mimeType?.toLowerCase(); + if (normalizedMimeType === "image/jpeg" || normalizedMimeType === "image/jpg") { + return JPEG_FORMAT; + } + if (normalizedMimeType === "image/png") { + return PNG_FORMAT; + } + return null; +} + +function pickedAssetSupportedFormat( + asset: ExpoImagePickerAssetLike, +): SupportedPickedImageFormat | null { + const uriExtension = extensionFromPath(asset.uri); + if (uriExtension) { + return supportedFormatForExtension(uriExtension); + } + + return ( + supportedFormatForExtension(extensionFromPath(asset.fileName)) ?? + supportedFormatForMimeType(asset.mimeType) + ); +} + +function replaceFileExtension( + fileName: string | null | undefined, + extension: SupportedPickedImageFormat["extension"], +): string | null { + if (!fileName) { + return null; + } + + return fileName.replace(/\.[^./\\]+$/, "") + `.${extension}`; +} + +export async function normalizePickedImageAssetsWith( + assets: readonly ExpoImagePickerAssetLike[], + exportAsPng: ExportPickedImageAsPng, +): Promise { + return await Promise.all( + assets.map(async (asset) => { + const supportedFormat = pickedAssetSupportedFormat(asset); + if (supportedFormat) { + return { + source: { kind: "file_uri", uri: asset.uri }, + mimeType: supportedFormat.mimeType, + fileName: replaceFileExtension(asset.fileName, supportedFormat.extension), + }; + } + + const convertedUri = await exportAsPng(asset.uri); + + return { + source: { kind: "file_uri", uri: convertedUri }, + mimeType: PNG_FORMAT.mimeType, + fileName: replaceFileExtension(asset.fileName, PNG_FORMAT.extension), + }; + }), + ); +} diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts new file mode 100644 index 000000000..1963e0dd4 --- /dev/null +++ b/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts @@ -0,0 +1,248 @@ +import { describe, expect, it } from "vitest"; +import type { WorkspaceStructureProject } from "@/stores/session-store-hooks"; +import { + appendMissingOrderKeys, + applyStoredOrdering, + buildSidebarProjectsFromStructure, + computeSidebarOrderUpdates, + deriveSidebarLoadingState, + type SidebarProjectEntry, +} from "./sidebar-workspaces-view-model"; + +interface OrderedItem { + key: string; +} + +function item(key: string): OrderedItem { + return { key }; +} + +function project(input: { + projectKey: string; + projectName?: string; + projectKind?: WorkspaceStructureProject["projectKind"]; + iconWorkingDir?: string; + workspaceKeys: string[]; +}): WorkspaceStructureProject { + return { + projectKey: input.projectKey, + projectName: input.projectName ?? input.projectKey, + projectKind: input.projectKind ?? "git", + iconWorkingDir: input.iconWorkingDir ?? input.projectKey, + workspaceKeys: input.workspaceKeys, + }; +} + +function sidebarProject(input: { + projectKey: string; + workspaceKeys: string[]; + serverId?: string; +}): SidebarProjectEntry { + const projects = buildSidebarProjectsFromStructure({ + serverId: input.serverId ?? "srv", + projects: [project({ projectKey: input.projectKey, workspaceKeys: input.workspaceKeys })], + }); + const result = projects[0]; + if (!result) { + throw new Error("expected a project entry"); + } + return result; +} + +describe("applyStoredOrdering", () => { + it("keeps unknown items on the baseline while applying stored order", () => { + const result = applyStoredOrdering({ + items: [item("new"), item("a"), item("b")], + storedOrder: ["b", "a"], + getKey: (entry) => entry.key, + }); + + expect(result.map((entry) => entry.key)).toEqual(["new", "b", "a"]); + }); + + it("ignores stale and duplicate stored keys", () => { + const result = applyStoredOrdering({ + items: [item("x"), item("y")], + storedOrder: ["missing", "y", "y", "x"], + getKey: (entry) => entry.key, + }); + + expect(result.map((entry) => entry.key)).toEqual(["y", "x"]); + }); + + it("returns baseline when there is no persisted order", () => { + const baseline = [item("first"), item("second")]; + const result = applyStoredOrdering({ + items: baseline, + storedOrder: [], + getKey: (entry) => entry.key, + }); + + expect(result).toBe(baseline); + }); +}); + +describe("appendMissingOrderKeys", () => { + it("appends unseen keys while preserving existing order", () => { + const result = appendMissingOrderKeys({ + currentOrder: ["project-b", "project-a"], + visibleKeys: ["project-a", "project-b", "project-c"], + }); + + expect(result).toEqual(["project-b", "project-a", "project-c"]); + }); + + it("returns the same array when there are no unseen keys", () => { + const currentOrder = ["project-a", "project-b"]; + + const result = appendMissingOrderKeys({ + currentOrder, + visibleKeys: ["project-b", "project-a"], + }); + + expect(result).toBe(currentOrder); + }); +}); + +describe("buildSidebarProjectsFromStructure", () => { + it("creates structural workspace rows from ordered workspace keys", () => { + const projects = buildSidebarProjectsFromStructure({ + serverId: "srv", + projects: [ + project({ + projectKey: "project-1", + projectName: "Project 1", + iconWorkingDir: "/repo/main", + workspaceKeys: ["ws-main"], + }), + ], + }); + + expect(projects).toHaveLength(1); + expect(projects[0]?.projectName).toBe("Project 1"); + expect(projects[0]?.workspaces[0]).toMatchObject({ + workspaceKey: "srv:ws-main", + serverId: "srv", + workspaceId: "ws-main", + projectRootPath: "/repo/main", + projectKind: "git", + }); + }); + + it("preserves the structure hook project order", () => { + const projects = buildSidebarProjectsFromStructure({ + serverId: "srv", + projects: [ + project({ projectKey: "project-b", workspaceKeys: ["ws-b"] }), + project({ projectKey: "project-a", workspaceKeys: ["ws-a"] }), + ], + }); + + expect(projects.map((entry) => entry.projectKey)).toEqual(["project-b", "project-a"]); + }); + + it("preserves the structure hook workspace order", () => { + const projects = buildSidebarProjectsFromStructure({ + serverId: "srv", + projects: [project({ projectKey: "project-1", workspaceKeys: ["feature", "main"] })], + }); + + expect(projects[0]?.workspaces.map((workspace) => workspace.workspaceId)).toEqual([ + "feature", + "main", + ]); + }); +}); + +describe("computeSidebarOrderUpdates", () => { + it("returns no updates when there are no visible projects", () => { + const updates = computeSidebarOrderUpdates({ + projects: [], + persistedProjectOrder: ["stale-project"], + getWorkspaceOrder: () => [], + }); + + expect(updates).toEqual({ projectOrder: null, workspaceOrders: [] }); + }); + + it("appends unseen projects and workspaces to the persisted orders", () => { + const projects = [ + sidebarProject({ projectKey: "project-a", workspaceKeys: ["ws-1", "ws-2"] }), + sidebarProject({ projectKey: "project-b", workspaceKeys: ["ws-3"] }), + ]; + + const updates = computeSidebarOrderUpdates({ + projects, + persistedProjectOrder: ["project-a"], + getWorkspaceOrder: (projectKey) => (projectKey === "project-a" ? ["srv:ws-1"] : []), + }); + + expect(updates.projectOrder).toEqual(["project-a", "project-b"]); + expect(updates.workspaceOrders).toEqual([ + { projectKey: "project-a", order: ["srv:ws-1", "srv:ws-2"] }, + { projectKey: "project-b", order: ["srv:ws-3"] }, + ]); + }); + + it("returns no project-order update when persisted order already covers visible keys", () => { + const projects = [ + sidebarProject({ projectKey: "project-a", workspaceKeys: ["ws-1"] }), + sidebarProject({ projectKey: "project-b", workspaceKeys: ["ws-2"] }), + ]; + + const updates = computeSidebarOrderUpdates({ + projects, + persistedProjectOrder: ["project-b", "project-a"], + getWorkspaceOrder: (projectKey) => (projectKey === "project-a" ? ["srv:ws-1"] : ["srv:ws-2"]), + }); + + expect(updates.projectOrder).toBeNull(); + expect(updates.workspaceOrders).toEqual([]); + }); +}); + +describe("deriveSidebarLoadingState", () => { + it("reports initial-load while active and unhydrated with no projects", () => { + expect( + deriveSidebarLoadingState({ + isActive: true, + serverId: "srv", + hasHydratedWorkspaces: false, + hasProjects: false, + }), + ).toEqual({ isLoading: true, isInitialLoad: true, isRevalidating: false }); + }); + + it("stays loading but not initial once projects are visible", () => { + expect( + deriveSidebarLoadingState({ + isActive: true, + serverId: "srv", + hasHydratedWorkspaces: false, + hasProjects: true, + }), + ).toEqual({ isLoading: true, isInitialLoad: false, isRevalidating: false }); + }); + + it("clears loading once workspaces have hydrated", () => { + expect( + deriveSidebarLoadingState({ + isActive: true, + serverId: "srv", + hasHydratedWorkspaces: true, + hasProjects: true, + }), + ).toEqual({ isLoading: false, isInitialLoad: false, isRevalidating: false }); + }); + + it("short-circuits to idle when inactive", () => { + expect( + deriveSidebarLoadingState({ + isActive: false, + serverId: "srv", + hasHydratedWorkspaces: false, + hasProjects: false, + }), + ).toEqual({ isLoading: false, isInitialLoad: false, isRevalidating: false }); + }); +}); diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.ts new file mode 100644 index 000000000..62f547958 --- /dev/null +++ b/packages/app/src/hooks/sidebar-workspaces-view-model.ts @@ -0,0 +1,200 @@ +import type { PrHint } from "@/git/use-pr-status-query"; +import type { WorkspaceDescriptor } from "@/stores/session-store"; +import type { WorkspaceStructureProject } from "@/stores/session-store-hooks"; + +const EMPTY_PROJECTS: SidebarProjectEntry[] = []; + +export type SidebarStateBucket = WorkspaceDescriptor["status"]; + +export interface SidebarWorkspaceEntry { + workspaceKey: string; + serverId: string; + workspaceId: string; + projectKey: string; + projectRootPath?: string; + workspaceDirectory?: string; + projectKind: WorkspaceDescriptor["projectKind"]; + workspaceKind: WorkspaceDescriptor["workspaceKind"]; + name: string; + statusBucket: SidebarStateBucket; + archivingAt: string | null; + diffStat: { additions: number; deletions: number } | null; + prHint: PrHint | null; + archiveHasUncommittedChanges: boolean | null; + archiveUnpushedCommitCount: number | null; + scripts: WorkspaceDescriptor["scripts"]; + hasRunningScripts: boolean; +} + +export interface SidebarProjectEntry { + projectKey: string; + projectName: string; + projectKind: WorkspaceDescriptor["projectKind"]; + iconWorkingDir: string; + workspaces: SidebarWorkspaceEntry[]; +} + +function createStructuralWorkspaceEntry(input: { + serverId: string; + project: WorkspaceStructureProject; + workspaceId: string; +}): SidebarWorkspaceEntry { + return { + workspaceKey: `${input.serverId}:${input.workspaceId}`, + serverId: input.serverId, + workspaceId: input.workspaceId, + projectKey: input.project.projectKey, + projectRootPath: input.project.iconWorkingDir, + workspaceDirectory: undefined, + projectKind: input.project.projectKind, + workspaceKind: "checkout", + name: input.workspaceId, + statusBucket: "done", + archivingAt: null, + diffStat: null, + prHint: null, + archiveHasUncommittedChanges: null, + archiveUnpushedCommitCount: null, + scripts: [], + hasRunningScripts: false, + }; +} + +export function buildSidebarProjectsFromStructure(input: { + serverId: string; + projects: WorkspaceStructureProject[]; +}): SidebarProjectEntry[] { + if (input.projects.length === 0) { + return EMPTY_PROJECTS; + } + + return input.projects.map((project) => ({ + projectKey: project.projectKey, + projectName: project.projectName, + projectKind: project.projectKind, + iconWorkingDir: project.iconWorkingDir, + workspaces: project.workspaceKeys.map((workspaceId) => + createStructuralWorkspaceEntry({ + serverId: input.serverId, + project, + workspaceId, + }), + ), + })); +} + +export function applyStoredOrdering(input: { + items: T[]; + storedOrder: string[]; + getKey: (item: T) => string; +}): T[] { + if (input.items.length <= 1 || input.storedOrder.length === 0) { + return input.items; + } + + const itemByKey = new Map(); + for (const item of input.items) { + itemByKey.set(input.getKey(item), item); + } + + const prunedOrder: string[] = []; + const seen = new Set(); + for (const key of input.storedOrder) { + if (!itemByKey.has(key) || seen.has(key)) { + continue; + } + seen.add(key); + prunedOrder.push(key); + } + + if (prunedOrder.length === 0) { + return input.items; + } + + const orderedSet = new Set(prunedOrder); + const ordered: T[] = []; + let orderedIndex = 0; + + for (const item of input.items) { + const key = input.getKey(item); + if (!orderedSet.has(key)) { + ordered.push(item); + continue; + } + + const targetKey = prunedOrder[orderedIndex] ?? key; + orderedIndex += 1; + ordered.push(itemByKey.get(targetKey) ?? item); + } + + return ordered; +} + +export function appendMissingOrderKeys(input: { + currentOrder: string[]; + visibleKeys: string[]; +}): string[] { + if (input.visibleKeys.length === 0) { + return input.currentOrder; + } + + const existingKeys = new Set(input.currentOrder); + const missingKeys = input.visibleKeys.filter((key) => !existingKeys.has(key)); + if (missingKeys.length === 0) { + return input.currentOrder; + } + + return [...input.currentOrder, ...missingKeys]; +} + +export interface SidebarOrderUpdates { + projectOrder: string[] | null; + workspaceOrders: Array<{ projectKey: string; order: string[] }>; +} + +export function computeSidebarOrderUpdates(input: { + projects: SidebarProjectEntry[]; + persistedProjectOrder: string[]; + getWorkspaceOrder: (projectKey: string) => string[]; +}): SidebarOrderUpdates { + if (input.projects.length === 0) { + return { projectOrder: null, workspaceOrders: [] }; + } + + const nextProjectOrder = appendMissingOrderKeys({ + currentOrder: input.persistedProjectOrder, + visibleKeys: input.projects.map((project) => project.projectKey), + }); + const projectOrder = nextProjectOrder === input.persistedProjectOrder ? null : nextProjectOrder; + + const workspaceOrders: Array<{ projectKey: string; order: string[] }> = []; + for (const project of input.projects) { + const persistedWorkspaceOrder = input.getWorkspaceOrder(project.projectKey); + const nextWorkspaceOrder = appendMissingOrderKeys({ + currentOrder: persistedWorkspaceOrder, + visibleKeys: project.workspaces.map((workspace) => workspace.workspaceKey), + }); + if (nextWorkspaceOrder !== persistedWorkspaceOrder) { + workspaceOrders.push({ projectKey: project.projectKey, order: nextWorkspaceOrder }); + } + } + + return { projectOrder, workspaceOrders }; +} + +export interface SidebarLoadingState { + isLoading: boolean; + isInitialLoad: boolean; + isRevalidating: boolean; +} + +export function deriveSidebarLoadingState(input: { + isActive: boolean; + serverId: string | null; + hasHydratedWorkspaces: boolean; + hasProjects: boolean; +}): SidebarLoadingState { + const isLoading = input.isActive && Boolean(input.serverId) && !input.hasHydratedWorkspaces; + const isInitialLoad = isLoading && !input.hasProjects; + return { isLoading, isInitialLoad, isRevalidating: false }; +} diff --git a/packages/app/src/hooks/use-agent-commands-query.test.ts b/packages/app/src/hooks/use-agent-commands-query.test.ts index 4bcd288cc..dfff07095 100644 --- a/packages/app/src/hooks/use-agent-commands-query.test.ts +++ b/packages/app/src/hooks/use-agent-commands-query.test.ts @@ -1,79 +1,75 @@ -/** - * @vitest-environment jsdom - */ -import React from "react"; -import { renderHook, waitFor } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { + type AgentCommandsClient, + type DraftCommandConfig, + fetchAgentCommands, +} from "./use-agent-commands-query"; -import { useAgentCommandsQuery } from "./use-agent-commands-query"; +type ListCommands = DaemonClient["listCommands"]; +type ListCommandsResult = Awaited>; -const { mockClient, mockRuntime } = vi.hoisted(() => { - const hoistedClient = { - listCommands: vi.fn(), - }; +interface ListCommandsCall { + agentId: string; + draftConfig: DraftCommandConfig | undefined; +} + +interface FakeAgentCommandsClient extends AgentCommandsClient { + calls: ListCommandsCall[]; +} + +function createClient(response: ListCommandsResult): FakeAgentCommandsClient { + const calls: ListCommandsCall[] = []; return { - mockClient: hoistedClient, - mockRuntime: { - client: hoistedClient, - isConnected: true, - }, + calls, + listCommands: (async ( + agentId: string, + requestIdOrOptions?: string | { draftConfig?: DraftCommandConfig }, + ) => { + const options = + typeof requestIdOrOptions === "object" && requestIdOrOptions !== null + ? requestIdOrOptions + : undefined; + calls.push({ agentId, draftConfig: options?.draftConfig }); + return response; + }) as ListCommands, }; -}); - -vi.mock("@/runtime/host-runtime", () => ({ - useHostRuntimeClient: () => mockRuntime.client, - useHostRuntimeIsConnected: () => mockRuntime.isConnected, -})); - -function createQueryClient(): QueryClient { - return new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, - }); } -function renderCommandsHook(input: Parameters[0]) { - const queryClient = createQueryClient(); - const wrapper = ({ children }: { children: React.ReactNode }) => - React.createElement(QueryClientProvider, { client: queryClient }, children); - - return renderHook(() => useAgentCommandsQuery(input), { wrapper }); +function commandsPayload(commands: ListCommandsResult["commands"]): ListCommandsResult { + return { + requestId: "req_commands", + agentId: "", + error: null, + commands, + }; } -describe("useAgentCommandsQuery", () => { - afterEach(() => { - vi.clearAllMocks(); - mockRuntime.client = mockClient; - mockRuntime.isConnected = true; - }); - +describe("fetchAgentCommands", () => { it("loads commands for a draft composer without an agent id", async () => { - mockClient.listCommands.mockResolvedValue({ - commands: [{ name: "compact", description: "Compact context", argumentHint: "" }], - }); + const client = createClient( + commandsPayload([{ name: "compact", description: "Compact context", argumentHint: "" }]), + ); - const draftConfig = { - provider: "opencode" as const, + const draftConfig: DraftCommandConfig = { + provider: "opencode", cwd: "/repo", modeId: "build", }; - const { result } = renderCommandsHook({ - serverId: "server-1", - agentId: "", - draftConfig, - }); + const commands = await fetchAgentCommands({ client, agentId: "", draftConfig }); - await waitFor(() => { - expect(result.current.commands).toEqual([ - { name: "compact", description: "Compact context", argumentHint: "" }, - ]); - }); + expect(commands).toEqual([ + { name: "compact", description: "Compact context", argumentHint: "" }, + ]); + expect(client.calls).toEqual([{ agentId: "", draftConfig }]); + }); - expect(mockClient.listCommands).toHaveBeenCalledWith("", { draftConfig }); + it("passes the agent id when fetching commands for a running agent", async () => { + const client = createClient(commandsPayload([])); + + await fetchAgentCommands({ client, agentId: "agent-1" }); + + expect(client.calls).toEqual([{ agentId: "agent-1", draftConfig: undefined }]); }); }); diff --git a/packages/app/src/hooks/use-agent-commands-query.ts b/packages/app/src/hooks/use-agent-commands-query.ts index c7b511707..05071d903 100644 --- a/packages/app/src/hooks/use-agent-commands-query.ts +++ b/packages/app/src/hooks/use-agent-commands-query.ts @@ -1,4 +1,5 @@ import { useQuery } from "@tanstack/react-query"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import type { AgentProvider } from "@getpaseo/protocol/agent-types"; @@ -19,7 +20,24 @@ export interface DraftCommandConfig { featureValues?: Record; } -function commandsQueryKey(serverId: string, agentId: string, draftConfig?: DraftCommandConfig) { +export type AgentCommandsClient = Pick; + +export async function fetchAgentCommands(input: { + client: AgentCommandsClient; + agentId: string; + draftConfig?: DraftCommandConfig; +}): Promise { + const response = await input.client.listCommands(input.agentId, { + draftConfig: input.draftConfig, + }); + return response.commands as AgentSlashCommand[]; +} + +function agentCommandsQueryKey( + serverId: string, + agentId: string, + draftConfig?: DraftCommandConfig, +) { return [ "agentCommands", serverId, @@ -50,13 +68,12 @@ export function useAgentCommandsQuery({ const isConnected = useHostRuntimeIsConnected(serverId); const query = useQuery({ - queryKey: commandsQueryKey(serverId, agentId, draftConfig), + queryKey: agentCommandsQueryKey(serverId, agentId, draftConfig), queryFn: async () => { if (!client) { throw new Error("Daemon client not available"); } - const response = await client.listCommands(agentId, { draftConfig }); - return response.commands as AgentSlashCommand[]; + return fetchAgentCommands({ client, agentId, draftConfig }); }, enabled: enabled && !!client && isConnected && (!!agentId || !!draftConfig), staleTime: COMMANDS_STALE_TIME, diff --git a/packages/app/src/hooks/use-agent-history.test.ts b/packages/app/src/hooks/use-agent-history.test.ts new file mode 100644 index 000000000..0cdb392c2 --- /dev/null +++ b/packages/app/src/hooks/use-agent-history.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, it } from "vitest"; +import type { + DaemonClient, + FetchAgentHistoryEntry, + FetchAgentHistoryOptions, +} from "@getpaseo/client/internal/daemon-client"; +import { type AgentHistoryClient, fetchAgentHistoryPage } from "./use-agent-history"; + +type FetchAgentHistory = DaemonClient["fetchAgentHistory"]; +type FetchAgentHistoryResult = Awaited>; + +interface FakeAgentHistoryClient extends AgentHistoryClient { + calls: FetchAgentHistoryOptions[]; +} + +function createClient(pages: FetchAgentHistoryResult[]): FakeAgentHistoryClient { + const calls: FetchAgentHistoryOptions[] = []; + let index = 0; + return { + calls, + fetchAgentHistory: async (options) => { + calls.push(options ?? {}); + const page = pages[index] ?? pages[pages.length - 1]; + index += 1; + if (!page) { + throw new Error("No more history pages configured"); + } + return page; + }, + }; +} + +function historyPayload(input: { + entries: FetchAgentHistoryEntry[]; + hasMore?: boolean; + nextCursor?: string | null; +}): FetchAgentHistoryResult { + return { + requestId: "req_history", + entries: input.entries, + pageInfo: { + nextCursor: input.nextCursor ?? null, + prevCursor: null, + hasMore: input.hasMore ?? false, + }, + }; +} + +function historyEntry(input: { + id: string; + cwd: string; + updatedAt: string; + title?: string | null; + archivedAt?: string | null; +}): FetchAgentHistoryEntry { + return { + agent: { + id: input.id, + provider: "codex", + status: "closed", + createdAt: input.updatedAt, + updatedAt: input.updatedAt, + lastUserMessageAt: null, + lastError: undefined, + runtimeInfo: { + provider: "codex", + sessionId: null, + }, + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + title: input.title ?? null, + cwd: input.cwd, + model: null, + thinkingOptionId: null, + requiresAttention: false, + attentionReason: null, + attentionTimestamp: null, + archivedAt: input.archivedAt ?? null, + labels: {}, + }, + project: { + projectKey: input.cwd, + projectName: "workspace", + checkout: { + cwd: input.cwd, + isGit: false, + currentBranch: null, + remoteUrl: null, + worktreeRoot: null, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }, + }, + }; +} + +describe("fetchAgentHistoryPage", () => { + it("requests the first page with the default limit and updated_at descending sort", async () => { + const client = createClient([ + historyPayload({ + entries: [ + historyEntry({ + id: "history-1", + cwd: "/repo", + updatedAt: "2026-04-02T10:00:00.000Z", + title: "History one", + }), + ], + hasMore: true, + nextCursor: "cursor-2", + }), + ]); + + const page = await fetchAgentHistoryPage({ client, serverId: "server-1", cursor: null }); + + expect(client.calls).toEqual([ + { + sort: [{ key: "updated_at", direction: "desc" }], + page: { limit: 200 }, + } satisfies FetchAgentHistoryOptions, + ]); + expect(page.agents.map((agent) => agent.id)).toEqual(["history-1"]); + expect(page.pageInfo).toEqual({ + nextCursor: "cursor-2", + prevCursor: null, + hasMore: true, + }); + }); + + it("passes the cursor when fetching subsequent pages", async () => { + const client = createClient([ + historyPayload({ + entries: [ + historyEntry({ + id: "history-2", + cwd: "/repo", + updatedAt: "2026-04-01T10:00:00.000Z", + title: "History two", + }), + ], + }), + ]); + + await fetchAgentHistoryPage({ client, serverId: "server-1", cursor: "cursor-2" }); + + expect(client.calls.at(-1)).toEqual({ + sort: [{ key: "updated_at", direction: "desc" }], + page: { limit: 200, cursor: "cursor-2" }, + } satisfies FetchAgentHistoryOptions); + }); + + it("maps daemon history entries into aggregated agents tagged with the requested server", async () => { + const client = createClient([ + historyPayload({ + entries: [ + historyEntry({ + id: "history-1", + cwd: "/repo", + updatedAt: "2026-04-02T10:00:00.000Z", + title: "History one", + }), + ], + }), + ]); + + const page = await fetchAgentHistoryPage({ client, serverId: "server-1", cursor: null }); + + expect(page.agents).toEqual([ + expect.objectContaining({ + id: "history-1", + serverId: "server-1", + serverLabel: "server-1", + title: "History one", + cwd: "/repo", + provider: "codex", + archivedAt: null, + }), + ]); + }); + + it("carries archived entries through with their archivedAt timestamp", async () => { + const client = createClient([ + historyPayload({ + entries: [ + historyEntry({ + id: "history-archived", + cwd: "/repo", + updatedAt: "2026-04-01T10:00:00.000Z", + archivedAt: "2026-04-01T10:05:00.000Z", + }), + ], + }), + ]); + + const page = await fetchAgentHistoryPage({ client, serverId: "server-1", cursor: null }); + + expect(page.agents[0]?.archivedAt).toEqual(new Date("2026-04-01T10:05:00.000Z")); + }); +}); diff --git a/packages/app/src/hooks/use-agent-history.test.tsx b/packages/app/src/hooks/use-agent-history.test.tsx deleted file mode 100644 index d43082216..000000000 --- a/packages/app/src/hooks/use-agent-history.test.tsx +++ /dev/null @@ -1,267 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import React from "react"; -import { act, renderHook, waitFor } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import type { - DaemonClient, - FetchAgentHistoryEntry, - FetchAgentHistoryOptions, -} from "@getpaseo/client/internal/daemon-client"; -import { useSessionStore, type Agent } from "@/stores/session-store"; -import { useAgentHistory } from "./use-agent-history"; - -const { mockClient, mockRuntimeStore } = vi.hoisted(() => { - const hoistedClient = { - fetchAgentHistory: vi.fn(), - }; - const hoistedRuntimeStore = { - refreshAgentDirectory: vi.fn(), - }; - return { mockClient: hoistedClient, mockRuntimeStore: hoistedRuntimeStore }; -}); - -vi.mock("@/runtime/host-runtime", () => ({ - getHostRuntimeStore: () => mockRuntimeStore, - useHostRuntimeClient: () => mockClient, - useHostRuntimeIsConnected: () => true, - useHosts: () => [{ serverId: "server-1", label: "Local" }], -})); - -function createQueryClient(): QueryClient { - return new QueryClient({ - defaultOptions: { - queries: { retry: false }, - }, - }); -} - -function renderAgentHistoryHook(options?: { enabled?: boolean }) { - const queryClient = createQueryClient(); - const wrapper = ({ children }: { children: React.ReactNode }) => - React.createElement(QueryClientProvider, { client: queryClient }, children); - - return renderHook(() => useAgentHistory({ serverId: "server-1", enabled: options?.enabled }), { - wrapper, - }); -} - -function makeHistoryPayload(input: { - entries: FetchAgentHistoryEntry[]; - hasMore?: boolean; - nextCursor?: string | null; -}): Awaited> { - return { - requestId: "req_history", - entries: input.entries, - pageInfo: { - nextCursor: input.nextCursor ?? null, - prevCursor: null, - hasMore: input.hasMore ?? false, - }, - }; -} - -function makeHistoryEntry(input: { - id: string; - cwd: string; - updatedAt: string; - title?: string | null; - archivedAt?: string | null; -}): FetchAgentHistoryEntry { - return { - agent: { - id: input.id, - provider: "codex", - status: "closed", - createdAt: input.updatedAt, - updatedAt: input.updatedAt, - lastUserMessageAt: null, - lastError: undefined, - runtimeInfo: { - provider: "codex", - sessionId: null, - }, - capabilities: { - supportsStreaming: true, - supportsSessionPersistence: true, - supportsDynamicModes: true, - supportsMcpServers: true, - supportsReasoningStream: true, - supportsToolInvocations: true, - }, - currentModeId: null, - availableModes: [], - pendingPermissions: [], - persistence: null, - title: input.title ?? null, - cwd: input.cwd, - model: null, - thinkingOptionId: null, - requiresAttention: false, - attentionReason: null, - attentionTimestamp: null, - archivedAt: input.archivedAt ?? null, - labels: {}, - }, - project: { - projectKey: input.cwd, - projectName: "workspace", - checkout: { - cwd: input.cwd, - isGit: false, - currentBranch: null, - remoteUrl: null, - worktreeRoot: null, - isPaseoOwnedWorktree: false, - mainRepoRoot: null, - }, - }, - }; -} - -function makeActiveAgent(): Agent { - const timestamp = new Date("2026-04-01T10:00:00.000Z"); - return { - serverId: "server-1", - id: "active-1", - provider: "codex", - status: "idle", - createdAt: timestamp, - updatedAt: timestamp, - lastUserMessageAt: null, - lastActivityAt: timestamp, - capabilities: { - supportsStreaming: true, - supportsSessionPersistence: true, - supportsDynamicModes: true, - supportsMcpServers: true, - supportsReasoningStream: true, - supportsToolInvocations: true, - }, - currentModeId: null, - availableModes: [], - pendingPermissions: [], - persistence: null, - title: "Active", - cwd: "/repo", - model: null, - labels: {}, - archivedAt: null, - parentAgentId: null, - }; -} - -afterEach(() => { - mockClient.fetchAgentHistory.mockReset(); - mockRuntimeStore.refreshAgentDirectory.mockReset(); - useSessionStore.setState({ sessions: {}, agentLastActivity: new Map() }); -}); - -function agentIds(agents: ReadonlyArray<{ id: string }>): string[] { - return agents.map((agent) => agent.id); -} - -describe("useAgentHistory", () => { - it("loads history one page at a time without refreshing active agents", async () => { - mockClient.fetchAgentHistory - .mockResolvedValueOnce( - makeHistoryPayload({ - entries: [ - makeHistoryEntry({ - id: "history-1", - cwd: "/repo", - updatedAt: "2026-04-02T10:00:00.000Z", - title: "History one", - }), - ], - hasMore: true, - nextCursor: "cursor-2", - }), - ) - .mockResolvedValueOnce( - makeHistoryPayload({ - entries: [ - makeHistoryEntry({ - id: "history-2", - cwd: "/repo", - updatedAt: "2026-04-01T10:00:00.000Z", - title: "History two", - archivedAt: "2026-04-01T10:05:00.000Z", - }), - ], - }), - ); - - act(() => { - useSessionStore - .getState() - .initializeSession("server-1", mockClient as unknown as DaemonClient); - useSessionStore.getState().setAgents("server-1", new Map([["active-1", makeActiveAgent()]])); - }); - - const { result } = renderAgentHistoryHook(); - - await waitFor(() => { - expect(mockClient.fetchAgentHistory).toHaveBeenCalledTimes(1); - }); - - expect(mockClient.fetchAgentHistory.mock.calls.map(([options]) => options)).toEqual([ - { - sort: [{ key: "updated_at", direction: "desc" }], - page: { limit: 200 }, - }, - ] satisfies FetchAgentHistoryOptions[]); - await waitFor(() => { - expect(agentIds(result.current.agents)).toEqual(["history-1"]); - }); - expect(result.current.hasMore).toBe(true); - - await act(async () => { - result.current.loadMore(); - }); - - await waitFor(() => { - expect(mockClient.fetchAgentHistory).toHaveBeenCalledTimes(2); - }); - - expect(mockClient.fetchAgentHistory.mock.calls.at(-1)?.[0]).toEqual({ - sort: [{ key: "updated_at", direction: "desc" }], - page: { limit: 200, cursor: "cursor-2" }, - } satisfies FetchAgentHistoryOptions); - await waitFor(() => { - expect(agentIds(result.current.agents)).toEqual(["history-1", "history-2"]); - }); - expect(result.current.hasMore).toBe(false); - expect( - Array.from(useSessionStore.getState().sessions["server-1"]?.agents.keys() ?? []), - ).toEqual(["active-1"]); - expect(mockRuntimeStore.refreshAgentDirectory).not.toHaveBeenCalled(); - }); - - it("waits until history is enabled before calling the history RPC", async () => { - mockClient.fetchAgentHistory.mockResolvedValue(makeHistoryPayload({ entries: [] })); - - const queryClient = createQueryClient(); - const wrapper = ({ children }: { children: React.ReactNode }) => - React.createElement(QueryClientProvider, { client: queryClient }, children); - let enabled = false; - const { rerender } = renderHook(() => useAgentHistory({ serverId: "server-1", enabled }), { - wrapper, - }); - await act(async () => { - await Promise.resolve(); - }); - - expect(mockClient.fetchAgentHistory).not.toHaveBeenCalled(); - - enabled = true; - rerender(); - - await waitFor(() => { - expect(mockClient.fetchAgentHistory).toHaveBeenCalledTimes(1); - }); - }); -}); diff --git a/packages/app/src/hooks/use-agent-history.ts b/packages/app/src/hooks/use-agent-history.ts index 0ddf54709..ff9fa0101 100644 --- a/packages/app/src/hooks/use-agent-history.ts +++ b/packages/app/src/hooks/use-agent-history.ts @@ -26,13 +26,15 @@ export interface AgentHistoryResult { loadMore: () => void; } -interface AgentHistoryPage { +export interface AgentHistoryPage { agents: AggregatedAgent[]; pageInfo: FetchAgentHistoryPageInfo; } -async function fetchAgentHistoryPage(input: { - client: DaemonClient; +export type AgentHistoryClient = Pick; + +export async function fetchAgentHistoryPage(input: { + client: AgentHistoryClient; serverId: string; cursor: string | null; }): Promise { @@ -147,7 +149,3 @@ export function useAgentHistory(options: { loadMore, }; } - -export const __private__ = { - fetchAgentHistoryPage, -}; diff --git a/packages/app/src/hooks/use-agent-initialization.test.ts b/packages/app/src/hooks/use-agent-initialization.test.ts index cdb9496bf..2c7b9f38b 100644 --- a/packages/app/src/hooks/use-agent-initialization.test.ts +++ b/packages/app/src/hooks/use-agent-initialization.test.ts @@ -1,29 +1,39 @@ -// @vitest-environment jsdom - -import { act, renderHook } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { useSessionStore } from "@/stores/session-store"; import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy"; import { getInitDeferred, getInitKey, resolveInitDeferred } from "@/utils/agent-initialization"; -import { useAgentInitialization } from "./use-agent-initialization"; +import { + createSetAgentInitializing, + ensureAgentIsInitialized, + refreshAgent, +} from "./use-agent-initialization"; const serverId = "server-1"; const agentId = "agent-1"; -function makeClient() { +interface FakeDaemonClient { + fetchAgentTimeline: ReturnType; + refreshAgent: ReturnType; +} + +function makeClient(): FakeDaemonClient { return { fetchAgentTimeline: vi.fn().mockResolvedValue(undefined), refreshAgent: vi.fn().mockResolvedValue(undefined), }; } +function bindSetAgentInitializing() { + return createSetAgentInitializing(serverId, useSessionStore.getState().setInitializingAgents); +} + afterEach(() => { resolveInitDeferred(getInitKey(serverId, agentId)); useSessionStore.setState({ sessions: {}, agentLastActivity: new Map() }); vi.restoreAllMocks(); }); -describe("useAgentInitialization", () => { +describe("ensureAgentIsInitialized", () => { it("requests bounded canonical catch-up after the current cursor when authoritative history is loaded", () => { const client = makeClient(); useSessionStore.getState().initializeSession(serverId, client as never); @@ -35,12 +45,11 @@ describe("useAgentInitialization", () => { ); useSessionStore.getState().setAgentAuthoritativeHistoryApplied(serverId, agentId, true); - const { result } = renderHook(() => - useAgentInitialization({ serverId, client: client as never }), - ); - - act(() => { - void result.current.ensureAgentIsInitialized(agentId); + void ensureAgentIsInitialized({ + serverId, + agentId, + client: client as never, + setAgentInitializing: bindSetAgentInitializing(), }); expect(client.fetchAgentTimeline).toHaveBeenCalledWith(agentId, { @@ -56,12 +65,11 @@ describe("useAgentInitialization", () => { const client = makeClient(); useSessionStore.getState().initializeSession(serverId, client as never); - const { result } = renderHook(() => - useAgentInitialization({ serverId, client: client as never }), - ); - - act(() => { - void result.current.ensureAgentIsInitialized(agentId); + void ensureAgentIsInitialized({ + serverId, + agentId, + client: client as never, + setAgentInitializing: bindSetAgentInitializing(), }); expect(client.fetchAgentTimeline).toHaveBeenCalledWith(agentId, { @@ -77,20 +85,17 @@ describe("useAgentInitialization", () => { const client = makeClient(); useSessionStore.getState().initializeSession(serverId, client as never); - const { result } = renderHook(() => - useAgentInitialization({ serverId, client: client as never }), - ); - - const promise = result.current.ensureAgentIsInitialized(agentId); - - act(() => { - vi.advanceTimersByTime(29_999); + const promise = ensureAgentIsInitialized({ + serverId, + agentId, + client: client as never, + setAgentInitializing: bindSetAgentInitializing(), }); + + vi.advanceTimersByTime(29_999); expect(getInitDeferred(getInitKey(serverId, agentId))).toBeDefined(); - act(() => { - vi.advanceTimersByTime(1); - }); + vi.advanceTimersByTime(1); await expect(promise).rejects.toThrow("History sync timed out after 30s"); expect(getInitDeferred(getInitKey(serverId, agentId))).toBeUndefined(); @@ -99,15 +104,17 @@ describe("useAgentInitialization", () => { ); vi.useRealTimers(); }); +}); - it("refresh fetches a bounded canonical tail after refreshing the agent", async () => { +describe("refreshAgent", () => { + it("fetches a bounded canonical tail after refreshing the agent", async () => { const client = makeClient(); - const { result } = renderHook(() => - useAgentInitialization({ serverId, client: client as never }), - ); + useSessionStore.getState().initializeSession(serverId, client as never); - await act(async () => { - await result.current.refreshAgent(agentId); + await refreshAgent({ + agentId, + client: client as never, + setAgentInitializing: bindSetAgentInitializing(), }); expect(client.refreshAgent).toHaveBeenCalledWith(agentId); diff --git a/packages/app/src/hooks/use-agent-initialization.ts b/packages/app/src/hooks/use-agent-initialization.ts index 6d89a074c..8e1e72deb 100644 --- a/packages/app/src/hooks/use-agent-initialization.ts +++ b/packages/app/src/hooks/use-agent-initialization.ts @@ -1,6 +1,6 @@ -import { useCallback } from "react"; -import { useSessionStore } from "@/stores/session-store"; +import { useCallback, useMemo } from "react"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { useSessionStore } from "@/stores/session-store"; import { attachInitTimeout, createInitDeferred, @@ -10,7 +10,109 @@ import { } from "@/utils/agent-initialization"; import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy"; -const INIT_TIMEOUT_MS = 30_000; +export const INIT_TIMEOUT_MS = 30_000; + +export type SetAgentInitializing = (agentId: string, initializing: boolean) => void; + +export interface EnsureAgentIsInitializedInput { + serverId: string; + agentId: string; + client: Pick | null; + setAgentInitializing: SetAgentInitializing; +} + +export function ensureAgentIsInitialized(input: EnsureAgentIsInitializedInput): Promise { + const { serverId, agentId, client, setAgentInitializing } = input; + const key = getInitKey(serverId, agentId); + const existing = getInitDeferred(key); + if (existing) { + return existing.promise; + } + + const session = useSessionStore.getState().sessions[serverId]; + const cursor = session?.agentTimelineCursor.get(agentId); + const hasAuthoritativeHistory = session?.agentAuthoritativeHistoryApplied.get(agentId) === true; + const timelineRequest = + hasAuthoritativeHistory && cursor + ? { + direction: "after" as const, + cursor: { epoch: cursor.epoch, seq: cursor.endSeq }, + limit: TIMELINE_FETCH_PAGE_SIZE, + projection: "canonical" as const, + } + : { + direction: "tail" as const, + limit: TIMELINE_FETCH_PAGE_SIZE, + projection: "canonical" as const, + }; + + const deferred = createInitDeferred(key, timelineRequest.direction); + const timeoutId = setTimeout(() => { + setAgentInitializing(agentId, false); + rejectInitDeferred( + key, + new Error(`History sync timed out after ${Math.round(INIT_TIMEOUT_MS / 1000)}s`), + ); + }, INIT_TIMEOUT_MS); + attachInitTimeout(key, timeoutId); + + setAgentInitializing(agentId, true); + + if (!client) { + setAgentInitializing(agentId, false); + rejectInitDeferred(key, new Error("Host is not connected")); + return deferred.promise; + } + + client.fetchAgentTimeline(agentId, timelineRequest).catch((error) => { + setAgentInitializing(agentId, false); + rejectInitDeferred(key, error instanceof Error ? error : new Error(String(error))); + }); + + return deferred.promise; +} + +export interface RefreshAgentInput { + agentId: string; + client: Pick | null; + setAgentInitializing: SetAgentInitializing; +} + +export async function refreshAgent(input: RefreshAgentInput): Promise { + const { agentId, client, setAgentInitializing } = input; + if (!client) { + throw new Error("Host is not connected"); + } + setAgentInitializing(agentId, true); + + try { + await client.refreshAgent(agentId); + await client.fetchAgentTimeline(agentId, { + direction: "tail", + limit: TIMELINE_FETCH_PAGE_SIZE, + projection: "canonical", + }); + } catch (error) { + setAgentInitializing(agentId, false); + throw error; + } +} + +export function createSetAgentInitializing( + serverId: string, + setInitializingAgents: ReturnType["setInitializingAgents"], +): SetAgentInitializing { + return (agentId, initializing) => { + setInitializingAgents(serverId, (prev) => { + if (prev.get(agentId) === initializing) { + return prev; + } + const next = new Map(prev); + next.set(agentId, initializing); + return next; + }); + }; +} export function useAgentInitialization({ serverId, @@ -20,95 +122,24 @@ export function useAgentInitialization({ client: DaemonClient | null; }) { const setInitializingAgents = useSessionStore((state) => state.setInitializingAgents); - const setAgentInitializing = useCallback( - (agentId: string, initializing: boolean) => { - setInitializingAgents(serverId, (prev) => { - if (prev.get(agentId) === initializing) { - return prev; - } - const next = new Map(prev); - next.set(agentId, initializing); - return next; - }); - }, + const setAgentInitializing = useMemo( + () => createSetAgentInitializing(serverId, setInitializingAgents), [serverId, setInitializingAgents], ); - const ensureAgentIsInitialized = useCallback( - (agentId: string): Promise => { - const key = getInitKey(serverId, agentId); - const existing = getInitDeferred(key); - if (existing) { - return existing.promise; - } - - const session = useSessionStore.getState().sessions[serverId]; - const cursor = session?.agentTimelineCursor.get(agentId); - const hasAuthoritativeHistory = - session?.agentAuthoritativeHistoryApplied.get(agentId) === true; - const timelineRequest = - hasAuthoritativeHistory && cursor - ? { - direction: "after" as const, - cursor: { epoch: cursor.epoch, seq: cursor.endSeq }, - limit: TIMELINE_FETCH_PAGE_SIZE, - projection: "canonical" as const, - } - : { - direction: "tail" as const, - limit: TIMELINE_FETCH_PAGE_SIZE, - projection: "canonical" as const, - }; - - const deferred = createInitDeferred(key, timelineRequest.direction); - const timeoutId = setTimeout(() => { - setAgentInitializing(agentId, false); - rejectInitDeferred( - key, - new Error(`History sync timed out after ${Math.round(INIT_TIMEOUT_MS / 1000)}s`), - ); - }, INIT_TIMEOUT_MS); - attachInitTimeout(key, timeoutId); - - setAgentInitializing(agentId, true); - - if (!client) { - setAgentInitializing(agentId, false); - rejectInitDeferred(key, new Error("Host is not connected")); - return deferred.promise; - } - - client.fetchAgentTimeline(agentId, timelineRequest).catch((error) => { - setAgentInitializing(agentId, false); - rejectInitDeferred(key, error instanceof Error ? error : new Error(String(error))); - }); - - return deferred.promise; - }, + const ensureAgentIsInitializedCallback = useCallback( + (agentId: string): Promise => + ensureAgentIsInitialized({ serverId, agentId, client, setAgentInitializing }), [client, serverId, setAgentInitializing], ); - const refreshAgent = useCallback( - async (agentId: string) => { - if (!client) { - throw new Error("Host is not connected"); - } - setAgentInitializing(agentId, true); - - try { - await client.refreshAgent(agentId); - await client.fetchAgentTimeline(agentId, { - direction: "tail", - limit: TIMELINE_FETCH_PAGE_SIZE, - projection: "canonical", - }); - } catch (error) { - setAgentInitializing(agentId, false); - throw error; - } - }, + const refreshAgentCallback = useCallback( + (agentId: string): Promise => refreshAgent({ agentId, client, setAgentInitializing }), [client, setAgentInitializing], ); - return { ensureAgentIsInitialized, refreshAgent }; + return { + ensureAgentIsInitialized: ensureAgentIsInitializedCallback, + refreshAgent: refreshAgentCallback, + }; } diff --git a/packages/app/src/hooks/use-archive-agent.test.ts b/packages/app/src/hooks/use-archive-agent.test.ts index bd05954f0..429fdc602 100644 --- a/packages/app/src/hooks/use-archive-agent.test.ts +++ b/packages/app/src/hooks/use-archive-agent.test.ts @@ -1,18 +1,15 @@ -/** - * @vitest-environment jsdom - */ -import React from "react"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { act, renderHook, waitFor } from "@testing-library/react"; +import { QueryClient } from "@tanstack/react-query"; import { beforeEach, describe, expect, it } from "vitest"; import type { Agent } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store"; import { agentHistoryQueryKey } from "./agent-history-query-key"; import { - __private__, applyArchivedAgentCloseResults, - usePendingArchiveAgentIds, + isAgentArchiving, + removeAgentFromListPayload, + selectPendingArchiveAgentIds, + setAgentArchiving, } from "./use-archive-agent"; function makeAgent(overrides: Partial = {}): Agent { @@ -56,14 +53,14 @@ describe("useArchiveAgent", () => { const queryClient = new QueryClient(); expect( - __private__.isAgentArchiving({ + isAgentArchiving({ queryClient, serverId: "server-a", agentId: "agent-1", }), ).toBe(false); - __private__.setAgentArchiving({ + setAgentArchiving({ queryClient, serverId: "server-a", agentId: "agent-1", @@ -71,21 +68,21 @@ describe("useArchiveAgent", () => { }); expect( - __private__.isAgentArchiving({ + isAgentArchiving({ queryClient, serverId: "server-a", agentId: "agent-1", }), ).toBe(true); expect( - __private__.isAgentArchiving({ + isAgentArchiving({ queryClient, serverId: "server-a", agentId: "agent-2", }), ).toBe(false); - __private__.setAgentArchiving({ + setAgentArchiving({ queryClient, serverId: "server-a", agentId: "agent-1", @@ -93,7 +90,7 @@ describe("useArchiveAgent", () => { }); expect( - __private__.isAgentArchiving({ + isAgentArchiving({ queryClient, serverId: "server-a", agentId: "agent-1", @@ -102,7 +99,7 @@ describe("useArchiveAgent", () => { }); it("selects pending archive ids for a single server", () => { - const pendingIds = __private__.selectPendingArchiveAgentIds( + const pendingIds = selectPendingArchiveAgentIds( { "server-a:agent-1": true, "server-a:agent-2": true, @@ -114,42 +111,13 @@ describe("useArchiveAgent", () => { expect(Array.from(pendingIds)).toEqual(["agent-1", "agent-2"]); }); - it("subscribes to pending archive ids through react-query", async () => { - const queryClient = new QueryClient(); - const wrapper = ({ children }: { children: React.ReactNode }) => - React.createElement(QueryClientProvider, { client: queryClient }, children); - - const { result } = renderHook(() => usePendingArchiveAgentIds("server-a"), { wrapper }); - - expect(Array.from(result.current)).toEqual([]); - - act(() => { - __private__.setAgentArchiving({ - queryClient, - serverId: "server-a", - agentId: "agent-1", - isArchiving: true, - }); - __private__.setAgentArchiving({ - queryClient, - serverId: "server-b", - agentId: "agent-2", - isArchiving: true, - }); - }); - - await waitFor(() => { - expect(Array.from(result.current)).toEqual(["agent-1"]); - }); - }); - it("removes an archived agent from cached list payloads", () => { const payload = { entries: [{ agent: { id: "agent-1" } }, { agent: { id: "agent-2" } }], pageInfo: { hasMore: false }, }; - const next = __private__.removeAgentFromListPayload(payload, "agent-1"); + const next = removeAgentFromListPayload(payload, "agent-1"); expect(next.entries).toEqual([{ agent: { id: "agent-2" } }]); expect(next.pageInfo).toEqual({ hasMore: false }); diff --git a/packages/app/src/hooks/use-archive-agent.ts b/packages/app/src/hooks/use-archive-agent.ts index 6122893ae..333e0b71c 100644 --- a/packages/app/src/hooks/use-archive-agent.ts +++ b/packages/app/src/hooks/use-archive-agent.ts @@ -11,12 +11,7 @@ export interface ArchiveAgentInput { agentId: string; } -export interface ArchivedAgentCloseResult { - agentId: string; - archivedAt: string; -} - -type ArchiveAgentPendingState = Record; +export type ArchiveAgentPendingState = Record; interface SetAgentArchivingInput extends ArchiveAgentInput { queryClient: QueryClient; @@ -27,35 +22,24 @@ interface IsAgentArchivingInput extends ArchiveAgentInput { queryClient: QueryClient; } -interface AgentsListQueryData { +export interface AgentsListQueryData { entries?: Array<{ agent?: { id?: string | null } | null } | null>; } -interface ArchivedAgentListCacheSnapshot { - sidebarAgentsList: AgentsListQueryData | undefined; - allAgents: AgentsListQueryData | undefined; - agentHistory: AgentHistoryQueryData | undefined; -} - -interface ArchiveAgentMutationContext { - agent: ReturnType; - lists: ArchivedAgentListCacheSnapshot; -} - -interface AgentHistoryQueryAgent { +export interface AgentHistoryQueryAgent { id?: string | null; archivedAt?: Date | null; } -interface AgentHistoryQueryPage { +export interface AgentHistoryQueryPage { agents?: AgentHistoryQueryAgent[]; } -interface AgentHistoryQueryData { +export interface AgentHistoryQueryData { pages?: AgentHistoryQueryPage[]; } -function toArchiveKey(input: ArchiveAgentInput): string { +export function toArchiveKey(input: ArchiveAgentInput): string { const serverId = input.serverId.trim(); const agentId = input.agentId.trim(); if (!serverId || !agentId) { @@ -64,11 +48,11 @@ function toArchiveKey(input: ArchiveAgentInput): string { return `${serverId}:${agentId}`; } -function readPendingState(queryClient: QueryClient): ArchiveAgentPendingState { +export function readPendingState(queryClient: QueryClient): ArchiveAgentPendingState { return queryClient.getQueryData(ARCHIVE_AGENT_PENDING_QUERY_KEY) ?? {}; } -function selectPendingArchiveAgentIds( +export function selectPendingArchiveAgentIds( pendingState: ArchiveAgentPendingState, serverId: string, ): ReadonlySet { @@ -97,7 +81,7 @@ function selectPendingArchiveAgentIds( return new Set(agentIds); } -function setAgentArchiving(input: SetAgentArchivingInput): void { +export function setAgentArchiving(input: SetAgentArchivingInput): void { const key = toArchiveKey(input); if (!key) { return; @@ -125,7 +109,7 @@ function setAgentArchiving(input: SetAgentArchivingInput): void { ); } -function isAgentArchiving(input: IsAgentArchivingInput): boolean { +export function isAgentArchiving(input: IsAgentArchivingInput): boolean { const key = toArchiveKey(input); if (!key) { return false; @@ -133,7 +117,7 @@ function isAgentArchiving(input: IsAgentArchivingInput): boolean { return readPendingState(input.queryClient)[key] ?? false; } -function removeAgentFromListPayload( +export function removeAgentFromListPayload( payload: T, agentId: string, ): T { @@ -150,7 +134,10 @@ function removeAgentFromListPayload( } as T; } -function removeAgentFromCachedLists(queryClient: QueryClient, input: ArchiveAgentInput): void { +export function removeAgentFromCachedLists( + queryClient: QueryClient, + input: ArchiveAgentInput, +): void { const agentId = input.agentId.trim(); if (!agentId) { return; @@ -166,7 +153,7 @@ function removeAgentFromCachedLists(queryClient: QueryClient, input: ArchiveAgen ); } -function markAgentArchivedInHistoryPayload( +export function markAgentArchivedInHistoryPayload( payload: T, input: ArchiveAgentInput & { archivedAt: string }, ): T { @@ -204,7 +191,7 @@ function markAgentArchivedInHistoryPayload; + lists: ArchivedAgentListCacheSnapshot; +} + function getStoredAgentSnapshot(input: ArchiveAgentInput) { return useSessionStore.getState().sessions[input.serverId]?.agents.get(input.agentId); } @@ -352,13 +362,6 @@ export function applyArchivedAgentCloseResults(input: ApplyArchivedAgentCloseRes } } -export function clearArchiveAgentPending(input: IsAgentArchivingInput): void { - setAgentArchiving({ - ...input, - isArchiving: false, - }); -} - function useArchiveAgentPendingQuery() { return useQuery({ queryKey: ARCHIVE_AGENT_PENDING_QUERY_KEY, @@ -472,13 +475,3 @@ export function useArchiveAgent() { isArchivingAgent, }; } - -export const __private__ = { - toArchiveKey, - readPendingState, - selectPendingArchiveAgentIds, - setAgentArchiving, - isAgentArchiving, - removeAgentFromListPayload, - markAgentArchivedInHistoryPayload, -}; diff --git a/packages/app/src/hooks/use-changes-preferences.test.ts b/packages/app/src/hooks/use-changes-preferences.test.ts deleted file mode 100644 index 04451a73d..000000000 --- a/packages/app/src/hooks/use-changes-preferences.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const asyncStorageMock = vi.hoisted(() => ({ - getItem: vi.fn<(_: string) => Promise>(), - setItem: vi.fn<(_: string, __: string) => Promise>(), -})); - -vi.mock("@react-native-async-storage/async-storage", () => ({ - default: asyncStorageMock, -})); - -describe("use-changes-preferences", () => { - beforeEach(() => { - vi.resetModules(); - asyncStorageMock.getItem.mockReset(); - asyncStorageMock.setItem.mockReset(); - }); - - it("defaults to unified layout with visible whitespace", async () => { - asyncStorageMock.getItem.mockResolvedValue(null); - asyncStorageMock.setItem.mockResolvedValue(); - - const mod = await import("./use-changes-preferences"); - const result = await mod.loadChangesPreferencesFromStorage(); - - expect(result).toEqual(mod.DEFAULT_CHANGES_PREFERENCES); - expect(asyncStorageMock.setItem).toHaveBeenCalledWith( - "@paseo:changes-preferences", - JSON.stringify(mod.DEFAULT_CHANGES_PREFERENCES), - ); - }); - - it("migrates the legacy wrap-lines toggle into the new preferences object", async () => { - asyncStorageMock.getItem.mockImplementation(async (key: string) => { - if (key === "diff-wrap-lines") { - return "true"; - } - return null; - }); - asyncStorageMock.setItem.mockResolvedValue(); - - const mod = await import("./use-changes-preferences"); - const result = await mod.loadChangesPreferencesFromStorage(); - - expect(result).toEqual({ - layout: "unified", - wrapLines: true, - hideWhitespace: false, - }); - }); - - it("loads persisted layout and whitespace preferences", async () => { - asyncStorageMock.getItem.mockImplementation(async (key: string) => { - if (key === "@paseo:changes-preferences") { - return JSON.stringify({ - layout: "split", - hideWhitespace: true, - wrapLines: false, - }); - } - return null; - }); - - const mod = await import("./use-changes-preferences"); - const result = await mod.loadChangesPreferencesFromStorage(); - - expect(result).toEqual({ - layout: "split", - hideWhitespace: true, - wrapLines: false, - }); - expect(asyncStorageMock.setItem).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/app/src/hooks/use-changes-preferences.ts b/packages/app/src/hooks/use-changes-preferences.ts deleted file mode 100644 index cfc6ee57b..000000000 --- a/packages/app/src/hooks/use-changes-preferences.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { useCallback } from "react"; -import AsyncStorage from "@react-native-async-storage/async-storage"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { z } from "zod"; - -const CHANGES_PREFERENCES_STORAGE_KEY = "@paseo:changes-preferences"; -const LEGACY_WRAP_LINES_STORAGE_KEY = "diff-wrap-lines"; -const CHANGES_PREFERENCES_QUERY_KEY = ["changes-preferences"]; - -const changesPreferencesSchema = z.object({ - layout: z.enum(["unified", "split"]).optional(), - wrapLines: z.boolean().optional(), - hideWhitespace: z.boolean().optional(), -}); - -export interface ChangesPreferences { - layout: "unified" | "split"; - wrapLines: boolean; - hideWhitespace: boolean; -} - -export const DEFAULT_CHANGES_PREFERENCES: ChangesPreferences = { - layout: "unified", - wrapLines: false, - hideWhitespace: false, -}; - -async function loadLegacyWrapLinesPreference(): Promise { - const legacyValue = await AsyncStorage.getItem(LEGACY_WRAP_LINES_STORAGE_KEY); - if (legacyValue === "true") { - return true; - } - if (legacyValue === "false") { - return false; - } - return null; -} - -export async function loadChangesPreferencesFromStorage(): Promise { - const stored = await AsyncStorage.getItem(CHANGES_PREFERENCES_STORAGE_KEY); - if (stored) { - const parsed = changesPreferencesSchema.safeParse(JSON.parse(stored)); - if (parsed.success) { - return { ...DEFAULT_CHANGES_PREFERENCES, ...parsed.data }; - } - } - - const legacyWrapLines = await loadLegacyWrapLinesPreference(); - const next = { - ...DEFAULT_CHANGES_PREFERENCES, - ...(legacyWrapLines !== null ? { wrapLines: legacyWrapLines } : {}), - } satisfies ChangesPreferences; - await AsyncStorage.setItem(CHANGES_PREFERENCES_STORAGE_KEY, JSON.stringify(next)); - return next; -} - -export interface UseChangesPreferencesReturn { - preferences: ChangesPreferences; - isLoading: boolean; - updatePreferences: (updates: Partial) => Promise; -} - -export function useChangesPreferences(): UseChangesPreferencesReturn { - const queryClient = useQueryClient(); - const { data, isPending } = useQuery({ - queryKey: CHANGES_PREFERENCES_QUERY_KEY, - queryFn: loadChangesPreferencesFromStorage, - staleTime: Infinity, - gcTime: Infinity, - }); - - const updatePreferences = useCallback( - async (updates: Partial) => { - const prev = - queryClient.getQueryData(CHANGES_PREFERENCES_QUERY_KEY) ?? - DEFAULT_CHANGES_PREFERENCES; - const next = { ...prev, ...updates }; - queryClient.setQueryData(CHANGES_PREFERENCES_QUERY_KEY, next); - await AsyncStorage.setItem(CHANGES_PREFERENCES_STORAGE_KEY, JSON.stringify(next)); - }, - [queryClient], - ); - - return { - preferences: data ?? DEFAULT_CHANGES_PREFERENCES, - isLoading: isPending, - updatePreferences, - }; -} diff --git a/packages/app/src/hooks/use-changes-preferences/fakes.ts b/packages/app/src/hooks/use-changes-preferences/fakes.ts new file mode 100644 index 000000000..96e7a8490 --- /dev/null +++ b/packages/app/src/hooks/use-changes-preferences/fakes.ts @@ -0,0 +1,20 @@ +import type { KeyValueStorage } from "./storage"; + +export interface InMemoryKeyValueStorage extends KeyValueStorage { + readonly entries: Map; +} + +export function createInMemoryKeyValueStorage( + initial: Record = {}, +): InMemoryKeyValueStorage { + const entries = new Map(Object.entries(initial)); + return { + entries, + async getItem(key) { + return entries.get(key) ?? null; + }, + async setItem(key, value) { + entries.set(key, value); + }, + }; +} diff --git a/packages/app/src/hooks/use-changes-preferences/index.ts b/packages/app/src/hooks/use-changes-preferences/index.ts new file mode 100644 index 000000000..72570f6d2 --- /dev/null +++ b/packages/app/src/hooks/use-changes-preferences/index.ts @@ -0,0 +1,52 @@ +import { useCallback } from "react"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { + CHANGES_PREFERENCES_QUERY_KEY, + DEFAULT_CHANGES_PREFERENCES, + loadChangesPreferencesFromStorage as loadChangesPreferencesFromStoragePure, + saveChangesPreferences as saveChangesPreferencesPure, + type ChangesPreferences, + type KeyValueStorage, +} from "./storage"; + +export { DEFAULT_CHANGES_PREFERENCES, type ChangesPreferences, type KeyValueStorage }; + +const productionStorage: KeyValueStorage = AsyncStorage; + +export function loadChangesPreferencesFromStorage(): Promise { + return loadChangesPreferencesFromStoragePure(productionStorage); +} + +export interface UseChangesPreferencesReturn { + preferences: ChangesPreferences; + isLoading: boolean; + updatePreferences: (updates: Partial) => Promise; +} + +export function useChangesPreferences(): UseChangesPreferencesReturn { + const queryClient = useQueryClient(); + const { data, isPending } = useQuery({ + queryKey: CHANGES_PREFERENCES_QUERY_KEY, + queryFn: loadChangesPreferencesFromStorage, + staleTime: Infinity, + gcTime: Infinity, + }); + + const updatePreferences = useCallback( + async (updates: Partial) => { + await saveChangesPreferencesPure({ + queryClient, + updates, + storage: productionStorage, + }); + }, + [queryClient], + ); + + return { + preferences: data ?? DEFAULT_CHANGES_PREFERENCES, + isLoading: isPending, + updatePreferences, + }; +} diff --git a/packages/app/src/hooks/use-changes-preferences/storage.test.ts b/packages/app/src/hooks/use-changes-preferences/storage.test.ts new file mode 100644 index 000000000..044cec1a9 --- /dev/null +++ b/packages/app/src/hooks/use-changes-preferences/storage.test.ts @@ -0,0 +1,89 @@ +import { QueryClient } from "@tanstack/react-query"; +import { describe, expect, it } from "vitest"; +import { createInMemoryKeyValueStorage } from "./fakes"; +import { + CHANGES_PREFERENCES_QUERY_KEY, + CHANGES_PREFERENCES_STORAGE_KEY, + DEFAULT_CHANGES_PREFERENCES, + loadChangesPreferencesFromStorage, + saveChangesPreferences, +} from "./storage"; + +describe("loadChangesPreferencesFromStorage", () => { + it("defaults to unified layout with visible whitespace and writes the defaults back", async () => { + const storage = createInMemoryKeyValueStorage(); + + const result = await loadChangesPreferencesFromStorage(storage); + + expect(result).toEqual(DEFAULT_CHANGES_PREFERENCES); + expect(storage.entries.get(CHANGES_PREFERENCES_STORAGE_KEY)).toBe( + JSON.stringify(DEFAULT_CHANGES_PREFERENCES), + ); + }); + + it("migrates the legacy wrap-lines toggle into the new preferences object", async () => { + const storage = createInMemoryKeyValueStorage({ "diff-wrap-lines": "true" }); + + const result = await loadChangesPreferencesFromStorage(storage); + + expect(result).toEqual({ + layout: "unified", + wrapLines: true, + hideWhitespace: false, + }); + expect(storage.entries.get(CHANGES_PREFERENCES_STORAGE_KEY)).toBe(JSON.stringify(result)); + }); + + it("loads persisted layout and whitespace preferences without rewriting storage", async () => { + const persisted = JSON.stringify({ + layout: "split", + hideWhitespace: true, + wrapLines: false, + }); + const storage = createInMemoryKeyValueStorage({ + [CHANGES_PREFERENCES_STORAGE_KEY]: persisted, + }); + + const result = await loadChangesPreferencesFromStorage(storage); + + expect(result).toEqual({ + layout: "split", + hideWhitespace: true, + wrapLines: false, + }); + expect(storage.entries.get(CHANGES_PREFERENCES_STORAGE_KEY)).toBe(persisted); + expect(storage.entries.size).toBe(1); + }); +}); + +describe("saveChangesPreferences", () => { + it("merges updates onto cached preferences and persists the result", async () => { + const storage = createInMemoryKeyValueStorage(); + const queryClient = new QueryClient(); + queryClient.setQueryData(CHANGES_PREFERENCES_QUERY_KEY, DEFAULT_CHANGES_PREFERENCES); + + await saveChangesPreferences({ + queryClient, + updates: { layout: "split", hideWhitespace: true }, + storage, + }); + + const expected = { ...DEFAULT_CHANGES_PREFERENCES, layout: "split", hideWhitespace: true }; + expect(queryClient.getQueryData(CHANGES_PREFERENCES_QUERY_KEY)).toEqual(expected); + expect(storage.entries.get(CHANGES_PREFERENCES_STORAGE_KEY)).toBe(JSON.stringify(expected)); + }); + + it("falls back to defaults when the query cache has no prior preferences", async () => { + const storage = createInMemoryKeyValueStorage(); + const queryClient = new QueryClient(); + + await saveChangesPreferences({ + queryClient, + updates: { wrapLines: true }, + storage, + }); + + const expected = { ...DEFAULT_CHANGES_PREFERENCES, wrapLines: true }; + expect(storage.entries.get(CHANGES_PREFERENCES_STORAGE_KEY)).toBe(JSON.stringify(expected)); + }); +}); diff --git a/packages/app/src/hooks/use-changes-preferences/storage.ts b/packages/app/src/hooks/use-changes-preferences/storage.ts new file mode 100644 index 000000000..0c6f8ecf4 --- /dev/null +++ b/packages/app/src/hooks/use-changes-preferences/storage.ts @@ -0,0 +1,73 @@ +import type { QueryClient } from "@tanstack/react-query"; +import { z } from "zod"; + +export const CHANGES_PREFERENCES_STORAGE_KEY = "@paseo:changes-preferences"; +export const LEGACY_WRAP_LINES_STORAGE_KEY = "diff-wrap-lines"; +export const CHANGES_PREFERENCES_QUERY_KEY = ["changes-preferences"]; + +const changesPreferencesSchema = z.object({ + layout: z.enum(["unified", "split"]).optional(), + wrapLines: z.boolean().optional(), + hideWhitespace: z.boolean().optional(), +}); + +export interface ChangesPreferences { + layout: "unified" | "split"; + wrapLines: boolean; + hideWhitespace: boolean; +} + +export const DEFAULT_CHANGES_PREFERENCES: ChangesPreferences = { + layout: "unified", + wrapLines: false, + hideWhitespace: false, +}; + +export interface KeyValueStorage { + getItem(key: string): Promise; + setItem(key: string, value: string): Promise; +} + +async function loadLegacyWrapLinesPreference(storage: KeyValueStorage): Promise { + const legacyValue = await storage.getItem(LEGACY_WRAP_LINES_STORAGE_KEY); + if (legacyValue === "true") { + return true; + } + if (legacyValue === "false") { + return false; + } + return null; +} + +export async function loadChangesPreferencesFromStorage( + storage: KeyValueStorage, +): Promise { + const stored = await storage.getItem(CHANGES_PREFERENCES_STORAGE_KEY); + if (stored) { + const parsed = changesPreferencesSchema.safeParse(JSON.parse(stored)); + if (parsed.success) { + return { ...DEFAULT_CHANGES_PREFERENCES, ...parsed.data }; + } + } + + const legacyWrapLines = await loadLegacyWrapLinesPreference(storage); + const next = { + ...DEFAULT_CHANGES_PREFERENCES, + ...(legacyWrapLines !== null ? { wrapLines: legacyWrapLines } : {}), + } satisfies ChangesPreferences; + await storage.setItem(CHANGES_PREFERENCES_STORAGE_KEY, JSON.stringify(next)); + return next; +} + +export async function saveChangesPreferences(input: { + queryClient: QueryClient; + updates: Partial; + storage: KeyValueStorage; +}): Promise { + const prev = + input.queryClient.getQueryData(CHANGES_PREFERENCES_QUERY_KEY) ?? + DEFAULT_CHANGES_PREFERENCES; + const next = { ...prev, ...input.updates }; + input.queryClient.setQueryData(CHANGES_PREFERENCES_QUERY_KEY, next); + await input.storage.setItem(CHANGES_PREFERENCES_STORAGE_KEY, JSON.stringify(next)); +} diff --git a/packages/app/src/hooks/use-client-activity.test.tsx b/packages/app/src/hooks/use-client-activity.test.tsx deleted file mode 100644 index 98d17400a..000000000 --- a/packages/app/src/hooks/use-client-activity.test.tsx +++ /dev/null @@ -1,244 +0,0 @@ -import React, { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { JSDOM } from "jsdom"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { useClientActivity } from "./use-client-activity"; - -interface HeartbeatPayload { - deviceType: "web" | "mobile"; - focusedAgentId: string | null; - lastActivityAt: string; - appVisible: boolean; - appVisibilityChangedAt?: string; -} - -const { platformState, getDesktopSystemIdleTimeMs } = vi.hoisted(() => ({ - platformState: { - isWeb: true, - isNative: false, - isElectron: false, - }, - getDesktopSystemIdleTimeMs: vi.fn<() => Promise>(), -})); - -vi.mock("@/constants/platform", () => ({ - get isWeb() { - return platformState.isWeb; - }, - get isNative() { - return platformState.isNative; - }, - getIsElectron: () => platformState.isElectron, -})); - -vi.mock("@/desktop/electron/idle", () => ({ - getDesktopSystemIdleTimeMs, -})); - -vi.mock("react-native", () => ({ - AppState: { - currentState: "active", - addEventListener: vi.fn(() => ({ - remove: vi.fn(), - })), - }, -})); - -vi.mock("@getpaseo/client/internal/daemon-client", () => ({})); - -function createTestClient() { - return { - isConnected: true, - subscribeConnectionStatus: vi.fn((listener: (state: { status: "connected" }) => void) => { - listener({ status: "connected" }); - return vi.fn(); - }), - sendHeartbeat: vi.fn<(payload: HeartbeatPayload) => void>(), - }; -} - -function latestHeartbeat(client: ReturnType): HeartbeatPayload { - const call = client.sendHeartbeat.mock.calls.at(-1); - if (!call) { - throw new Error("Expected a heartbeat"); - } - return call[0]; -} - -function heartbeatTimeMs(client: ReturnType): number { - return new Date(latestHeartbeat(client).lastActivityAt).getTime(); -} - -async function renderActivityHook({ - client = createTestClient(), - focusedAgentId = "agent-1", -}: { - client?: ReturnType; - focusedAgentId?: string | null; -} = {}) { - function Probe({ focusedAgentId: probeFocusedAgentId }: { focusedAgentId: string | null }) { - useClientActivity({ - client: client as unknown as Parameters[0]["client"], - focusedAgentId: probeFocusedAgentId, - }); - return null; - } - - const container = document.createElement("div"); - document.body.appendChild(container); - const root = createRoot(container); - - await act(async () => { - root.render(); - }); - - const rerender = async (nextFocusedAgentId: string | null) => { - await act(async () => { - root.render(); - }); - }; - - return { client, rerender, root }; -} - -async function advance(ms: number) { - await act(async () => { - await vi.advanceTimersByTimeAsync(ms); - }); -} - -describe("useClientActivity", () => { - let root: Root | null = null; - - beforeEach(() => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-04-19T10:00:00.000Z")); - - platformState.isWeb = true; - platformState.isNative = false; - platformState.isElectron = false; - getDesktopSystemIdleTimeMs.mockReset(); - - const dom = new JSDOM("", { - url: "http://localhost", - }); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - vi.stubGlobal("window", dom.window); - vi.stubGlobal("document", dom.window.document); - }); - - afterEach(async () => { - if (root) { - await act(async () => { - root?.unmount(); - }); - root = null; - } - vi.unstubAllGlobals(); - vi.useRealTimers(); - }); - - it("updates lastActivityAt from web pointer activity", async () => { - const rendered = await renderActivityHook(); - root = rendered.root; - - vi.setSystemTime(new Date("2026-04-19T10:00:05.250Z")); - window.dispatchEvent(new window.Event("pointerdown")); - - expect(heartbeatTimeMs(rendered.client)).toBe(Date.now()); - expect(getDesktopSystemIdleTimeMs).not.toHaveBeenCalled(); - }); - - it("sends one immediate heartbeat when the focused agent changes", async () => { - const rendered = await renderActivityHook(); - root = rendered.root; - - expect(rendered.client.sendHeartbeat).toHaveBeenCalledTimes(1); - rendered.client.sendHeartbeat.mockClear(); - - vi.setSystemTime(new Date("2026-04-19T10:00:05.000Z")); - await rendered.rerender("agent-2"); - - expect(rendered.client.subscribeConnectionStatus).toHaveBeenCalledTimes(1); - expect(rendered.client.sendHeartbeat).toHaveBeenCalledTimes(1); - expect(latestHeartbeat(rendered.client)).toMatchObject({ - focusedAgentId: "agent-2", - lastActivityAt: "2026-04-19T10:00:05.000Z", - }); - }); - - it("drives lastActivityAt forward from Electron idle polling", async () => { - platformState.isElectron = true; - getDesktopSystemIdleTimeMs.mockResolvedValue(0); - const rendered = await renderActivityHook(); - root = rendered.root; - - await advance(5_000); - expect(getDesktopSystemIdleTimeMs).toHaveBeenCalledTimes(1); - - await advance(10_000); - - expect(heartbeatTimeMs(rendered.client)).toBe(Date.now()); - }); - - it("sets lastActivityAt to Date.now() minus the Electron idle time", async () => { - platformState.isElectron = true; - getDesktopSystemIdleTimeMs.mockResolvedValue(2_000); - const rendered = await renderActivityHook(); - root = rendered.root; - - await advance(5_000); - await advance(10_000); - - expect(heartbeatTimeMs(rendered.client)).toBe(Date.now() - 2_000); - }); - - it("skips failed Electron idle polls", async () => { - platformState.isElectron = true; - getDesktopSystemIdleTimeMs.mockResolvedValue(null); - const rendered = await renderActivityHook(); - root = rendered.root; - const previousLastActivityAt = heartbeatTimeMs(rendered.client); - - await advance(5_000); - await advance(10_000); - - expect(heartbeatTimeMs(rendered.client)).toBe(previousLastActivityAt); - }); - - it("never moves lastActivityAt backward from an idle poll", async () => { - platformState.isElectron = true; - getDesktopSystemIdleTimeMs.mockResolvedValue(20_000); - const rendered = await renderActivityHook(); - root = rendered.root; - - vi.setSystemTime(new Date("2026-04-19T10:00:05.000Z")); - window.dispatchEvent(new window.Event("pointerdown")); - const pointerActivityAt = latestHeartbeat(rendered.client).lastActivityAt; - - await advance(5_000); - expect(getDesktopSystemIdleTimeMs).toHaveBeenCalled(); - - await advance(10_000); - - expect(latestHeartbeat(rendered.client).lastActivityAt).toBe(pointerActivityAt); - }); - - it("keeps an Electron pointerdown newer than a stale idle poll", async () => { - platformState.isElectron = true; - getDesktopSystemIdleTimeMs.mockResolvedValue(20_000); - const rendered = await renderActivityHook(); - root = rendered.root; - - await advance(4_500); - window.dispatchEvent(new window.Event("pointerdown")); - const pointerActivityAt = latestHeartbeat(rendered.client).lastActivityAt; - - await advance(500); - expect(getDesktopSystemIdleTimeMs).toHaveBeenCalled(); - - await advance(10_000); - - expect(latestHeartbeat(rendered.client).lastActivityAt).toBe(pointerActivityAt); - }); -}); diff --git a/packages/app/src/hooks/use-client-activity.ts b/packages/app/src/hooks/use-client-activity.ts index 257b59d31..3dfcb6c0c 100644 --- a/packages/app/src/hooks/use-client-activity.ts +++ b/packages/app/src/hooks/use-client-activity.ts @@ -1,13 +1,15 @@ -import { useEffect, useRef, useCallback } from "react"; +import { useEffect, useRef } from "react"; import { AppState } from "react-native"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { getIsElectron, isWeb, isNative } from "@/constants/platform"; -import { getDesktopSystemIdleTimeMs } from "@/desktop/electron/idle"; -import { useStableEvent } from "@/hooks/use-stable-event"; - -const HEARTBEAT_INTERVAL_MS = 15_000; -const ACTIVITY_HEARTBEAT_THROTTLE_MS = 5_000; -const DESKTOP_IDLE_POLL_INTERVAL_MS = 5_000; +import { readDesktopSystemIdleTimeMs } from "@/desktop/electron/idle"; +import { invokeDesktopCommand } from "@/desktop/electron/invoke"; +import { + type ClientActivityTracker, + createClientActivityTracker, + DESKTOP_IDLE_POLL_INTERVAL_MS, + HEARTBEAT_INTERVAL_MS, +} from "./client-activity-tracker"; interface ClientActivityOptions { client: DaemonClient; @@ -26,93 +28,46 @@ export function useClientActivity({ focusedAgentId, onAppResumed, }: ClientActivityOptions): void { - const lastActivityAtRef = useRef(new Date()); - const appVisibleRef = useRef(AppState.currentState === "active"); - const appVisibilityChangedAtRef = useRef(new Date()); - const backgroundedAtMsRef = useRef( - AppState.currentState === "active" ? null : Date.now(), - ); - const heartbeatIntervalRef = useRef | null>(null); - const prevFocusedAgentIdRef = useRef(focusedAgentId); - const lastImmediateHeartbeatAtRef = useRef(0); + const onAppResumedRef = useRef(onAppResumed); + onAppResumedRef.current = onAppResumed; - const deviceType = isWeb ? "web" : "mobile"; - - const recordUserActivity = useCallback(() => { - lastActivityAtRef.current = new Date(); - }, []); - - const sendHeartbeat = useStableEvent(() => { - if (!client.isConnected) return; - client.sendHeartbeat({ - deviceType, - focusedAgentId, - lastActivityAt: lastActivityAtRef.current.toISOString(), - appVisible: appVisibleRef.current, - appVisibilityChangedAt: appVisibilityChangedAtRef.current.toISOString(), + const trackerRef = useRef(null); + if (!trackerRef.current) { + trackerRef.current = createClientActivityTracker({ + client, + deviceType: isWeb ? "web" : "mobile", + initialFocusedAgentId: focusedAgentId, + initialAppVisible: AppState.currentState === "active", + now: () => Date.now(), + onAppResumed: (awayMs) => onAppResumedRef.current?.(awayMs), }); - }); + } + const tracker = trackerRef.current; - const setAppVisible = useCallback( - (nextVisible: boolean) => { - const previousVisible = appVisibleRef.current; - if (previousVisible === nextVisible) { - return; - } - appVisibleRef.current = nextVisible; - appVisibilityChangedAtRef.current = new Date(); - - if (!nextVisible) { - backgroundedAtMsRef.current = Date.now(); - return; - } - - const backgroundedAt = backgroundedAtMsRef.current; - backgroundedAtMsRef.current = null; - if (backgroundedAt !== null) { - onAppResumed?.(Math.max(0, Date.now() - backgroundedAt)); - } - recordUserActivity(); - }, - [onAppResumed, recordUserActivity], - ); - - const maybeSendImmediateHeartbeat = useCallback(() => { - if (!client.isConnected) return; - const now = Date.now(); - if (now - lastImmediateHeartbeatAtRef.current < ACTIVITY_HEARTBEAT_THROTTLE_MS) { - return; - } - lastImmediateHeartbeatAtRef.current = now; - sendHeartbeat(); - }, [client, sendHeartbeat]); - - // Track app visibility + // Track app visibility via AppState (native). useEffect(() => { const subscription = AppState.addEventListener("change", (nextState) => { - setAppVisible(nextState === "active"); - // Send immediately on visibility changes so the server can adapt streaming behavior. - sendHeartbeat(); + tracker.notifyAppVisibility(nextState === "active"); + tracker.sendHeartbeat(); }); - return () => subscription.remove(); - }, [sendHeartbeat, setAppVisible]); + }, [tracker]); - // Track user activity on web for accurate staleness. + // Track user activity and visibility on web. useEffect(() => { if (isNative) return; if (typeof document === "undefined") return; const handleUserActivity = () => { - recordUserActivity(); - maybeSendImmediateHeartbeat(); + tracker.recordUserActivity(); + tracker.maybeSendImmediateHeartbeat(); }; const handleVisibilityChange = () => { const visible = document.visibilityState === "visible"; - setAppVisible(visible); - if (visible) { - maybeSendImmediateHeartbeat(); + const { changed } = tracker.notifyAppVisibility(visible); + if (changed && visible) { + tracker.maybeSendImmediateHeartbeat(); } }; @@ -131,7 +86,7 @@ export function useClientActivity({ window.removeEventListener("wheel", handleUserActivity); window.removeEventListener("touchstart", handleUserActivity); }; - }, [maybeSendImmediateHeartbeat, recordUserActivity, setAppVisible]); + }, [tracker]); // Track OS-wide activity in Electron so backgrounded desktop windows still report presence. useEffect(() => { @@ -139,13 +94,9 @@ export function useClientActivity({ let disposed = false; const pollSystemIdleTime = async () => { - const systemIdleMs = await getDesktopSystemIdleTimeMs(); - if (disposed || systemIdleMs === null) return; - - const systemLastActivityAtMs = Date.now() - systemIdleMs; - if (systemLastActivityAtMs > lastActivityAtRef.current.getTime()) { - lastActivityAtRef.current = new Date(systemLastActivityAtMs); - } + const systemIdleMs = await readDesktopSystemIdleTimeMs(invokeDesktopCommand); + if (disposed) return; + tracker.notifySystemIdleMs(systemIdleMs); }; const interval = setInterval(() => { @@ -156,45 +107,41 @@ export function useClientActivity({ disposed = true; clearInterval(interval); }; - }, [client]); + }, [tracker]); - // Send heartbeat on focused agent change + // Send heartbeat on focused agent change. useEffect(() => { - if (prevFocusedAgentIdRef.current !== focusedAgentId) { - prevFocusedAgentIdRef.current = focusedAgentId; - recordUserActivity(); - sendHeartbeat(); - } - }, [focusedAgentId, recordUserActivity, sendHeartbeat]); + tracker.setFocusedAgentId(focusedAgentId); + }, [focusedAgentId, tracker]); - // Periodic heartbeat + // Periodic heartbeat gated by connection status. useEffect(() => { - const startHeartbeat = () => { - if (heartbeatIntervalRef.current) { - clearInterval(heartbeatIntervalRef.current); - } - sendHeartbeat(); - heartbeatIntervalRef.current = setInterval(sendHeartbeat, HEARTBEAT_INTERVAL_MS); + let intervalId: ReturnType | null = null; + + const start = () => { + if (intervalId) clearInterval(intervalId); + tracker.sendHeartbeat(); + intervalId = setInterval(() => tracker.sendHeartbeat(), HEARTBEAT_INTERVAL_MS); }; - const stopHeartbeat = () => { - if (heartbeatIntervalRef.current) { - clearInterval(heartbeatIntervalRef.current); - heartbeatIntervalRef.current = null; + const stop = () => { + if (intervalId) { + clearInterval(intervalId); + intervalId = null; } }; const unsubscribe = client.subscribeConnectionStatus((state) => { if (state.status === "connected") { - startHeartbeat(); + start(); } else { - stopHeartbeat(); + stop(); } }); return () => { unsubscribe(); - stopHeartbeat(); + stop(); }; - }, [client, sendHeartbeat]); + }, [client, tracker]); } diff --git a/packages/app/src/hooks/use-hover-safe-zone.test.tsx b/packages/app/src/hooks/use-hover-safe-zone.test.tsx deleted file mode 100644 index 3c4d31c9d..000000000 --- a/packages/app/src/hooks/use-hover-safe-zone.test.tsx +++ /dev/null @@ -1,157 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import React, { useCallback, useRef, type RefObject } from "react"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import type { View } from "react-native"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { useHoverSafeZone } from "./use-hover-safe-zone"; - -vi.mock("@/constants/platform", () => ({ - isWeb: true, -})); - -interface RectInput { - left: number; - right: number; - top: number; - bottom: number; -} - -let root: Root | null = null; -let container: HTMLElement | null = null; - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); -}); - -afterEach(() => { - if (root) { - act(() => { - root?.unmount(); - }); - } - root = null; - container?.remove(); - container = null; - vi.unstubAllGlobals(); - vi.restoreAllMocks(); -}); - -function installRect(node: HTMLDivElement | null, rect: RectInput): void { - if (!node) return; - node.getBoundingClientRect = () => - ({ - ...rect, - x: rect.left, - y: rect.top, - width: rect.right - rect.left, - height: rect.bottom - rect.top, - toJSON: () => rect, - }) as DOMRect; -} - -function Harness({ - onEnterSafeZone, - onLeaveSafeZone, -}: { - onEnterSafeZone: () => void; - onLeaveSafeZone: () => void; -}) { - const triggerRef = useRef(null); - const contentRef = useRef(null); - - useHoverSafeZone({ - enabled: true, - triggerRef: triggerRef as unknown as RefObject, - contentRef: contentRef as unknown as RefObject, - onEnterSafeZone, - onLeaveSafeZone, - }); - - const handleTriggerRef = useCallback((node: HTMLDivElement | null) => { - triggerRef.current = node; - installRect(node, { left: 0, right: 100, top: 20, bottom: 60 }); - }, []); - - const handleContentRef = useCallback((node: HTMLDivElement | null) => { - contentRef.current = node; - installRect(node, { left: 120, right: 240, top: 20, bottom: 120 }); - }, []); - - return ( - <> -
-
- - ); -} - -function pointerMove(x: number, y: number): void { - document.dispatchEvent( - new window.MouseEvent("pointermove", { - bubbles: true, - clientX: x, - clientY: y, - }), - ); -} - -describe("useHoverSafeZone", () => { - it("tracks transitions across trigger, bridge, content, and outside space", () => { - const onEnterSafeZone = vi.fn(); - const onLeaveSafeZone = vi.fn(); - - act(() => { - root?.render(); - }); - - act(() => pointerMove(110, 40)); - expect(onEnterSafeZone).toHaveBeenCalledTimes(1); - expect(onLeaveSafeZone).not.toHaveBeenCalled(); - - act(() => pointerMove(300, 40)); - expect(onLeaveSafeZone).toHaveBeenCalledTimes(1); - - act(() => pointerMove(130, 40)); - expect(onEnterSafeZone).toHaveBeenCalledTimes(2); - }); - - it("refreshes the safe-zone enter callback while moving inside", () => { - const onEnterSafeZone = vi.fn(); - const onLeaveSafeZone = vi.fn(); - - act(() => { - root?.render(); - }); - - act(() => pointerMove(110, 40)); - act(() => pointerMove(130, 40)); - - expect(onEnterSafeZone).toHaveBeenCalledTimes(2); - expect(onLeaveSafeZone).not.toHaveBeenCalled(); - }); - - it("treats leaving the browser window as leaving the safe zone", () => { - const onEnterSafeZone = vi.fn(); - const onLeaveSafeZone = vi.fn(); - - act(() => { - root?.render(); - }); - - act(() => { - window.dispatchEvent(new window.MouseEvent("pointerout", { bubbles: true })); - }); - expect(onLeaveSafeZone).toHaveBeenCalledTimes(1); - - act(() => { - window.dispatchEvent(new window.FocusEvent("blur")); - }); - expect(onLeaveSafeZone).toHaveBeenCalledTimes(1); - }); -}); diff --git a/packages/app/src/hooks/use-hover-safe-zone.ts b/packages/app/src/hooks/use-hover-safe-zone.ts index 4f4d1cb6f..487052251 100644 --- a/packages/app/src/hooks/use-hover-safe-zone.ts +++ b/packages/app/src/hooks/use-hover-safe-zone.ts @@ -1,6 +1,7 @@ import { useEffect, type RefObject } from "react"; import type { View } from "react-native"; import { isWeb } from "@/constants/platform"; +import { createHoverSafeZoneTracker, type RectLike } from "@/hooks/hover-safe-zone-tracker"; interface UseHoverSafeZoneParams { enabled: boolean; @@ -10,18 +11,11 @@ interface UseHoverSafeZoneParams { onLeaveSafeZone: () => void; } -interface RectLike { - left: number; - right: number; - top: number; - bottom: number; +function readRect(ref: RefObject): RectLike | null { + const node = ref.current as unknown as Element | null; + return node ? node.getBoundingClientRect() : null; } -// Tracks the pointer's position relative to a hover card's "safe zone": the -// trigger, the content, and the rectangular bridge between them. The bridge -// lets the pointer cross the visual gap without dropping the hover. Fires -// `onEnterSafeZone` / `onLeaveSafeZone` on transitions only. Web-only; no-op -// on native. export function useHoverSafeZone({ enabled, triggerRef, @@ -32,77 +26,34 @@ export function useHoverSafeZone({ useEffect(() => { if (!isWeb || !enabled) return; - // The pointer opened the card, so we start inside. - let wasInside = true; - - function enterSafeZone() { - if (wasInside) return; - wasInside = true; - onEnterSafeZone(); - } - - function leaveSafeZone() { - if (!wasInside) return; - wasInside = false; - onLeaveSafeZone(); - } + const tracker = createHoverSafeZoneTracker({ + getTriggerRect: () => readRect(triggerRef), + getContentRect: () => readRect(contentRef), + onEnterSafeZone, + onLeaveSafeZone, + }); function handlePointerMove(event: PointerEvent) { - const triggerNode = triggerRef.current as unknown as Element | null; - const contentNode = contentRef.current as unknown as Element | null; - const triggerRect = triggerNode ? triggerNode.getBoundingClientRect() : null; - const contentRect = contentNode ? contentNode.getBoundingClientRect() : null; - - const inside = isInsideSafeZone(triggerRect, contentRect, event.clientX, event.clientY); - if (inside) { - if (wasInside) { - onEnterSafeZone(); - return; - } - enterSafeZone(); - return; - } - leaveSafeZone(); + tracker.pointerMoved(event.clientX, event.clientY); } function handlePointerOut(event: PointerEvent) { if (event.relatedTarget === null) { - leaveSafeZone(); + tracker.pointerLeftWindow(); } } + function handleBlur() { + tracker.windowBlurred(); + } + document.addEventListener("pointermove", handlePointerMove); window.addEventListener("pointerout", handlePointerOut); - window.addEventListener("blur", leaveSafeZone); + window.addEventListener("blur", handleBlur); return () => { document.removeEventListener("pointermove", handlePointerMove); window.removeEventListener("pointerout", handlePointerOut); - window.removeEventListener("blur", leaveSafeZone); + window.removeEventListener("blur", handleBlur); }; }, [enabled, triggerRef, contentRef, onEnterSafeZone, onLeaveSafeZone]); } - -function isInsideRect(rect: RectLike | null, x: number, y: number): boolean { - if (!rect) return false; - return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; -} - -function isInsideSafeZone( - trigger: RectLike | null, - content: RectLike | null, - x: number, - y: number, -): boolean { - if (isInsideRect(trigger, x, y)) return true; - if (isInsideRect(content, x, y)) return true; - if (!trigger || !content) return false; - - // Bridge: the horizontal strip connecting trigger and content, stretched - // vertically to span both. If they overlap horizontally there's no bridge. - const bridgeLeft = Math.min(trigger.right, content.right); - const bridgeRight = Math.max(trigger.left, content.left); - if (bridgeLeft >= bridgeRight) return false; - const bridgeTop = Math.min(trigger.top, content.top); - const bridgeBottom = Math.max(trigger.bottom, content.bottom); - return x >= bridgeLeft && x <= bridgeRight && y >= bridgeTop && y <= bridgeBottom; -} diff --git a/packages/app/src/hooks/use-image-attachment-picker.ts b/packages/app/src/hooks/use-image-attachment-picker.ts index a8b428c1e..0475f3ef8 100644 --- a/packages/app/src/hooks/use-image-attachment-picker.ts +++ b/packages/app/src/hooks/use-image-attachment-picker.ts @@ -1,7 +1,7 @@ import { useCallback, useRef } from "react"; import { Alert } from "react-native"; import * as ImagePicker from "expo-image-picker"; -import { isElectronRuntime } from "@/desktop/host"; +import { getDesktopHost, isElectronRuntime } from "@/desktop/host"; import { normalizePickedImageAssets, openImagePathsWithDesktopDialog, @@ -49,7 +49,7 @@ export function useImageAttachmentPicker(): UseImageAttachmentPickerResult { try { if (isWeb && isElectronRuntime()) { - const selectedPaths = await openImagePathsWithDesktopDialog(); + const selectedPaths = await openImagePathsWithDesktopDialog(getDesktopHost()?.dialog); if (selectedPaths.length === 0) { return null; } diff --git a/packages/app/src/hooks/use-ios-hardware-keyboard-submit.test.tsx b/packages/app/src/hooks/use-ios-hardware-keyboard-submit.test.tsx deleted file mode 100644 index 86c7c64c5..000000000 --- a/packages/app/src/hooks/use-ios-hardware-keyboard-submit.test.tsx +++ /dev/null @@ -1,119 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import { act, renderHook } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { useIosHardwareKeyboardSubmit } from "./use-ios-hardware-keyboard-submit"; -import { - emitHardwareKeyboardSubmitForTest, - resetHardwareKeyboardSubmitForTest, - getHardwareKeyboardSubmitEnabledForTest, - setHardwareKeyboardSubmitEnabled, -} from "@/native/ios-hardware-keyboard-submit"; - -describe("useIosHardwareKeyboardSubmit", () => { - beforeEach(() => { - resetHardwareKeyboardSubmitForTest(); - }); - - it("submits when the focused composer receives a hardware keyboard submit event", () => { - const onSubmit = vi.fn(); - - renderHook(() => - useIosHardwareKeyboardSubmit({ - isEnabled: true, - onSubmit, - }), - ); - - act(() => { - emitHardwareKeyboardSubmitForTest(); - }); - - expect(onSubmit).toHaveBeenCalledTimes(1); - }); - - it("does not submit when the composer is blurred", () => { - const onSubmit = vi.fn(); - - renderHook(() => - useIosHardwareKeyboardSubmit({ - isEnabled: false, - onSubmit, - }), - ); - - act(() => { - emitHardwareKeyboardSubmitForTest(); - }); - - expect(onSubmit).not.toHaveBeenCalled(); - expect(getHardwareKeyboardSubmitEnabledForTest()).toBe(false); - }); - - it("does not submit while the default send action is disabled", () => { - const onSubmit = vi.fn(); - - renderHook(() => - useIosHardwareKeyboardSubmit({ - isEnabled: false, - onSubmit, - }), - ); - - act(() => { - emitHardwareKeyboardSubmitForTest(); - }); - - expect(onSubmit).not.toHaveBeenCalled(); - }); - - it("does not disable native hardware submit when mounted disabled", () => { - setHardwareKeyboardSubmitEnabled(true); - - renderHook(() => - useIosHardwareKeyboardSubmit({ - isEnabled: false, - onSubmit: vi.fn(), - }), - ); - - expect(getHardwareKeyboardSubmitEnabledForTest()).toBe(true); - }); - - it("disables native hardware submit when focus moves away", () => { - const onSubmit = vi.fn(); - const { rerender } = renderHook( - ({ isEnabled }) => - useIosHardwareKeyboardSubmit({ - isEnabled, - onSubmit, - }), - { initialProps: { isEnabled: true } }, - ); - - expect(getHardwareKeyboardSubmitEnabledForTest()).toBe(true); - - rerender({ isEnabled: false }); - - expect(getHardwareKeyboardSubmitEnabledForTest()).toBe(false); - }); - - it("unsubscribes on unmount", () => { - const onSubmit = vi.fn(); - const { unmount } = renderHook(() => - useIosHardwareKeyboardSubmit({ - isEnabled: true, - onSubmit, - }), - ); - - unmount(); - - act(() => { - emitHardwareKeyboardSubmitForTest(); - }); - - expect(onSubmit).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/app/src/hooks/use-ios-hardware-keyboard-submit.ts b/packages/app/src/hooks/use-ios-hardware-keyboard-submit.ts index b11626315..75517aebe 100644 --- a/packages/app/src/hooks/use-ios-hardware-keyboard-submit.ts +++ b/packages/app/src/hooks/use-ios-hardware-keyboard-submit.ts @@ -3,6 +3,10 @@ import { addHardwareKeyboardSubmitListener, setHardwareKeyboardSubmitEnabled, } from "@/native/ios-hardware-keyboard-submit"; +import { + createHardwareKeyboardSubmitController, + type HardwareKeyboardSubmitController, +} from "./hardware-keyboard-submit-controller"; interface UseIosHardwareKeyboardSubmitInput { isEnabled: boolean; @@ -10,25 +14,22 @@ interface UseIosHardwareKeyboardSubmitInput { } export function useIosHardwareKeyboardSubmit(input: UseIosHardwareKeyboardSubmitInput) { - const onSubmitRef = useRef(input.onSubmit); + const controllerRef = useRef(null); + if (!controllerRef.current) { + controllerRef.current = createHardwareKeyboardSubmitController({ + addListener: addHardwareKeyboardSubmitListener, + setEnabled: setHardwareKeyboardSubmitEnabled, + }); + } + const controller = controllerRef.current; - useEffect(() => { - onSubmitRef.current = input.onSubmit; - }, [input.onSubmit]); + controller.setOnSubmit(input.onSubmit); useEffect(() => { if (!input.isEnabled) { return; } - - const subscription = addHardwareKeyboardSubmitListener(() => { - onSubmitRef.current(); - }); - setHardwareKeyboardSubmitEnabled(true); - - return () => { - setHardwareKeyboardSubmitEnabled(false); - subscription.remove(); - }; - }, [input.isEnabled]); + controller.enable(); + return () => controller.disable(); + }, [controller, input.isEnabled]); } diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.ts b/packages/app/src/hooks/use-keyboard-shortcuts.ts index ee697206e..f2bdc894b 100644 --- a/packages/app/src/hooks/use-keyboard-shortcuts.ts +++ b/packages/app/src/hooks/use-keyboard-shortcuts.ts @@ -3,7 +3,7 @@ import { usePathname, useRouter } from "expo-router"; import { getIsElectronRuntime } from "@/constants/layout"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore"; -import { navigateToWorkspace } from "@/hooks/use-workspace-navigation"; +import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher"; import { type ChordState, diff --git a/packages/app/src/hooks/use-load-older-agent-history.test.ts b/packages/app/src/hooks/use-load-older-agent-history.test.ts index 1568c27c0..ca86dacf8 100644 --- a/packages/app/src/hooks/use-load-older-agent-history.test.ts +++ b/packages/app/src/hooks/use-load-older-agent-history.test.ts @@ -1,156 +1,188 @@ -// @vitest-environment jsdom - -import { act, renderHook, waitFor } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import type { ToastApi } from "@/components/toast-host"; -import { useSessionStore } from "@/stores/session-store"; +import { describe, expect, it } from "vitest"; +import type { ToastApi, ToastShowOptions } from "@/components/toast-host"; +import type { AgentTimelineCursorState } from "@/stores/session-store"; import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy"; -import { useLoadOlderAgentHistory } from "./use-load-older-agent-history"; +import { + loadOlderAgentHistory, + type LoadOlderAgentHistoryClient, +} from "./use-load-older-agent-history"; -const serverId = "server-1"; const agentId = "agent-1"; -type FetchAgentTimeline = (agentId: string, request: unknown) => Promise; +interface FakeClient extends LoadOlderAgentHistoryClient { + calls: Array<{ + agentId: string; + request: Parameters[1]; + }>; +} -function createToast(): ToastApi { +function createClient(behavior: () => Promise = async () => undefined): FakeClient { + const calls: FakeClient["calls"] = []; return { - show: vi.fn(), - copied: vi.fn(), - error: vi.fn(), + calls, + fetchAgentTimeline: async (id, request) => { + calls.push({ agentId: id, request }); + await behavior(); + }, }; } -function makeClient( - fetchAgentTimeline: FetchAgentTimeline = vi.fn().mockResolvedValue(undefined), -) { - return { fetchAgentTimeline }; +interface FakeInFlight { + values: boolean[]; + setInFlight(value: boolean): void; + get current(): boolean; } -function initialize(input?: { - cursor?: { epoch: string; startSeq: number; endSeq: number }; - hasOlder?: boolean; - inFlight?: boolean; - fetchAgentTimeline?: FetchAgentTimeline; -}) { - const client = makeClient(input?.fetchAgentTimeline); - useSessionStore.getState().initializeSession(serverId, client as never); - if (input?.cursor) { - useSessionStore.getState().setAgentTimelineCursor(serverId, new Map([[agentId, input.cursor]])); - } - if (input?.hasOlder !== undefined) { - useSessionStore - .getState() - .setAgentTimelineHasOlder(serverId, new Map([[agentId, input.hasOlder]])); - } - if (input?.inFlight !== undefined) { - useSessionStore - .getState() - .setAgentTimelineOlderFetchInFlight(serverId, new Map([[agentId, input.inFlight]])); - } - return client; +function createInFlight(initial = false): FakeInFlight { + const values: boolean[] = [initial]; + return { + values, + setInFlight(value) { + values.push(value); + }, + get current() { + return values[values.length - 1] ?? initial; + }, + }; } -afterEach(() => { - useSessionStore.setState({ sessions: {}, agentLastActivity: new Map() }); - vi.restoreAllMocks(); -}); +interface FakeToast extends ToastApi { + shown: Array<{ message: unknown; options: ToastShowOptions | undefined }>; +} -describe("useLoadOlderAgentHistory", () => { - it("no-ops without a cursor", () => { - const client = initialize({ hasOlder: true }); - const { result } = renderHook(() => useLoadOlderAgentHistory({ serverId, agentId })); +function createToast(): FakeToast { + const shown: FakeToast["shown"] = []; + return { + shown, + show: (message, options) => { + shown.push({ message, options }); + }, + copied: () => {}, + error: () => {}, + }; +} - act(() => { - result.current.loadOlder(); - }); +interface FakeLogger { + warnings: unknown[][]; + warn: (...args: unknown[]) => void; +} - expect(client.fetchAgentTimeline).not.toHaveBeenCalled(); - }); +function createLogger(): FakeLogger { + const warnings: unknown[][] = []; + return { + warnings, + warn: (...args) => { + warnings.push(args); + }, + }; +} - it("no-ops when the daemon says no older history exists", () => { - const client = initialize({ - cursor: { epoch: "epoch-1", startSeq: 10, endSeq: 20 }, - hasOlder: false, - }); - const { result } = renderHook(() => useLoadOlderAgentHistory({ serverId, agentId })); +const someCursor: AgentTimelineCursorState = { epoch: "epoch-1", startSeq: 10, endSeq: 20 }; - act(() => { - result.current.loadOlder(); - }); +describe("loadOlderAgentHistory", () => { + it("no-ops without a cursor", async () => { + const client = createClient(); + const inFlight = createInFlight(); - expect(client.fetchAgentTimeline).not.toHaveBeenCalled(); - }); - - it("dedupes concurrent older-page requests", () => { - const fetchAgentTimeline = vi.fn( - () => - new Promise((resolve) => { - setTimeout(resolve, 20); - }), - ); - const client = initialize({ - cursor: { epoch: "epoch-1", startSeq: 10, endSeq: 20 }, + await loadOlderAgentHistory(agentId, { + client, + cursor: undefined, hasOlder: true, - fetchAgentTimeline, - }); - const { result } = renderHook(() => useLoadOlderAgentHistory({ serverId, agentId })); - - act(() => { - result.current.loadOlder(); - result.current.loadOlder(); + isLoadingOlder: false, + setInFlight: inFlight.setInFlight, }); - expect(client.fetchAgentTimeline).toHaveBeenCalledTimes(1); + expect(client.calls).toEqual([]); + expect(inFlight.values).toEqual([false]); + }); + + it("no-ops when the daemon says no older history exists", async () => { + const client = createClient(); + const inFlight = createInFlight(); + + await loadOlderAgentHistory(agentId, { + client, + cursor: someCursor, + hasOlder: false, + isLoadingOlder: false, + setInFlight: inFlight.setInFlight, + }); + + expect(client.calls).toEqual([]); + expect(inFlight.values).toEqual([false]); + }); + + it("no-ops when a request is already in flight", async () => { + const client = createClient(); + const inFlight = createInFlight(true); + + await loadOlderAgentHistory(agentId, { + client, + cursor: someCursor, + hasOlder: true, + isLoadingOlder: true, + setInFlight: inFlight.setInFlight, + }); + + expect(client.calls).toEqual([]); + expect(inFlight.values).toEqual([true]); }); it("requests the page before the current start cursor and clears in-flight on success", async () => { - const client = initialize({ - cursor: { epoch: "epoch-1", startSeq: 10, endSeq: 20 }, + const client = createClient(); + const inFlight = createInFlight(); + + await loadOlderAgentHistory(agentId, { + client, + cursor: someCursor, hasOlder: true, - }); - const { result } = renderHook(() => useLoadOlderAgentHistory({ serverId, agentId })); - - act(() => { - result.current.loadOlder(); + isLoadingOlder: false, + setInFlight: inFlight.setInFlight, }); - expect(client.fetchAgentTimeline).toHaveBeenCalledWith(agentId, { - direction: "before", - cursor: { epoch: "epoch-1", seq: 10 }, - limit: TIMELINE_FETCH_PAGE_SIZE, - projection: "canonical", - }); - await waitFor(() => { - expect( - useSessionStore.getState().sessions[serverId]?.agentTimelineOlderFetchInFlight.get(agentId), - ).toBe(false); - }); + expect(client.calls).toEqual([ + { + agentId, + request: { + direction: "before", + cursor: { epoch: "epoch-1", seq: 10 }, + limit: TIMELINE_FETCH_PAGE_SIZE, + projection: "canonical", + }, + }, + ]); + expect(inFlight.values).toEqual([false, true, false]); }); - it("shows a panel toast and clears in-flight on failure", async () => { - vi.spyOn(console, "warn").mockImplementation(() => {}); + it("shows a panel toast, warns, and clears in-flight on failure", async () => { const error = new Error("network"); - const client = initialize({ - cursor: { epoch: "epoch-1", startSeq: 10, endSeq: 20 }, - hasOlder: true, - fetchAgentTimeline: vi.fn().mockRejectedValue(error), + const client = createClient(async () => { + throw error; }); + const inFlight = createInFlight(); const toast = createToast(); - const { result } = renderHook(() => useLoadOlderAgentHistory({ serverId, agentId, toast })); + const logger = createLogger(); - act(() => { - result.current.loadOlder(); + await loadOlderAgentHistory(agentId, { + client, + cursor: someCursor, + hasOlder: true, + isLoadingOlder: false, + setInFlight: inFlight.setInFlight, + toast, + logger, }); - expect(client.fetchAgentTimeline).toHaveBeenCalledTimes(1); - await waitFor(() => { - expect(toast.show).toHaveBeenCalledWith("Couldn't load older history", { - durationMs: 2200, - testID: "agent-load-older-history-toast", - }); - }); - expect( - useSessionStore.getState().sessions[serverId]?.agentTimelineOlderFetchInFlight.get(agentId), - ).toBe(false); + expect(client.calls).toHaveLength(1); + expect(toast.shown).toEqual([ + { + message: "Couldn't load older history", + options: { durationMs: 2200, testID: "agent-load-older-history-toast" }, + }, + ]); + expect(logger.warnings).toEqual([ + ["[Timeline] failed to load older agent history", agentId, error], + ]); + expect(inFlight.values).toEqual([false, true, false]); }); }); diff --git a/packages/app/src/hooks/use-load-older-agent-history.ts b/packages/app/src/hooks/use-load-older-agent-history.ts index ba8a3691f..d9260d2ab 100644 --- a/packages/app/src/hooks/use-load-older-agent-history.ts +++ b/packages/app/src/hooks/use-load-older-agent-history.ts @@ -1,8 +1,62 @@ import { useCallback } from "react"; import type { ToastApi } from "@/components/toast-host"; -import { useSessionStore } from "@/stores/session-store"; +import { useSessionStore, type AgentTimelineCursorState } from "@/stores/session-store"; import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy"; +export interface LoadOlderAgentHistoryClient { + fetchAgentTimeline: ( + agentId: string, + request: { + direction: "before"; + cursor: { epoch: string; seq: number }; + limit: number; + projection: "canonical"; + }, + ) => Promise; +} + +export interface LoadOlderAgentHistoryLogger { + warn: (...args: unknown[]) => void; +} + +export interface LoadOlderAgentHistoryDeps { + client: LoadOlderAgentHistoryClient | null; + cursor: AgentTimelineCursorState | undefined; + hasOlder: boolean; + isLoadingOlder: boolean; + setInFlight: (value: boolean) => void; + toast?: ToastApi | null; + logger?: LoadOlderAgentHistoryLogger; +} + +export async function loadOlderAgentHistory( + agentId: string, + deps: LoadOlderAgentHistoryDeps, +): Promise { + const { client, cursor, hasOlder, isLoadingOlder, setInFlight, toast, logger } = deps; + if (!client || !cursor || !hasOlder || isLoadingOlder) { + return; + } + + setInFlight(true); + try { + await client.fetchAgentTimeline(agentId, { + direction: "before", + cursor: { epoch: cursor.epoch, seq: cursor.startSeq }, + limit: TIMELINE_FETCH_PAGE_SIZE, + projection: "canonical", + }); + } catch (error) { + (logger ?? console).warn("[Timeline] failed to load older agent history", agentId, error); + toast?.show("Couldn't load older history", { + durationMs: 2200, + testID: "agent-load-older-history-toast", + }); + } finally { + setInFlight(false); + } +} + export function useLoadOlderAgentHistory({ serverId, agentId, @@ -12,10 +66,6 @@ export function useLoadOlderAgentHistory({ agentId: string; toast?: ToastApi | null; }) { - const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); - const cursor = useSessionStore((state) => - state.sessions[serverId]?.agentTimelineCursor.get(agentId), - ); const hasOlder = useSessionStore((state) => state.sessions[serverId]?.agentTimelineHasOlder.get(agentId)) === true; @@ -42,31 +92,16 @@ export function useLoadOlderAgentHistory({ ); const loadOlder = useCallback(() => { - const latestSession = useSessionStore.getState().sessions[serverId]; - const latestIsLoading = latestSession?.agentTimelineOlderFetchInFlight.get(agentId) === true; - if (!client || !cursor || !hasOlder || isLoadingOlder || latestIsLoading) { - return; - } - - setInFlight(true); - void client - .fetchAgentTimeline(agentId, { - direction: "before", - cursor: { epoch: cursor.epoch, seq: cursor.startSeq }, - limit: TIMELINE_FETCH_PAGE_SIZE, - projection: "canonical", - }) - .catch((error) => { - console.warn("[Timeline] failed to load older agent history", agentId, error); - toast?.show("Couldn't load older history", { - durationMs: 2200, - testID: "agent-load-older-history-toast", - }); - }) - .finally(() => { - setInFlight(false); - }); - }, [agentId, client, cursor, hasOlder, isLoadingOlder, serverId, setInFlight, toast]); + const session = useSessionStore.getState().sessions[serverId]; + void loadOlderAgentHistory(agentId, { + client: (session?.client ?? null) as LoadOlderAgentHistoryClient | null, + cursor: session?.agentTimelineCursor.get(agentId), + hasOlder: session?.agentTimelineHasOlder.get(agentId) === true, + isLoadingOlder: session?.agentTimelineOlderFetchInFlight.get(agentId) === true, + setInFlight, + toast, + }); + }, [agentId, serverId, setInFlight, toast]); return { isLoadingOlder, diff --git a/packages/app/src/hooks/use-open-project.test.ts b/packages/app/src/hooks/use-open-project.test.ts index 41073b136..b291dad7a 100644 --- a/packages/app/src/hooks/use-open-project.test.ts +++ b/packages/app/src/hooks/use-open-project.test.ts @@ -1,129 +1,129 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -vi.hoisted(() => { - (globalThis as typeof globalThis & { __DEV__?: boolean }).__DEV__ = false; -}); - -vi.mock("@react-native-async-storage/async-storage", () => { - const storage = new Map(); - return { - default: { - getItem: vi.fn(async (key: string) => storage.get(key) ?? null), - setItem: vi.fn(async (key: string, value: string) => { - storage.set(key, value); - }), - removeItem: vi.fn(async (key: string) => { - storage.delete(key); - }), - }, - }; -}); - -const { replaceRoute } = vi.hoisted(() => ({ - replaceRoute: vi.fn(), -})); - -vi.mock("@/hooks/use-workspace-navigation", () => ({ - navigateToWorkspace: replaceRoute, -})); - -import { openProjectDirectly } from "@/hooks/use-open-project"; -import { useSessionStore } from "@/stores/session-store"; -import { - buildWorkspaceTabPersistenceKey, - collectAllTabs, - useWorkspaceLayoutStore, -} from "@/stores/workspace-layout-store"; -import { generateDraftId } from "@/stores/draft-keys"; +import { describe, expect, it, vi } from "vitest"; +import { openProjectDirectly } from "@/hooks/open-project"; +import type { WorkspaceDescriptor } from "@/stores/session-store"; const SERVER_ID = "server-1"; -const WORKSPACE_ID = "/repo/project"; +const PROJECT_PATH = "/repo/project"; -function createOpenDraftTab() { - return (workspaceKey: string) => - useWorkspaceLayoutStore.getState().openTabFocused(workspaceKey, { - kind: "draft", - draftId: generateDraftId(), - }); +function buildWorkspacePayload() { + return { + id: "1", + projectId: "1", + projectDisplayName: "project", + projectRootPath: PROJECT_PATH, + workspaceDirectory: PROJECT_PATH, + projectKind: "git" as const, + workspaceKind: "checkout" as const, + name: "project", + archivingAt: null, + status: "done" as const, + activityAt: null, + diffStat: null, + scripts: [], + }; +} + +interface RecordedMerge { + serverId: string; + workspaces: WorkspaceDescriptor[]; +} + +interface RecordedHydrated { + serverId: string; + hydrated: boolean; +} + +interface RecordedOpenDraftTab { + workspaceKey: string; +} + +interface RecordedNavigate { + serverId: string; + workspaceId: string; +} + +function createFakeSession() { + const merges: RecordedMerge[] = []; + const hydrated: RecordedHydrated[] = []; + return { + merges, + hydrated, + mergeWorkspaces: (serverId: string, workspaces: Iterable) => { + merges.push({ serverId, workspaces: Array.from(workspaces) }); + }, + setHasHydratedWorkspaces: (serverId: string, value: boolean) => { + hydrated.push({ serverId, hydrated: value }); + }, + }; +} + +function createFakeWorkspaceLayout() { + const openedTabs: RecordedOpenDraftTab[] = []; + return { + openedTabs, + openDraftTab: (workspaceKey: string) => { + openedTabs.push({ workspaceKey }); + return "tab-1"; + }, + }; +} + +function createFakeNavigator() { + const navigations: RecordedNavigate[] = []; + return { + navigations, + navigateToWorkspace: (serverId: string, workspaceId: string) => { + navigations.push({ serverId, workspaceId }); + }, + }; } describe("openProjectDirectly", () => { - beforeEach(() => { - replaceRoute.mockReset(); - useSessionStore.setState({ - sessions: {}, - }); - useSessionStore.getState().initializeSession(SERVER_ID, {} as never); - useWorkspaceLayoutStore.setState({ - layoutByWorkspace: {}, - splitSizesByWorkspace: {}, - pinnedAgentIdsByWorkspace: {}, - }); - vi.restoreAllMocks(); - }); + it("opens the workspace, marks workspaces hydrated, and seeds a draft tab", async () => { + const session = createFakeSession(); + const layout = createFakeWorkspaceLayout(); + const navigator = createFakeNavigator(); + const workspacePayload = buildWorkspacePayload(); - it("opens the workspace directly, marks workspaces hydrated, and seeds a draft tab", async () => { const result = await openProjectDirectly({ serverId: SERVER_ID, - projectPath: WORKSPACE_ID, + projectPath: PROJECT_PATH, isConnected: true, client: { openProject: vi.fn(async () => ({ requestId: "request-1", error: null, - workspace: { - id: "1", - projectId: "1", - projectDisplayName: "project", - projectRootPath: WORKSPACE_ID, - workspaceDirectory: WORKSPACE_ID, - projectKind: "git" as const, - workspaceKind: "checkout" as const, - name: "project", - archivingAt: null, - status: "done" as const, - activityAt: null, - diffStat: null, - scripts: [], - }, + workspace: workspacePayload, })), }, - mergeWorkspaces: useSessionStore.getState().mergeWorkspaces, - setHasHydratedWorkspaces: useSessionStore.getState().setHasHydratedWorkspaces, - openDraftTab: createOpenDraftTab(), - navigateToWorkspace: replaceRoute, + mergeWorkspaces: session.mergeWorkspaces, + setHasHydratedWorkspaces: session.setHasHydratedWorkspaces, + openDraftTab: layout.openDraftTab, + navigateToWorkspace: navigator.navigateToWorkspace, }); expect(result).toBe(true); - expect(useSessionStore.getState().sessions[SERVER_ID]?.hasHydratedWorkspaces).toBe(true); - expect( - Array.from(useSessionStore.getState().sessions[SERVER_ID]?.workspaces.values() ?? []), - ).toEqual([ - expect.objectContaining({ - id: "1", - projectId: "1", - projectRootPath: WORKSPACE_ID, - workspaceDirectory: WORKSPACE_ID, - }), - ]); - - const workspaceKey = buildWorkspaceTabPersistenceKey({ - serverId: SERVER_ID, - workspaceId: "1", + expect(session.merges).toHaveLength(1); + expect(session.merges[0]?.serverId).toBe(SERVER_ID); + expect(session.merges[0]?.workspaces[0]).toMatchObject({ + id: "1", + projectId: "1", + projectRootPath: PROJECT_PATH, + workspaceDirectory: PROJECT_PATH, }); - expect(workspaceKey).toBeTruthy(); - const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey as string]; - expect(layout.root.kind).toBe("pane"); - const tabs = collectAllTabs(layout.root); - expect(tabs).toHaveLength(1); - expect(tabs[0]?.target.kind).toBe("draft"); - expect(replaceRoute).toHaveBeenCalledWith("server-1", "1"); + expect(session.hydrated).toEqual([{ serverId: SERVER_ID, hydrated: true }]); + expect(layout.openedTabs).toEqual([{ workspaceKey: `${SERVER_ID}:1` }]); + expect(navigator.navigations).toEqual([{ serverId: SERVER_ID, workspaceId: "1" }]); }); it("does not navigate or seed tabs when openProject fails", async () => { + const session = createFakeSession(); + const layout = createFakeWorkspaceLayout(); + const navigator = createFakeNavigator(); + const result = await openProjectDirectly({ serverId: SERVER_ID, - projectPath: WORKSPACE_ID, + projectPath: PROJECT_PATH, isConnected: true, client: { openProject: vi.fn(async () => ({ @@ -132,16 +132,16 @@ describe("openProjectDirectly", () => { workspace: null, })), }, - mergeWorkspaces: useSessionStore.getState().mergeWorkspaces, - setHasHydratedWorkspaces: useSessionStore.getState().setHasHydratedWorkspaces, - openDraftTab: createOpenDraftTab(), - navigateToWorkspace: replaceRoute, + mergeWorkspaces: session.mergeWorkspaces, + setHasHydratedWorkspaces: session.setHasHydratedWorkspaces, + openDraftTab: layout.openDraftTab, + navigateToWorkspace: navigator.navigateToWorkspace, }); expect(result).toBe(false); - expect(useSessionStore.getState().sessions[SERVER_ID]?.hasHydratedWorkspaces).toBe(false); - expect(useSessionStore.getState().sessions[SERVER_ID]?.workspaces.size).toBe(0); - expect(useWorkspaceLayoutStore.getState().layoutByWorkspace).toEqual({}); - expect(replaceRoute).not.toHaveBeenCalled(); + expect(session.merges).toEqual([]); + expect(session.hydrated).toEqual([]); + expect(layout.openedTabs).toEqual([]); + expect(navigator.navigations).toEqual([]); }); }); diff --git a/packages/app/src/hooks/use-open-project.ts b/packages/app/src/hooks/use-open-project.ts index 2fc7bb949..cf09064c9 100644 --- a/packages/app/src/hooks/use-open-project.ts +++ b/packages/app/src/hooks/use-open-project.ts @@ -1,57 +1,10 @@ import { useCallback } from "react"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; -import { - normalizeWorkspaceDescriptor, - type WorkspaceDescriptor, - useSessionStore, -} from "@/stores/session-store"; -import { - buildWorkspaceTabPersistenceKey, - useWorkspaceLayoutStore, -} from "@/stores/workspace-layout-store"; +import { useSessionStore } from "@/stores/session-store"; +import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { generateDraftId } from "@/stores/draft-keys"; -import { navigateToWorkspace } from "@/hooks/use-workspace-navigation"; - -interface OpenProjectDirectlyInput { - serverId: string; - projectPath: string; - isConnected: boolean; - client: Pick | null; - mergeWorkspaces: (serverId: string, workspaces: Iterable) => void; - setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void; - openDraftTab: (workspaceKey: string) => string | null; - navigateToWorkspace: (serverId: string, workspaceId: string) => void; -} - -export async function openProjectDirectly(input: OpenProjectDirectlyInput): Promise { - const normalizedServerId = input.serverId.trim(); - const trimmedPath = input.projectPath.trim(); - if (!normalizedServerId || !trimmedPath || !input.client || !input.isConnected) { - return false; - } - - const payload = await input.client.openProject(trimmedPath); - if (payload.error || !payload.workspace) { - return false; - } - - const workspace = normalizeWorkspaceDescriptor(payload.workspace); - input.mergeWorkspaces(normalizedServerId, [workspace]); - input.setHasHydratedWorkspaces(normalizedServerId, true); - - const workspaceKey = buildWorkspaceTabPersistenceKey({ - serverId: normalizedServerId, - workspaceId: workspace.id, - }); - if (!workspaceKey) { - return false; - } - - input.openDraftTab(workspaceKey); - input.navigateToWorkspace(normalizedServerId, workspace.id); - return true; -} +import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; +import { openProjectDirectly } from "@/hooks/open-project"; export function useOpenProject(serverId: string | null): (path: string) => Promise { const normalizedServerId = serverId?.trim() ?? ""; diff --git a/packages/app/src/hooks/use-pr-pane-data.test.ts b/packages/app/src/hooks/use-pr-pane-data.test.ts index a19d5b9e5..7070b4fcb 100644 --- a/packages/app/src/hooks/use-pr-pane-data.test.ts +++ b/packages/app/src/hooks/use-pr-pane-data.test.ts @@ -1,58 +1,22 @@ -import React, { act } from "react"; -import { createRoot } from "react-dom/client"; -import { JSDOM } from "jsdom"; -import { - QueryClient, - QueryClientProvider, - focusManager, - onlineManager, -} from "@tanstack/react-query"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import type { CheckoutPrStatusResponse, PullRequestTimelineResponse, } from "@getpaseo/protocol/messages"; -import { checkoutPrStatusQueryKey, prPaneTimelineQueryKey } from "@/git/query-keys"; -import { usePrPaneData, type UsePrPaneDataResult } from "./use-pr-pane-data"; -import { useWorkspacePrHint } from "@/git/use-pr-status-query"; +import { + createInMemoryUnsupportedTimelineRegistry, + extractPrRepoIdentity, + fetchPrPaneTimelinePage, + isUnsupportedTimelineError, + type PrPaneTimelineClient, + selectPrPaneState, + shouldFetchTimelineFrom, + unsupportedTimelineKey, +} from "./use-pr-pane-data"; type CheckoutPrStatus = NonNullable; -type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"]; type PullRequestTimelinePayload = PullRequestTimelineResponse["payload"]; - -const { mockRuntime, mockClient, checkoutStatusUpdateHandlers } = vi.hoisted(() => { - const hoistedHandlers = new Set<(message: unknown) => void>(); - const hoistedClient = { - checkoutPrStatus: vi.fn(), - pullRequestTimeline: vi.fn(), - on: vi.fn((type: string, handler: (message: unknown) => void) => { - if (type !== "checkout_status_update") { - return () => {}; - } - hoistedHandlers.add(handler); - return () => { - hoistedHandlers.delete(handler); - }; - }), - }; - - return { - mockClient: hoistedClient, - checkoutStatusUpdateHandlers: hoistedHandlers, - mockRuntime: { - client: hoistedClient, - isConnected: true, - }, - }; -}); - -vi.mock("@/runtime/host-runtime", () => ({ - useHostRuntimeClient: () => mockRuntime.client, - useHostRuntimeIsConnected: () => mockRuntime.isConnected, -})); - -const cwd = "/repo"; -const serverId = "server-1"; +type PullRequestTimelineInput = Parameters[0]; const githubStatus: CheckoutPrStatus["github"] = { mergeStateStatus: null, @@ -72,7 +36,7 @@ const githubStatus: CheckoutPrStatus["github"] = { isInMergeQueue: false, }; -function status(overrides: Partial = {}): CheckoutPrStatus { +function prStatus(overrides: Partial = {}): CheckoutPrStatus { return { number: 42, url: "https://github.com/getpaseo/paseo/pull/42", @@ -92,22 +56,11 @@ function status(overrides: Partial = {}): CheckoutPrStatus { }; } -function statusPayload(overrides: Partial = {}): CheckoutPrStatusPayload { - return { - cwd, - status: status(), - githubFeaturesEnabled: true, - error: null, - requestId: "status-1", - ...overrides, - }; -} - function timelinePayload( overrides: Partial = {}, ): PullRequestTimelinePayload { return { - cwd, + cwd: "/repo", prNumber: 42, items: [], truncated: false, @@ -118,37 +71,26 @@ function timelinePayload( }; } -async function emitCheckoutStatusUpdatePrStatus(payload: CheckoutPrStatusPayload): Promise { - await act(async () => { - for (const handler of checkoutStatusUpdateHandlers) { - handler({ - type: "checkout_status_update", - payload: { - cwd: payload.cwd, - requestId: `subscription:${payload.cwd}`, - isGit: true, - isPaseoOwnedWorktree: false, - repoRoot: payload.cwd, - currentBranch: "main", - isDirty: false, - baseRef: "main", - aheadBehind: { ahead: 0, behind: 0 }, - aheadOfOrigin: 0, - behindOfOrigin: 0, - hasRemote: true, - remoteUrl: "https://github.com/getpaseo/paseo.git", - error: null, - prStatus: payload, - }, - }); - } - await Promise.resolve(); - }); +interface FakeTimelineClient extends PrPaneTimelineClient { + calls: PullRequestTimelineInput[]; +} + +function createTimelineClient( + respond: (input: PullRequestTimelineInput) => Promise, +): FakeTimelineClient { + const calls: PullRequestTimelineInput[] = []; + return { + calls, + pullRequestTimeline: async (input) => { + calls.push(input); + return respond(input); + }, + }; } function unsupportedTimelineError(): Error { const error = new Error( - "Unknown request, try upgrading the daemon (currently v0.1.78) requestType=pull_request_timeline_request code=unknown_schema", + "Unknown request, try upgrading the daemon requestType=pull_request_timeline_request code=unknown_schema", ) as Error & { code: string; requestType: string }; error.name = "DaemonRpcError"; error.code = "unknown_schema"; @@ -156,644 +98,415 @@ function unsupportedTimelineError(): Error { return error; } -function createTestQueryClient(): QueryClient { - return new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, +const baseSelectInput = { + status: null as CheckoutPrStatus | null, + statusPayloadError: null, + statusError: null as Error | null, + statusIsLoading: false, + statusIsFetching: false, + githubFeaturesEnabled: true, + timelineEnabled: true, + shouldFetchTimeline: true, + timelinePayload: undefined as PullRequestTimelinePayload | undefined, + timelineError: null as Error | null, + timelineIsLoading: false, + timelineIsFetching: false, +}; + +describe("extractPrRepoIdentity", () => { + it("reads the PR number, owner, and name from a status payload", () => { + expect(extractPrRepoIdentity(prStatus())).toEqual({ + prNumber: 42, + repoOwner: "getpaseo", + repoName: "paseo", + }); }); -} -function renderPrPaneHook({ - queryClient = createTestQueryClient(), - options = { serverId, cwd }, -}: { - queryClient?: QueryClient; - options?: Parameters[0]; -} = {}) { - let latest: UsePrPaneDataResult | null = null; - let currentOptions = options; + it("returns null fields when the status is missing", () => { + expect(extractPrRepoIdentity(null)).toEqual({ + prNumber: null, + repoOwner: null, + repoName: null, + }); + }); - function Probe({ hookOptions }: { hookOptions: Parameters[0] }) { - latest = usePrPaneData(hookOptions); - return null; - } + it("treats empty owner or name strings as missing", () => { + expect(extractPrRepoIdentity(prStatus({ repoOwner: "", repoName: "" }))).toMatchObject({ + repoOwner: null, + repoName: null, + }); + }); - const container = document.getElementById("root"); - if (!container) { - throw new Error("Missing root container"); - } + it("reports no PR number when the status omits it", () => { + const status = prStatus(); + delete status.number; + expect(extractPrRepoIdentity(status).prNumber).toBeNull(); + }); +}); - const root = createRoot(container); +describe("shouldFetchTimelineFrom", () => { + const baseGate = { + hasClient: true, + isConnected: true, + timelineEnabled: true, + githubFeaturesEnabled: true, + cwd: "/repo", + identity: { prNumber: 42, repoOwner: "getpaseo", repoName: "paseo" }, + timelineUnsupported: false, + }; - function render() { - root.render( - React.createElement( - QueryClientProvider, - { client: queryClient }, - React.createElement(Probe, { hookOptions: currentOptions }), - ), + it("fetches when every gate is open", () => { + expect(shouldFetchTimelineFrom(baseGate)).toBe(true); + }); + + it.each([ + { name: "no client", overrides: { hasClient: false } }, + { name: "disconnected", overrides: { isConnected: false } }, + { name: "timeline disabled", overrides: { timelineEnabled: false } }, + { name: "github features off", overrides: { githubFeaturesEnabled: false } }, + { name: "empty cwd", overrides: { cwd: "" } }, + { + name: "missing PR number", + overrides: { identity: { ...baseGate.identity, prNumber: null } }, + }, + { name: "missing owner", overrides: { identity: { ...baseGate.identity, repoOwner: null } } }, + { name: "missing name", overrides: { identity: { ...baseGate.identity, repoName: null } } }, + { name: "tuple marked unsupported", overrides: { timelineUnsupported: true } }, + ])("skips fetching when $name", ({ overrides }) => { + expect(shouldFetchTimelineFrom({ ...baseGate, ...overrides })).toBe(false); + }); +}); + +describe("createInMemoryUnsupportedTimelineRegistry", () => { + it("remembers added keys and answers has() against them", () => { + const registry = createInMemoryUnsupportedTimelineRegistry(); + const key = unsupportedTimelineKey({ serverId: "host", cwd: "/repo", prNumber: 99 }); + + expect(registry.has(key)).toBe(false); + registry.add(key); + expect(registry.has(key)).toBe(true); + }); + + it("uses serverId, cwd, and prNumber to scope each key", () => { + expect(unsupportedTimelineKey({ serverId: "host-a", cwd: "/repo", prNumber: 1 })).not.toEqual( + unsupportedTimelineKey({ serverId: "host-b", cwd: "/repo", prNumber: 1 }), ); - } + expect(unsupportedTimelineKey({ serverId: "host", cwd: "/repo-a", prNumber: 1 })).not.toEqual( + unsupportedTimelineKey({ serverId: "host", cwd: "/repo-b", prNumber: 1 }), + ); + expect(unsupportedTimelineKey({ serverId: "host", cwd: "/repo", prNumber: 1 })).not.toEqual( + unsupportedTimelineKey({ serverId: "host", cwd: "/repo", prNumber: 2 }), + ); + }); +}); - return { - get latest() { - if (!latest) { - throw new Error("Expected hook result"); - } - return latest; - }, - queryClient, - async mount() { - await act(async () => { - render(); - }); - }, - async rerender(nextOptions: Parameters[0]) { - currentOptions = nextOptions; - await act(async () => { - render(); - }); - }, - async unmount() { - await act(async () => { - root.unmount(); - }); - }, - }; -} +describe("fetchPrPaneTimelinePage", () => { + it("forwards the cwd, PR number, owner, and name to the daemon client", async () => { + const client = createTimelineClient(async () => timelinePayload()); + const registry = createInMemoryUnsupportedTimelineRegistry(); -function renderSharedStatusConsumers({ queryClient = createTestQueryClient() } = {}) { - let latest: UsePrPaneDataResult | null = null; - - function Probe() { - latest = usePrPaneData({ serverId, cwd }); - useWorkspacePrHint({ serverId, cwd }); - return null; - } - - const container = document.getElementById("root"); - if (!container) { - throw new Error("Missing root container"); - } - - const root = createRoot(container); - - return { - get latest() { - if (!latest) { - throw new Error("Expected hook result"); - } - return latest; - }, - async mount() { - await act(async () => { - root.render( - React.createElement( - QueryClientProvider, - { client: queryClient }, - React.createElement(Probe), - ), - ); - }); - }, - }; -} - -async function waitForExpectation(assertion: () => void): Promise { - let lastError: unknown; - - for (let attempt = 0; attempt < 60; attempt += 1) { - try { - assertion(); - return; - } catch (error) { - lastError = error; - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 10)); - }); - } - } - - throw lastError; -} - -describe("usePrPaneData", () => { - beforeEach(() => { - const dom = new JSDOM("
", { - url: "http://localhost", + await fetchPrPaneTimelinePage({ + client, + registry, + serverId: "host", + cwd: "/repo", + prNumber: 42, + repoOwner: "getpaseo", + repoName: "paseo", }); - Object.defineProperty(globalThis, "document", { - value: dom.window.document, - configurable: true, - }); - Object.defineProperty(globalThis, "window", { - value: dom.window, - configurable: true, - }); - Object.defineProperty(globalThis, "navigator", { - value: dom.window.navigator, - configurable: true, - }); - Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { - value: true, - configurable: true, - }); - - mockRuntime.client = mockClient; - mockRuntime.isConnected = true; - mockClient.checkoutPrStatus.mockReset(); - mockClient.pullRequestTimeline.mockReset(); - mockClient.on.mockClear(); - checkoutStatusUpdateHandlers.clear(); + expect(client.calls).toEqual([ + { cwd: "/repo", prNumber: 42, repoOwner: "getpaseo", repoName: "paseo" }, + ]); }); - afterEach(() => { - focusManager.setFocused(undefined); - onlineManager.setOnline(true); - vi.useRealTimers(); - }); + it("returns the daemon's timeline payload on success", async () => { + const payload = timelinePayload({ + items: [ + { + id: "comment-1", + kind: "comment", + author: "octocat", + body: "Looks good", + createdAt: Date.now(), + url: "https://github.com/getpaseo/paseo/pull/42#c1", + }, + ], + }); + const client = createTimelineClient(async () => payload); + const registry = createInMemoryUnsupportedTimelineRegistry(); - it("returns null when status has no PR number", async () => { - const statusWithoutNumber = status(); - delete statusWithoutNumber.number; - mockClient.checkoutPrStatus.mockResolvedValue(statusPayload({ status: statusWithoutNumber })); - mockClient.pullRequestTimeline.mockResolvedValue(timelinePayload()); - - const hook = renderPrPaneHook(); - await hook.mount(); - - await waitForExpectation(() => { - expect(hook.latest.isLoading).toBe(false); + const result = await fetchPrPaneTimelinePage({ + client, + registry, + serverId: "host", + cwd: "/repo", + prNumber: 42, + repoOwner: "getpaseo", + repoName: "paseo", }); - expect(hook.latest.data).toBeNull(); - expect(mockClient.pullRequestTimeline).not.toHaveBeenCalled(); + expect(result).toBe(payload); }); - it("wires the timeline query only when a PR number is known", async () => { - mockClient.checkoutPrStatus.mockResolvedValue(statusPayload({ status: null })); - mockClient.pullRequestTimeline.mockResolvedValue(timelinePayload()); - - const hook = renderPrPaneHook(); - await hook.mount(); - - await waitForExpectation(() => { - expect(hook.latest.isLoading).toBe(false); + it("records the tuple in the registry when the daemon rejects the request as unsupported", async () => { + const error = unsupportedTimelineError(); + const client = createTimelineClient(async () => { + throw error; }); + const registry = createInMemoryUnsupportedTimelineRegistry(); - expect(mockClient.pullRequestTimeline).not.toHaveBeenCalled(); - expect( - hook.queryClient.getQueryState(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 42 })), - ).toBeUndefined(); - - mockClient.checkoutPrStatus.mockResolvedValue(statusPayload()); - hook.queryClient.invalidateQueries({ queryKey: checkoutPrStatusQueryKey(serverId, cwd) }); - - await waitForExpectation(() => { - expect(mockClient.pullRequestTimeline).toHaveBeenCalledWith({ - cwd, - prNumber: 42, + await expect( + fetchPrPaneTimelinePage({ + client, + registry, + serverId: "host", + cwd: "/repo", + prNumber: 99, repoOwner: "getpaseo", repoName: "paseo", - }); - }); - }); - - it("checks PR status while timeline activity is disabled", async () => { - mockClient.checkoutPrStatus.mockResolvedValue(statusPayload()); - mockClient.pullRequestTimeline.mockResolvedValue(timelinePayload()); - - const hook = renderPrPaneHook({ - options: { serverId, cwd, enabled: true, timelineEnabled: false }, - }); - await hook.mount(); - - await waitForExpectation(() => { - expect(hook.latest.prNumber).toBe(42); - }); - - expect(hook.latest.data).toBeNull(); - expect(mockClient.checkoutPrStatus).toHaveBeenCalledTimes(1); - expect(mockClient.pullRequestTimeline).not.toHaveBeenCalled(); - }); - - it("does not request timeline activity until the PR repo identity is known", async () => { - mockClient.checkoutPrStatus.mockResolvedValue( - statusPayload({ - status: status({ - repoOwner: undefined, - repoName: undefined, - }), }), - ); - mockClient.pullRequestTimeline.mockResolvedValue(timelinePayload()); + ).rejects.toBe(error); - const hook = renderPrPaneHook(); - await hook.mount(); - - await waitForExpectation(() => { - expect(hook.latest.isLoading).toBe(false); - }); - - expect(hook.latest.data?.number).toBe(42); - expect(hook.latest.data?.activity).toEqual([]); - expect(mockClient.pullRequestTimeline).not.toHaveBeenCalled(); - }); - - it("shares the checkout PR status query with workspace hint consumers", async () => { - mockClient.checkoutPrStatus.mockResolvedValue(statusPayload()); - mockClient.pullRequestTimeline.mockResolvedValue(timelinePayload()); - - const hook = renderSharedStatusConsumers(); - await hook.mount(); - - await waitForExpectation(() => { - expect(hook.latest.data?.number).toBe(42); - }); - - expect(mockClient.checkoutPrStatus).toHaveBeenCalledTimes(1); - }); - - it("server-pushed PR status update writes into the checkout PR status cache", async () => { - const queryClient = createTestQueryClient(); - mockClient.checkoutPrStatus.mockResolvedValue( - statusPayload({ - status: status({ - checksStatus: "pending", - checks: [{ name: "test", status: "pending", url: "https://github.com/checks/1" }], - }), - }), - ); - mockClient.pullRequestTimeline.mockResolvedValue(timelinePayload()); - - const hook = renderPrPaneHook({ queryClient }); - await hook.mount(); - await waitForExpectation(() => { - expect(hook.latest.data?.checks[0]?.status).toBe("pending"); - }); - - mockClient.checkoutPrStatus.mockClear(); - await emitCheckoutStatusUpdatePrStatus( - statusPayload({ - requestId: "server-push", - status: status({ - checksStatus: "success", - checks: [{ name: "test", status: "success", url: "https://github.com/checks/1" }], - }), - }), - ); - - await waitForExpectation(() => { - expect(hook.latest.data?.checks[0]?.status).toBe("success"); - }); - expect(queryClient.getQueryData(checkoutPrStatusQueryKey(serverId, cwd))).toEqual( - statusPayload({ - requestId: "server-push", - status: status({ - checksStatus: "success", - checks: [{ name: "test", status: "success", url: "https://github.com/checks/1" }], - }), - }), - ); - expect(mockClient.checkoutPrStatus).not.toHaveBeenCalled(); - }); - - it("server-pushed PR status for a different cwd does not overwrite the current cache", async () => { - const queryClient = createTestQueryClient(); - const initial = statusPayload({ - status: status({ - checksStatus: "pending", - checks: [{ name: "test", status: "pending", url: "https://github.com/checks/1" }], - }), - }); - mockClient.checkoutPrStatus.mockResolvedValue(initial); - mockClient.pullRequestTimeline.mockResolvedValue(timelinePayload()); - - const hook = renderPrPaneHook({ queryClient }); - await hook.mount(); - await waitForExpectation(() => { - expect(hook.latest.data?.checks[0]?.status).toBe("pending"); - }); - - await emitCheckoutStatusUpdatePrStatus( - statusPayload({ - cwd: "/other-repo", - requestId: "other-server-push", - status: status({ - checksStatus: "success", - checks: [{ name: "test", status: "success", url: "https://github.com/checks/1" }], - }), - }), - ); - - expect(queryClient.getQueryData(checkoutPrStatusQueryKey(serverId, cwd))).toEqual(initial); expect( - queryClient.getQueryData(checkoutPrStatusQueryKey(serverId, "/other-repo")), - ).toBeUndefined(); + registry.has(unsupportedTimelineKey({ serverId: "host", cwd: "/repo", prNumber: 99 })), + ).toBe(true); }); - it("passes repoOwner and repoName to the timeline request when present", async () => { - mockClient.checkoutPrStatus.mockResolvedValue( - statusPayload({ - status: { - ...status(), - repoOwner: "fork-parent", - repoName: "paseo", - }, - }), - ); - mockClient.pullRequestTimeline.mockResolvedValue(timelinePayload()); - - const hook = renderPrPaneHook(); - await hook.mount(); - - await waitForExpectation(() => { - expect(mockClient.pullRequestTimeline).toHaveBeenCalledWith({ - cwd, - prNumber: 42, - repoOwner: "fork-parent", - repoName: "paseo", - }); + it("leaves the registry alone when the failure is a generic error", async () => { + const error = new Error("network down"); + const client = createTimelineClient(async () => { + throw error; }); + const registry = createInMemoryUnsupportedTimelineRegistry(); + + await expect( + fetchPrPaneTimelinePage({ + client, + registry, + serverId: "host", + cwd: "/repo", + prNumber: 99, + repoOwner: "getpaseo", + repoName: "paseo", + }), + ).rejects.toBe(error); + + expect( + registry.has(unsupportedTimelineKey({ serverId: "host", cwd: "/repo", prNumber: 99 })), + ).toBe(false); }); - it("lets the mapper reject stale timeline activity for a mismatched PR number", async () => { - mockClient.checkoutPrStatus.mockResolvedValue(statusPayload()); - mockClient.pullRequestTimeline.mockResolvedValue( - timelinePayload({ + it("scopes recorded tuples per serverId+cwd+prNumber so other PRs can still be tried", async () => { + const client = createTimelineClient(async (input) => { + if (input.cwd === "/repo-a") { + throw unsupportedTimelineError(); + } + return timelinePayload({ cwd: input.cwd, prNumber: input.prNumber }); + }); + const registry = createInMemoryUnsupportedTimelineRegistry(); + + await expect( + fetchPrPaneTimelinePage({ + client, + registry, + serverId: "host", + cwd: "/repo-a", + prNumber: 1, + repoOwner: "getpaseo", + repoName: "paseo", + }), + ).rejects.toThrow(); + + const result = await fetchPrPaneTimelinePage({ + client, + registry, + serverId: "host", + cwd: "/repo-b", + prNumber: 2, + repoOwner: "getpaseo", + repoName: "paseo", + }); + + expect(result.prNumber).toBe(2); + expect( + registry.has(unsupportedTimelineKey({ serverId: "host", cwd: "/repo-a", prNumber: 1 })), + ).toBe(true); + expect( + registry.has(unsupportedTimelineKey({ serverId: "host", cwd: "/repo-b", prNumber: 2 })), + ).toBe(false); + }); +}); + +describe("isUnsupportedTimelineError", () => { + it("matches the daemon's unknown-schema error for pull_request_timeline_request", () => { + expect(isUnsupportedTimelineError(unsupportedTimelineError())).toBe(true); + }); + + it("rejects unrelated errors", () => { + expect(isUnsupportedTimelineError(new Error("network down"))).toBe(false); + expect(isUnsupportedTimelineError("not an error")).toBe(false); + }); + + it("rejects daemon errors for a different request type", () => { + const error = new Error("nope") as Error & { code: string; requestType: string }; + error.name = "DaemonRpcError"; + error.code = "unknown_schema"; + error.requestType = "something_else_request"; + expect(isUnsupportedTimelineError(error)).toBe(false); + }); +}); + +describe("selectPrPaneState", () => { + it("returns null data and no PR number when the status payload has no PR", () => { + const state = selectPrPaneState({ ...baseSelectInput, status: null }); + + expect(state.data).toBeNull(); + expect(state.prNumber).toBeNull(); + }); + + it("composes data from status and timeline when both are present", () => { + const state = selectPrPaneState({ + ...baseSelectInput, + status: prStatus(), + timelinePayload: timelinePayload(), + }); + + expect(state.data?.number).toBe(42); + expect(state.data?.title).toBe("Wire real PR pane data"); + expect(state.data?.activity).toEqual([]); + }); + + it("returns null data when the consumer disabled timeline rendering", () => { + const state = selectPrPaneState({ + ...baseSelectInput, + status: prStatus(), + timelineEnabled: false, + timelinePayload: timelinePayload(), + }); + + expect(state.data).toBeNull(); + expect(state.prNumber).toBe(42); + }); + + it("drops timeline activity that belongs to a different PR number", () => { + const state = selectPrPaneState({ + ...baseSelectInput, + status: prStatus(), + timelinePayload: timelinePayload({ prNumber: 41, items: [ { id: "comment-1", kind: "comment", author: "octocat", - body: "This belongs to another PR", + body: "Belongs to another PR", createdAt: Date.now(), - url: "https://github.com/getpaseo/paseo/pull/41#issuecomment-1", + url: "https://github.com/getpaseo/paseo/pull/41#c1", }, ], }), - ); - - const hook = renderPrPaneHook(); - await hook.mount(); - - await waitForExpectation(() => { - expect(hook.latest.data?.number).toBe(42); }); - expect(hook.latest.data?.activity).toEqual([]); + expect(state.data?.activity).toEqual([]); }); - it("suppresses old-daemon errors and prevents future timeline requests for that tuple", async () => { - const unsupportedError = unsupportedTimelineError(); - mockClient.checkoutPrStatus.mockResolvedValue( - statusPayload({ status: status({ number: 99 }) }), - ); - mockClient.pullRequestTimeline.mockRejectedValue(unsupportedError); - - const hook = renderPrPaneHook(); - await hook.mount(); - - await waitForExpectation(() => { - expect(mockClient.pullRequestTimeline).toHaveBeenCalledTimes(1); - }); - - await waitForExpectation(() => { - expect(hook.latest.error).toBeNull(); - }); - - await hook.rerender({ serverId, cwd }); - await hook.queryClient.invalidateQueries({ - queryKey: prPaneTimelineQueryKey({ serverId, cwd, prNumber: 99 }), - }); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 20)); - }); - - expect(mockClient.pullRequestTimeline).toHaveBeenCalledTimes(1); - expect(hook.latest.data?.number).toBe(99); + it("reports loading while the status query is loading", () => { + expect(selectPrPaneState({ ...baseSelectInput, statusIsLoading: true }).isLoading).toBe(true); }); - it("surfaces checkout PR status payload errors", async () => { - mockClient.checkoutPrStatus.mockResolvedValue( - statusPayload({ - error: { - code: "UNKNOWN", - message: "bad daemon payload", - }, - }), - ); - mockClient.pullRequestTimeline.mockResolvedValue(timelinePayload()); - - const hook = renderPrPaneHook(); - await hook.mount(); - - await waitForExpectation(() => { - expect(hook.latest.error).not.toBeNull(); - expect(hook.latest.error?.message).toContain("bad daemon payload"); + it("reports loading while the timeline is still pending its first response", () => { + const state = selectPrPaneState({ + ...baseSelectInput, + status: prStatus(), + shouldFetchTimeline: true, + timelineIsLoading: true, }); + expect(state.isLoading).toBe(true); }); - it("retries timeline requests when the unsupported timeline tuple changes", async () => { - const cwdA = "/repo-a"; - const cwdB = "/repo-b"; - const prNumberA = 123; - const prNumberB = 124; - const unsupportedError = unsupportedTimelineError(); - - mockClient.checkoutPrStatus.mockImplementation(async (requestedCwd: string) => - statusPayload({ - cwd: requestedCwd, - status: status({ number: requestedCwd === cwdA ? prNumberA : prNumberB }), - }), - ); - mockClient.pullRequestTimeline.mockImplementation(async (input: { cwd: string }) => { - if (input.cwd === cwdA) { - throw unsupportedError; - } - return timelinePayload({ cwd: input.cwd, prNumber: prNumberB }); + it("does not report loading on the timeline when no fetch was scheduled", () => { + const state = selectPrPaneState({ + ...baseSelectInput, + status: prStatus(), + shouldFetchTimeline: false, + timelineIsLoading: true, }); - - const hook = renderPrPaneHook({ options: { serverId, cwd: cwdA } }); - await hook.mount(); - - await waitForExpectation(() => { - expect(countTimelineCalls({ cwd: cwdA, prNumber: prNumberA })).toBe(1); - }); - - await hook.rerender({ serverId, cwd: cwdA }); - await hook.queryClient.invalidateQueries({ - queryKey: prPaneTimelineQueryKey({ serverId, cwd: cwdA, prNumber: prNumberA }), - }); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 20)); - }); - - expect(countTimelineCalls({ cwd: cwdA, prNumber: prNumberA })).toBe(1); - - await hook.rerender({ serverId, cwd: cwdB }); - - await waitForExpectation(() => { - expect(countTimelineCalls({ cwd: cwdB, prNumber: prNumberB })).toBe(1); - }); - expect(countTimelineCalls({ cwd: cwdA, prNumber: prNumberA })).toBe(1); + expect(state.isLoading).toBe(false); }); - it("disables the timeline query when githubFeaturesEnabled is false", async () => { - mockClient.checkoutPrStatus.mockResolvedValue( - statusPayload({ githubFeaturesEnabled: false, status: status() }), - ); - mockClient.pullRequestTimeline.mockResolvedValue(timelinePayload()); - - const hook = renderPrPaneHook(); - await hook.mount(); - - await waitForExpectation(() => { - expect(hook.latest.data?.number).toBe(42); - }); - - expect(hook.latest.githubFeaturesEnabled).toBe(false); - expect(hook.latest.data?.activity).toEqual([]); - expect(mockClient.pullRequestTimeline).not.toHaveBeenCalled(); + it("reports refreshing during background revalidation of either query", () => { + expect( + selectPrPaneState({ + ...baseSelectInput, + statusIsFetching: true, + }).isRefreshing, + ).toBe(true); + expect( + selectPrPaneState({ + ...baseSelectInput, + timelineIsFetching: true, + }).isRefreshing, + ).toBe(true); }); - it("does not poll PR status or timeline after the initial load", async () => { - vi.useFakeTimers(); - mockClient.checkoutPrStatus.mockResolvedValue(statusPayload()); - mockClient.pullRequestTimeline.mockResolvedValue(timelinePayload()); - - const hook = renderPrPaneHook(); - await hook.mount(); - - await waitForHookResult(() => { - expect(hook.latest.data?.number).toBe(42); + it("surfaces status payload errors with the daemon's message", () => { + const state = selectPrPaneState({ + ...baseSelectInput, + status: prStatus(), + statusPayloadError: { code: "UNKNOWN", message: "bad daemon payload" }, }); - await act(async () => { - vi.advanceTimersByTime(120_000); - await Promise.resolve(); - }); - - expect(mockClient.checkoutPrStatus).toHaveBeenCalledTimes(1); - expect(mockClient.pullRequestTimeline).toHaveBeenCalledTimes(1); + expect(state.error?.message).toContain("bad daemon payload"); }); - it("does not refetch PR data on focus or reconnect", async () => { - mockClient.checkoutPrStatus.mockResolvedValue(statusPayload()); - mockClient.pullRequestTimeline.mockResolvedValue(timelinePayload()); - - const hook = renderPrPaneHook(); - await hook.mount(); - - await waitForExpectation(() => { - expect(hook.latest.data?.number).toBe(42); + it("falls back to a generic message when the status payload error is empty", () => { + const state = selectPrPaneState({ + ...baseSelectInput, + statusPayloadError: { code: "UNKNOWN", message: "" }, }); - await act(async () => { - focusManager.setFocused(false); - focusManager.setFocused(true); - onlineManager.setOnline(false); - onlineManager.setOnline(true); - await Promise.resolve(); - }); - - expect(mockClient.checkoutPrStatus).toHaveBeenCalledTimes(1); - expect(mockClient.pullRequestTimeline).toHaveBeenCalledTimes(1); + expect(state.error?.message).toBe("Unable to load pull request status"); }); - it("reports first-load, background refresh, and non-suppressed errors", async () => { - const statusDeferred = createDeferred(); - mockClient.checkoutPrStatus.mockReturnValue(statusDeferred.promise); - mockClient.pullRequestTimeline.mockResolvedValue( - timelinePayload({ - error: { - kind: "unknown", - message: "rate limited", - }, - }), - ); - - const hook = renderPrPaneHook(); - await hook.mount(); - - expect(hook.latest.isLoading).toBe(true); - expect(hook.latest.isRefreshing).toBe(false); - - await act(async () => { - statusDeferred.resolve(statusPayload()); + it("prefers the status thrown error over the timeline error", () => { + const statusError = new Error("status broke"); + const state = selectPrPaneState({ + ...baseSelectInput, + statusError, + timelineError: new Error("timeline broke"), }); - await waitForExpectation(() => { - expect(hook.latest.data?.number).toBe(42); - expect(hook.latest.error?.message).toBe("rate limited"); + expect(state.error).toBe(statusError); + }); + + it("suppresses unsupported timeline errors", () => { + const state = selectPrPaneState({ + ...baseSelectInput, + status: prStatus(), + timelineError: unsupportedTimelineError(), }); - const refreshDeferred = createDeferred(); - mockClient.checkoutPrStatus.mockReturnValue(refreshDeferred.promise); - hook.queryClient.invalidateQueries({ queryKey: checkoutPrStatusQueryKey(serverId, cwd) }); + expect(state.error).toBeNull(); + }); - await waitForExpectation(() => { - expect(hook.latest.isLoading).toBe(false); - expect(hook.latest.isRefreshing).toBe(true); + it("surfaces timeline payload errors when nothing earlier failed", () => { + const state = selectPrPaneState({ + ...baseSelectInput, + status: prStatus(), + timelinePayload: timelinePayload({ error: { kind: "unknown", message: "rate limited" } }), }); - await act(async () => { - refreshDeferred.resolve(statusPayload()); - }); + expect(state.error?.message).toBe("rate limited"); + }); - await waitForExpectation(() => { - expect(hook.latest.isRefreshing).toBe(false); - }); + it("passes the daemon's githubFeaturesEnabled flag through", () => { + expect( + selectPrPaneState({ + ...baseSelectInput, + status: prStatus(), + githubFeaturesEnabled: false, + }).githubFeaturesEnabled, + ).toBe(false); }); }); - -async function waitForHookResult(assertion: () => void): Promise { - let lastError: unknown; - - for (let attempt = 0; attempt < 60; attempt += 1) { - try { - assertion(); - return; - } catch (error) { - lastError = error; - await act(async () => { - vi.advanceTimersByTime(10); - await Promise.resolve(); - }); - } - } - - throw lastError; -} - -function createDeferred(): { - promise: Promise; - resolve: (value: T) => void; -} { - let resolve: (value: T) => void = () => undefined; - const promise = new Promise((promiseResolve) => { - resolve = promiseResolve; - }); - - return { promise, resolve }; -} - -function countTimelineCalls({ - cwd: targetCwd, - prNumber, -}: { - cwd: string; - prNumber: number; -}): number { - return mockClient.pullRequestTimeline.mock.calls.filter(([input]) => { - const request = input as { cwd?: string; prNumber?: number | null }; - return request.cwd === targetCwd && request.prNumber === prNumber; - }).length; -} diff --git a/packages/app/src/hooks/use-pr-pane-data.ts b/packages/app/src/hooks/use-pr-pane-data.ts index 698e362ed..a9178fead 100644 --- a/packages/app/src/hooks/use-pr-pane-data.ts +++ b/packages/app/src/hooks/use-pr-pane-data.ts @@ -1,18 +1,19 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { useMemo } from "react"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import type { CheckoutPrStatusResponse, PullRequestTimelineResponse, } from "@getpaseo/protocol/messages"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { mapPrPaneData, type PrPaneData } from "@/git/pr-pane-data"; import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query"; import { prPaneTimelineQueryKey } from "@/git/query-keys"; +type CheckoutPrStatus = CheckoutPrStatusResponse["payload"]["status"]; type CheckoutPrStatusPayloadError = CheckoutPrStatusResponse["payload"]["error"]; type PullRequestTimeline = PullRequestTimelineResponse["payload"]; -const unsupportedTimelineKeys = new Set(); - export interface UsePrPaneDataOptions { serverId: string; cwd: string; @@ -29,23 +30,21 @@ export interface UsePrPaneDataResult { githubFeaturesEnabled: boolean; } -interface PrRepoIdentity { +export interface PrRepoIdentity { prNumber: number | null; repoOwner: string | null; repoName: string | null; } -function extractPrRepoIdentity(status: CheckoutPrStatusLike): PrRepoIdentity { +export function extractPrRepoIdentity(status: CheckoutPrStatus | null): PrRepoIdentity { const prNumber = status?.number ?? null; const repoOwner = status?.repoOwner && status.repoOwner.length > 0 ? status.repoOwner : null; const repoName = status?.repoName && status.repoName.length > 0 ? status.repoName : null; return { prNumber, repoOwner, repoName }; } -type CheckoutPrStatusLike = ReturnType["status"]; - -interface ShouldFetchTimelineArgs { - daemonClient: unknown; +export interface ShouldFetchTimelineArgs { + hasClient: boolean; isConnected: boolean; timelineEnabled: boolean; githubFeaturesEnabled: boolean; @@ -54,8 +53,8 @@ interface ShouldFetchTimelineArgs { timelineUnsupported: boolean; } -function shouldFetchTimelineFrom({ - daemonClient, +export function shouldFetchTimelineFrom({ + hasClient, isConnected, timelineEnabled, githubFeaturesEnabled, @@ -64,7 +63,7 @@ function shouldFetchTimelineFrom({ timelineUnsupported, }: ShouldFetchTimelineArgs): boolean { return ( - !!daemonClient && + hasClient && isConnected && timelineEnabled && githubFeaturesEnabled && @@ -76,6 +75,112 @@ function shouldFetchTimelineFrom({ ); } +export type PrPaneTimelineClient = Pick; + +export interface UnsupportedTimelineRegistry { + has(key: string): boolean; + add(key: string): void; +} + +export function unsupportedTimelineKey({ + serverId, + cwd, + prNumber, +}: { + serverId: string; + cwd: string; + prNumber: number; +}): string { + return `${serverId}\0${cwd}\0${prNumber}`; +} + +export function createInMemoryUnsupportedTimelineRegistry(): UnsupportedTimelineRegistry { + const keys = new Set(); + return { + has: (key) => keys.has(key), + add: (key) => { + keys.add(key); + }, + }; +} + +const defaultUnsupportedTimelineRegistry = createInMemoryUnsupportedTimelineRegistry(); + +export interface FetchPrPaneTimelinePageInput { + client: PrPaneTimelineClient; + registry: UnsupportedTimelineRegistry; + serverId: string; + cwd: string; + prNumber: number; + repoOwner: string; + repoName: string; +} + +export async function fetchPrPaneTimelinePage( + input: FetchPrPaneTimelinePageInput, +): Promise { + try { + return await input.client.pullRequestTimeline({ + cwd: input.cwd, + prNumber: input.prNumber, + repoOwner: input.repoOwner, + repoName: input.repoName, + }); + } catch (error) { + if (isUnsupportedTimelineError(error)) { + input.registry.add( + unsupportedTimelineKey({ + serverId: input.serverId, + cwd: input.cwd, + prNumber: input.prNumber, + }), + ); + } + throw error; + } +} + +export interface SelectPrPaneStateInput { + status: CheckoutPrStatus | null; + statusPayloadError: CheckoutPrStatusPayloadError; + statusError: Error | null; + statusIsLoading: boolean; + statusIsFetching: boolean; + githubFeaturesEnabled: boolean; + timelineEnabled: boolean; + shouldFetchTimeline: boolean; + timelinePayload: PullRequestTimeline | undefined; + timelineError: Error | null; + timelineIsLoading: boolean; + timelineIsFetching: boolean; +} + +export function selectPrPaneState(input: SelectPrPaneStateInput): UsePrPaneDataResult { + const identity = extractPrRepoIdentity(input.status); + const data = + identity.prNumber === null || !input.timelineEnabled + ? null + : mapPrPaneData(input.status, input.timelinePayload); + const statusRefreshing = input.statusIsFetching && !input.statusIsLoading; + const timelineRefreshing = input.timelineIsFetching && !input.timelineIsLoading; + + return { + data, + prNumber: identity.prNumber, + isLoading: + input.statusIsLoading || + (input.shouldFetchTimeline && input.timelineIsLoading && input.timelinePayload === undefined), + isRefreshing: statusRefreshing || timelineRefreshing, + error: firstNonSuppressedError({ + statusPayloadError: input.statusPayloadError, + statusError: input.statusError, + timelineError: input.timelineError, + timelinePayloadError: input.timelinePayload?.error ?? null, + }), + githubFeaturesEnabled: input.githubFeaturesEnabled, + }; +} + export function usePrPaneData({ serverId, cwd, @@ -86,41 +191,47 @@ export function usePrPaneData({ const isConnected = useHostRuntimeIsConnected(serverId); const checkoutPrStatus = useCheckoutPrStatusQuery({ serverId, cwd, enabled }); const status = checkoutPrStatus.status; - const { prNumber, repoOwner, repoName } = extractPrRepoIdentity(status); + const identity = extractPrRepoIdentity(status); const githubFeaturesEnabled = checkoutPrStatus.githubFeaturesEnabled; + const registry = defaultUnsupportedTimelineRegistry; const unsupportedKey = - prNumber === null ? null : timelineUnsupportedKey({ serverId, cwd, prNumber }); - const timelineUnsupported = unsupportedKey ? unsupportedTimelineKeys.has(unsupportedKey) : false; + identity.prNumber === null + ? null + : unsupportedTimelineKey({ serverId, cwd, prNumber: identity.prNumber }); + const timelineUnsupported = unsupportedKey ? registry.has(unsupportedKey) : false; const shouldFetchTimeline = shouldFetchTimelineFrom({ - daemonClient, + hasClient: !!daemonClient, isConnected, timelineEnabled, githubFeaturesEnabled, cwd, - identity: { prNumber, repoOwner, repoName }, + identity, timelineUnsupported, }); const timelineQuery = useQuery({ - queryKey: prPaneTimelineQueryKey({ serverId, cwd, prNumber }), + queryKey: useMemo( + () => prPaneTimelineQueryKey({ serverId, cwd, prNumber: identity.prNumber }), + [serverId, cwd, identity.prNumber], + ), queryFn: async () => { - if (!daemonClient || prNumber === null || repoOwner === null || repoName === null) { + if ( + !daemonClient || + identity.prNumber === null || + identity.repoOwner === null || + identity.repoName === null + ) { throw new Error("Daemon client not available"); } - - try { - return await daemonClient.pullRequestTimeline({ - cwd, - prNumber, - repoOwner, - repoName, - }); - } catch (error) { - if (unsupportedKey && isUnsupportedTimelineError(error)) { - unsupportedTimelineKeys.add(unsupportedKey); - } - throw error; - } + return fetchPrPaneTimelinePage({ + client: daemonClient, + registry, + serverId, + cwd, + prNumber: identity.prNumber, + repoOwner: identity.repoOwner, + repoName: identity.repoName, + }); }, enabled: shouldFetchTimeline, staleTime: Infinity, @@ -131,26 +242,20 @@ export function usePrPaneData({ retry: (failureCount, error) => !isUnsupportedTimelineError(error) && failureCount < 3, }); - const data = - prNumber === null || !timelineEnabled ? null : mapPrPaneData(status, timelineQuery.data); - const statusRefreshing = checkoutPrStatus.isFetching && !checkoutPrStatus.isLoading; - const timelineRefreshing = timelineQuery.isFetching && !timelineQuery.isLoading; - - return { - data, - prNumber, - isLoading: - checkoutPrStatus.isLoading || - (shouldFetchTimeline && timelineQuery.isLoading && timelineQuery.data === undefined), - isRefreshing: statusRefreshing || timelineRefreshing, - error: firstNonSuppressedError({ - statusPayloadError: checkoutPrStatus.payloadError, - statusError: checkoutPrStatus.error, - timelineError: timelineQuery.error, - timelinePayloadError: timelineQuery.data?.error ?? null, - }), + return selectPrPaneState({ + status, + statusPayloadError: checkoutPrStatus.payloadError, + statusError: checkoutPrStatus.error, + statusIsLoading: checkoutPrStatus.isLoading, + statusIsFetching: checkoutPrStatus.isFetching, githubFeaturesEnabled, - }; + timelineEnabled, + shouldFetchTimeline, + timelinePayload: timelineQuery.data, + timelineError: timelineQuery.error, + timelineIsLoading: timelineQuery.isLoading, + timelineIsFetching: timelineQuery.isFetching, + }); } function firstNonSuppressedError({ @@ -183,19 +288,7 @@ function firstNonSuppressedError({ return null; } -function timelineUnsupportedKey({ - serverId, - cwd, - prNumber, -}: { - serverId: string; - cwd: string; - prNumber: number; -}): string { - return `${serverId}\0${cwd}\0${prNumber}`; -} - -function isUnsupportedTimelineError(error: unknown): boolean { +export function isUnsupportedTimelineError(error: unknown): boolean { if (!(error instanceof Error)) { return false; } diff --git a/packages/app/src/hooks/use-projects.test.ts b/packages/app/src/hooks/use-projects.test.ts index 0921df725..0a0d36385 100644 --- a/packages/app/src/hooks/use-projects.test.ts +++ b/packages/app/src/hooks/use-projects.test.ts @@ -1,76 +1,71 @@ -/** - * @vitest-environment jsdom - */ -import React from "react"; -import { renderHook, waitFor } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages"; -import { useProjects } from "./use-projects"; +import { + fetchAggregatedProjects, + type ProjectsHostInput, + type ProjectsRuntime, + type ProjectsRuntimeSnapshot, +} from "./use-projects"; -interface MockDaemonClient { - fetchWorkspaces: ReturnType; +type FetchWorkspaces = DaemonClient["fetchWorkspaces"]; +type FetchWorkspacesResult = Awaited>; + +interface HostFixture { + serverId: string; + serverName: string; + status: "online" | "offline" | "missing-snapshot"; + workspaces?: WorkspaceDescriptorPayload[] | Error; } -const { hostState, clientsByServerId, runtimeStore } = vi.hoisted(() => { - const state = { - hosts: [ - { serverId: "local", label: "Local" }, - { serverId: "laptop", label: "Laptop" }, - ], - snapshots: new Map(), - }; - const clients = new Map }>(); - return { - hostState: state, - clientsByServerId: clients, - runtimeStore: { - getClient: (serverId: string) => clients.get(serverId) ?? null, - getSnapshot: (serverId: string) => state.snapshots.get(serverId) ?? null, - }, - }; -}); - -vi.mock("@/runtime/host-runtime", () => ({ - getHostRuntimeStore: () => runtimeStore, - useHosts: () => hostState.hosts, -})); - -function createQueryClient(): QueryClient { - return new QueryClient({ - defaultOptions: { - queries: { retry: false }, - }, - }); +interface RuntimeAdapter extends ProjectsRuntime { + fetchCalls: Map; } -function renderProjectsHook() { - const queryClient = createQueryClient(); - const wrapper = ({ children }: { children: React.ReactNode }) => - React.createElement(QueryClientProvider, { client: queryClient }, children); +function createRuntime(hosts: HostFixture[]): RuntimeAdapter { + const snapshots = new Map(); + const clients = new Map | null>(); + const fetchCalls = new Map(); - return renderHook(() => useProjects(), { wrapper }); -} + for (const host of hosts) { + if (host.status === "missing-snapshot") { + snapshots.set(host.serverId, null); + } else { + snapshots.set(host.serverId, { connectionStatus: host.status }); + } -function makeClient(entries: WorkspaceDescriptorPayload[] | Error): MockDaemonClient { - return { - fetchWorkspaces: vi.fn(async () => { - if (entries instanceof Error) { - throw entries; + if (host.workspaces === undefined) { + clients.set(host.serverId, null); + continue; + } + + const workspaces = host.workspaces; + const fetchWorkspaces: FetchWorkspaces = async () => { + fetchCalls.set(host.serverId, (fetchCalls.get(host.serverId) ?? 0) + 1); + if (workspaces instanceof Error) { + throw workspaces; } return { - requestId: "req-workspaces", - entries, - pageInfo: { - nextCursor: null, - prevCursor: null, - hasMore: false, - }, - }; - }), + requestId: `req-${host.serverId}`, + entries: workspaces, + pageInfo: { nextCursor: null, prevCursor: null, hasMore: false }, + } satisfies FetchWorkspacesResult; + }; + + clients.set(host.serverId, { fetchWorkspaces }); + } + + return { + getClient: (serverId) => clients.get(serverId) ?? null, + getSnapshot: (serverId) => snapshots.get(serverId), + fetchCalls, }; } +function hostInputs(hosts: HostFixture[]): ProjectsHostInput[] { + return hosts.map((host) => ({ serverId: host.serverId, serverName: host.serverName })); +} + function workspace(input: { id: string; projectKey: string; @@ -118,83 +113,82 @@ function workspace(input: { }; } -afterEach(() => { - clientsByServerId.clear(); - hostState.hosts = [ - { serverId: "local", label: "Local" }, - { serverId: "laptop", label: "Laptop" }, - ]; - hostState.snapshots = new Map(); -}); +describe("fetchAggregatedProjects", () => { + it("calls every online host's client and aggregates projects sorted by display name", async () => { + const hosts: HostFixture[] = [ + { + serverId: "local", + serverName: "Local", + status: "online", + workspaces: [ + workspace({ + id: "z-main", + projectKey: "remote:github.com/acme/zeta", + projectName: "acme/zeta", + cwd: "/repo/zeta", + remoteUrl: "https://github.com/acme/zeta.git", + }), + ], + }, + { + serverId: "laptop", + serverName: "Laptop", + status: "online", + workspaces: [ + workspace({ + id: "a-main", + projectKey: "remote:github.com/acme/alpha", + projectName: "acme/alpha", + cwd: "/repo/alpha", + remoteUrl: "https://github.com/acme/alpha.git", + }), + ], + }, + ]; + const runtime = createRuntime(hosts); -describe("useProjects", () => { - it("calls all known daemon clients and returns aggregated projects sorted by display name", async () => { - hostState.snapshots.set("local", { connectionStatus: "online" }); - hostState.snapshots.set("laptop", { connectionStatus: "online" }); - clientsByServerId.set( - "local", - makeClient([ - workspace({ - id: "z-main", - projectKey: "remote:github.com/acme/zeta", - projectName: "acme/zeta", - cwd: "/repo/zeta", - remoteUrl: "https://github.com/acme/zeta.git", - }), - ]), - ); - clientsByServerId.set( - "laptop", - makeClient([ - workspace({ - id: "a-main", - projectKey: "remote:github.com/acme/alpha", - projectName: "acme/alpha", - cwd: "/repo/alpha", - remoteUrl: "https://github.com/acme/alpha.git", - }), - ]), - ); + const result = await fetchAggregatedProjects({ hosts: hostInputs(hosts), runtime }); - const { result } = renderProjectsHook(); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - expect(clientsByServerId.get("local")?.fetchWorkspaces).toHaveBeenCalledTimes(1); - expect(clientsByServerId.get("laptop")?.fetchWorkspaces).toHaveBeenCalledTimes(1); - expect(result.current.projects.map((project) => project.projectName)).toEqual([ + expect(runtime.fetchCalls.get("local")).toBe(1); + expect(runtime.fetchCalls.get("laptop")).toBe(1); + expect(result.projects.map((project) => project.projectName)).toEqual([ "acme/alpha", "acme/zeta", ]); + expect(result.hostErrors).toEqual([]); }); it("surfaces per-host fetch failures without dropping successful hosts", async () => { - hostState.snapshots.set("local", { connectionStatus: "online" }); - hostState.snapshots.set("laptop", { connectionStatus: "online" }); - clientsByServerId.set( - "local", - makeClient([ - workspace({ - id: "main", - projectKey: "remote:github.com/acme/app", - projectName: "acme/app", - cwd: "/repo/app", - remoteUrl: "https://github.com/acme/app.git", - }), - ]), - ); - clientsByServerId.set("laptop", makeClient(new Error("laptop unavailable"))); + const hosts: HostFixture[] = [ + { + serverId: "local", + serverName: "Local", + status: "online", + workspaces: [ + workspace({ + id: "main", + projectKey: "remote:github.com/acme/app", + projectName: "acme/app", + cwd: "/repo/app", + remoteUrl: "https://github.com/acme/app.git", + }), + ], + }, + { + serverId: "laptop", + serverName: "Laptop", + status: "online", + workspaces: new Error("laptop unavailable"), + }, + ]; + const runtime = createRuntime(hosts); - const { result } = renderProjectsHook(); + const result = await fetchAggregatedProjects({ hosts: hostInputs(hosts), runtime }); - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - expect(result.current.projects).toEqual([ + expect(result.projects).toEqual([ expect.objectContaining({ projectKey: "remote:github.com/acme/app" }), ]); - expect(result.current.hostErrors).toEqual([ + expect(result.hostErrors).toEqual([ { serverId: "laptop", serverName: "Laptop", @@ -204,53 +198,60 @@ describe("useProjects", () => { }); it("skips disconnected hosts silently without surfacing them as failures", async () => { - hostState.snapshots.set("local", { connectionStatus: "online" }); - hostState.snapshots.set("laptop", { connectionStatus: "offline" }); - clientsByServerId.set( - "local", - makeClient([ - workspace({ - id: "main", - projectKey: "remote:github.com/acme/app", - projectName: "acme/app", - cwd: "/repo/app", - remoteUrl: "https://github.com/acme/app.git", - }), - ]), - ); + const hosts: HostFixture[] = [ + { + serverId: "local", + serverName: "Local", + status: "online", + workspaces: [ + workspace({ + id: "main", + projectKey: "remote:github.com/acme/app", + projectName: "acme/app", + cwd: "/repo/app", + remoteUrl: "https://github.com/acme/app.git", + }), + ], + }, + { + serverId: "laptop", + serverName: "Laptop", + status: "offline", + }, + ]; + const runtime = createRuntime(hosts); - const { result } = renderProjectsHook(); + const result = await fetchAggregatedProjects({ hosts: hostInputs(hosts), runtime }); - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - expect(result.current.hostErrors).toEqual([]); - expect(result.current.projects).toEqual([ + expect(result.hostErrors).toEqual([]); + expect(result.projects).toEqual([ expect.objectContaining({ projectKey: "remote:github.com/acme/app" }), ]); + expect(runtime.fetchCalls.get("laptop")).toBeUndefined(); }); it("returns only the stable public project and host entry shapes", async () => { - hostState.snapshots.set("local", { connectionStatus: "online" }); - clientsByServerId.set( - "local", - makeClient([ - workspace({ - id: "main", - projectKey: "remote:github.com/acme/app", - projectName: "acme/app", - cwd: "/repo/app", - remoteUrl: "https://github.com/acme/app.git", - }), - ]), - ); + const hosts: HostFixture[] = [ + { + serverId: "local", + serverName: "Local", + status: "online", + workspaces: [ + workspace({ + id: "main", + projectKey: "remote:github.com/acme/app", + projectName: "acme/app", + cwd: "/repo/app", + remoteUrl: "https://github.com/acme/app.git", + }), + ], + }, + ]; + const runtime = createRuntime(hosts); - const { result } = renderProjectsHook(); + const result = await fetchAggregatedProjects({ hosts: hostInputs(hosts), runtime }); - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - expect(Object.keys(result.current.projects[0] ?? {}).sort()).toEqual([ + expect(Object.keys(result.projects[0] ?? {}).sort()).toEqual([ "githubUrl", "hostCount", "hosts", @@ -260,7 +261,7 @@ describe("useProjects", () => { "projectName", "totalWorkspaceCount", ]); - expect(Object.keys(result.current.projects[0]?.hosts[0] ?? {}).sort()).toEqual([ + expect(Object.keys(result.projects[0]?.hosts[0] ?? {}).sort()).toEqual([ "gitRuntime", "githubRuntime", "isOnline", @@ -270,7 +271,7 @@ describe("useProjects", () => { "workspaceCount", "workspaces", ]); - expect(Object.keys(result.current.projects[0]?.hosts[0]?.workspaces[0] ?? {}).sort()).toEqual([ + expect(Object.keys(result.projects[0]?.hosts[0]?.workspaces[0] ?? {}).sort()).toEqual([ "currentBranch", "id", "name", diff --git a/packages/app/src/hooks/use-projects.ts b/packages/app/src/hooks/use-projects.ts index a22d0b61a..e131f0927 100644 --- a/packages/app/src/hooks/use-projects.ts +++ b/packages/app/src/hooks/use-projects.ts @@ -21,8 +21,28 @@ export interface UseProjectsResult { refetch: () => void; } -interface FetchAllWorkspaceDescriptorsInput { - client: Pick; +export interface ProjectsRuntimeSnapshot { + connectionStatus: string; +} + +export interface ProjectsRuntime { + getClient(serverId: string): Pick | null; + getSnapshot(serverId: string): ProjectsRuntimeSnapshot | null | undefined; +} + +export interface ProjectsHostInput { + serverId: string; + serverName: string; +} + +export interface FetchAggregatedProjectsInput { + hosts: ProjectsHostInput[]; + runtime: ProjectsRuntime; +} + +export interface FetchAggregatedProjectsResult { + projects: ProjectSummary[]; + hostErrors: ProjectHostError[]; } interface HostWorkspacesResult { @@ -35,13 +55,13 @@ function toErrorMessage(error: unknown): string { } async function fetchAllWorkspaceDescriptors( - input: FetchAllWorkspaceDescriptorsInput, + client: Pick, ): Promise { const entries: WorkspaceDescriptor[] = []; let cursor: string | null = null; while (true) { - const payload = await input.client.fetchWorkspaces({ + const payload = await client.fetchWorkspaces({ sort: [{ key: "name", direction: "asc" }], page: cursor ? { limit: 200, cursor } : { limit: 200 }, }); @@ -55,10 +75,66 @@ async function fetchAllWorkspaceDescriptors( return entries; } +export async function fetchAggregatedProjects( + input: FetchAggregatedProjectsInput, +): Promise { + const results = await Promise.all( + input.hosts.map(async (host): Promise => { + const snapshot = input.runtime.getSnapshot(host.serverId); + const isOnline = snapshot?.connectionStatus === "online"; + const client = input.runtime.getClient(host.serverId); + + if (!client || !isOnline) { + return { + host: { + serverId: host.serverId, + serverName: host.serverName, + isOnline, + workspaces: [], + }, + error: null, + }; + } + + try { + return { + host: { + serverId: host.serverId, + serverName: host.serverName, + isOnline, + workspaces: await fetchAllWorkspaceDescriptors(client), + }, + error: null, + }; + } catch (error) { + return { + host: { + serverId: host.serverId, + serverName: host.serverName, + isOnline, + workspaces: [], + }, + error: { + serverId: host.serverId, + serverName: host.serverName, + message: toErrorMessage(error), + }, + }; + } + }), + ); + + const hostErrors = results.flatMap((result) => (result.error ? [result.error] : [])); + return { + ...buildProjects({ hosts: results.map((result) => result.host) }), + hostErrors, + }; +} + export function useProjects(): UseProjectsResult { const hosts = useHosts(); const runtime = getHostRuntimeStore(); - const hostInputs = useMemo( + const hostInputs = useMemo( () => hosts.map((host) => ({ serverId: host.serverId, @@ -69,59 +145,7 @@ export function useProjects(): UseProjectsResult { const projectsQuery = useQuery({ queryKey: projectsQueryKey, - queryFn: async () => { - const results = await Promise.all( - hostInputs.map(async (host): Promise => { - const snapshot = runtime.getSnapshot(host.serverId); - const isOnline = snapshot?.connectionStatus === "online"; - const client = runtime.getClient(host.serverId); - - if (!client || !isOnline) { - return { - host: { - serverId: host.serverId, - serverName: host.serverName, - isOnline, - workspaces: [], - }, - error: null, - }; - } - - try { - return { - host: { - serverId: host.serverId, - serverName: host.serverName, - isOnline, - workspaces: await fetchAllWorkspaceDescriptors({ client }), - }, - error: null, - }; - } catch (error) { - return { - host: { - serverId: host.serverId, - serverName: host.serverName, - isOnline, - workspaces: [], - }, - error: { - serverId: host.serverId, - serverName: host.serverName, - message: toErrorMessage(error), - }, - }; - } - }), - ); - - const hostErrors = results.flatMap((result) => (result.error ? [result.error] : [])); - return { - ...buildProjects({ hosts: results.map((result) => result.host) }), - hostErrors, - }; - }, + queryFn: () => fetchAggregatedProjects({ hosts: hostInputs, runtime }), }); return { diff --git a/packages/app/src/hooks/use-providers-snapshot.test.ts b/packages/app/src/hooks/use-providers-snapshot.test.ts index 68bbcbaf2..b57000732 100644 --- a/packages/app/src/hooks/use-providers-snapshot.test.ts +++ b/packages/app/src/hooks/use-providers-snapshot.test.ts @@ -1,92 +1,62 @@ -/** - * @vitest-environment jsdom - */ -import React from "react"; -import { act, renderHook, waitFor } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { QueryClient } from "@tanstack/react-query"; +import { beforeEach, describe, expect, it } from "vitest"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; -import { useSessionStore } from "@/stores/session-store"; -import { providersSnapshotQueryKey, useProvidersSnapshot } from "./use-providers-snapshot"; +import { + applyProvidersSnapshotUpdate, + fetchProvidersSnapshot, + providersSnapshotQueryKey, + refreshAndApplyProvidersSnapshot, + selectorOpenRefetchDecision, + type ProvidersSnapshotClient, + type ProvidersSnapshotUpdateMessage, +} from "./use-providers-snapshot"; -interface ProviderSnapshotUpdateMessage { - type: "providers_snapshot_update"; - payload: { - cwd?: string; - entries: ProviderSnapshotEntry[]; - generatedAt: string; - }; -} -type ProviderSnapshotUpdateListener = (message: ProviderSnapshotUpdateMessage) => void; -interface ProvidersSnapshot { - entries: ProviderSnapshotEntry[]; - generatedAt: string; - requestId: string; -} -type HookResult = ReturnType["result"]; +type GetProvidersSnapshotResult = Awaited>; +type RefreshProvidersSnapshotResult = Awaited>; +type GetProvidersSnapshotOptions = Parameters[0]; +type RefreshProvidersSnapshotOptions = Parameters[0]; -const { mockClient, mockRuntime, snapshotUpdateListeners } = vi.hoisted(() => { - const hoistedListeners: ProviderSnapshotUpdateListener[] = []; - const hoistedClient = { - getProvidersSnapshot: vi.fn(), - refreshProvidersSnapshot: vi.fn(), - on: vi.fn((_event: string, listener: ProviderSnapshotUpdateListener) => { - hoistedListeners.push(listener); - return () => {}; - }), +interface FakeProvidersSnapshotClient extends ProvidersSnapshotClient { + getCalls: GetProvidersSnapshotOptions[]; + refreshCalls: RefreshProvidersSnapshotOptions[]; +} + +function createClient( + input: { + snapshots?: GetProvidersSnapshotResult[]; + refreshResult?: RefreshProvidersSnapshotResult; + } = {}, +): FakeProvidersSnapshotClient { + const snapshots = [...(input.snapshots ?? [])]; + const refreshResult: RefreshProvidersSnapshotResult = input.refreshResult ?? { + acknowledged: true, + requestId: "refresh-1", }; + + const getCalls: GetProvidersSnapshotOptions[] = []; + const refreshCalls: RefreshProvidersSnapshotOptions[] = []; + return { - mockClient: hoistedClient, - mockRuntime: { - client: hoistedClient, - isConnected: true, + getCalls, + refreshCalls, + async getProvidersSnapshot(options) { + getCalls.push(options ?? {}); + const next = snapshots.shift(); + if (!next) { + throw new Error("No snapshot configured for getProvidersSnapshot call"); + } + return next; + }, + async refreshProvidersSnapshot(options) { + refreshCalls.push(options ?? {}); + return refreshResult; }, - snapshotUpdateListeners: hoistedListeners, }; -}); - -vi.mock("@/runtime/host-runtime", () => ({ - useHostRuntimeClient: () => mockRuntime.client, - useHostRuntimeIsConnected: () => mockRuntime.isConnected, -})); - -const serverId = "server-1"; - -function createQueryClient(): QueryClient { - return new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, - }); } -function enableProvidersSnapshot(): void { - act(() => { - useSessionStore.getState().initializeSession(serverId, mockClient as unknown as DaemonClient); - useSessionStore.getState().updateSessionServerInfo(serverId, { - serverId, - hostname: "localhost", - version: "test", - features: { providersSnapshot: true }, - } as never); - }); -} - -function renderProvidersSnapshotHook(options: { cwd?: string | null } = {}) { - const queryClient = createQueryClient(); - const wrapper = ({ children }: { children: React.ReactNode }) => - React.createElement(QueryClientProvider, { client: queryClient }, children); - - return renderHook(() => useProvidersSnapshot(serverId, options), { wrapper }); -} - -const readyCodexModel = { provider: "codex", id: "gpt-5.4", label: "GPT-5.4" } as const; - -function providersSnapshot(entries: ProviderSnapshotEntry[]): ProvidersSnapshot { +function providersSnapshot(entries: ProviderSnapshotEntry[]): GetProvidersSnapshotResult { return { entries, generatedAt: "2026-01-01T00:00:00.000Z", @@ -106,56 +76,11 @@ function codexEntry( }; } -async function waitForSnapshotReads(count: number): Promise { - await waitFor(() => { - expect(mockClient.getProvidersSnapshot).toHaveBeenCalledTimes(count); - }); -} +const readyCodexModel = { provider: "codex", id: "gpt-5.4", label: "GPT-5.4" } as const; +const serverId = "server-1"; -async function waitForSnapshotEntries( - result: HookResult, - entries: ProviderSnapshotEntry[], -): Promise { - await waitFor(() => { - expect(result.current.entries).toEqual(entries); - }); -} - -async function emitProvidersSnapshotUpdate( - entries: ProviderSnapshotEntry[], - cwd?: string, -): Promise { - const listener = snapshotUpdateListeners.at(-1); - expect(listener).toBeDefined(); - - await act(async () => { - listener?.({ - type: "providers_snapshot_update", - payload: { - ...(cwd ? { cwd } : {}), - entries, - generatedAt: "2026-01-01T00:00:01.000Z", - }, - }); - }); -} - -async function openSelectorForSelectedProvider(result: HookResult): Promise { - await act(async () => { - result.current.refetchIfStale("codex"); - }); -} - -afterEach(() => { - act(() => { - useSessionStore.getState().clearSession(serverId); - }); - vi.clearAllMocks(); - snapshotUpdateListeners.length = 0; -}); - -describe("providers snapshot hook cache scope", () => { - it("uses separate query keys for home and workspace scopes", () => { +describe("providersSnapshotQueryKey", () => { + it("uses separate keys for home and workspace scopes", () => { expect(providersSnapshotQueryKey(serverId)).toEqual(["providersSnapshot", serverId, "home"]); expect(providersSnapshotQueryKey(serverId, "/repo-a")).toEqual([ "providersSnapshot", @@ -164,118 +89,209 @@ describe("providers snapshot hook cache scope", () => { "/repo-a", ]); }); +}); - it("sends no cwd for settings snapshot loads and refreshes", async () => { - enableProvidersSnapshot(); - mockClient.getProvidersSnapshot.mockResolvedValue(providersSnapshot([])); - mockClient.refreshProvidersSnapshot.mockResolvedValue({ - acknowledged: true, - requestId: "settings-refresh", - }); +describe("fetchProvidersSnapshot", () => { + it("sends no cwd for the home scope", async () => { + const client = createClient({ snapshots: [providersSnapshot([])] }); - const { result } = renderProvidersSnapshotHook(); + await fetchProvidersSnapshot({ client, cwd: null }); - await waitFor(() => { - expect(mockClient.getProvidersSnapshot).toHaveBeenCalledWith({}); - }); - - await act(async () => { - await result.current.refresh(["codex"]); - }); - - expect(mockClient.refreshProvidersSnapshot).toHaveBeenCalledWith({ providers: ["codex"] }); - expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({}); + expect(client.getCalls).toEqual([{}]); }); - it("sends cwd for workspace snapshot loads and refreshes", async () => { - enableProvidersSnapshot(); - mockClient.getProvidersSnapshot.mockResolvedValue(providersSnapshot([])); - mockClient.refreshProvidersSnapshot.mockResolvedValue({ - acknowledged: true, - requestId: "workspace-refresh", + it("sends the workspace cwd for the workspace scope", async () => { + const client = createClient({ snapshots: [providersSnapshot([])] }); + + await fetchProvidersSnapshot({ client, cwd: "/repo-a" }); + + expect(client.getCalls).toEqual([{ cwd: "/repo-a" }]); + }); +}); + +describe("refreshAndApplyProvidersSnapshot", () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient(); + }); + + it("refreshes then re-fetches the home snapshot and writes it into the home query cache", async () => { + const client = createClient({ + snapshots: [providersSnapshot([codexEntry("ready", [readyCodexModel])])], }); - const { result } = renderProvidersSnapshotHook({ cwd: "/repo-a" }); - - await waitFor(() => { - expect(mockClient.getProvidersSnapshot).toHaveBeenCalledWith({ cwd: "/repo-a" }); + await refreshAndApplyProvidersSnapshot({ + client, + queryClient, + serverId, + cwd: null, + providers: ["codex"], }); - await act(async () => { - await result.current.refresh(["codex"]); + expect(client.refreshCalls).toEqual([{ providers: ["codex"] }]); + expect(client.getCalls).toEqual([{}]); + expect(queryClient.getQueryData(providersSnapshotQueryKey(serverId))).toEqual( + providersSnapshot([codexEntry("ready", [readyCodexModel])]), + ); + }); + + it("refreshes then re-fetches the workspace snapshot with the cwd preserved", async () => { + const client = createClient({ + snapshots: [providersSnapshot([codexEntry("ready", [readyCodexModel])])], }); - expect(mockClient.refreshProvidersSnapshot).toHaveBeenCalledWith({ + await refreshAndApplyProvidersSnapshot({ + client, + queryClient, + serverId, cwd: "/repo-a", providers: ["codex"], }); - expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({ cwd: "/repo-a" }); - }); - it("routes provider snapshot updates by cwd scope", async () => { - enableProvidersSnapshot(); - mockClient.getProvidersSnapshot.mockResolvedValue(providersSnapshot([])); - - const { result } = renderProvidersSnapshotHook({ cwd: "/repo-a" }); - - await waitForSnapshotEntries(result, []); - await emitProvidersSnapshotUpdate([codexEntry("ready", [readyCodexModel])], "/repo-b"); - expect(result.current.entries).toEqual([]); - - await emitProvidersSnapshotUpdate([codexEntry("ready", [readyCodexModel])], "/repo-a"); - await waitForSnapshotEntries(result, [codexEntry("ready", [readyCodexModel])]); - }); - - it("applies loading snapshot updates without refetching through the read path", async () => { - enableProvidersSnapshot(); - mockClient.getProvidersSnapshot.mockResolvedValueOnce( - providersSnapshot([codexEntry("ready", [])]), + expect(client.refreshCalls).toEqual([{ cwd: "/repo-a", providers: ["codex"] }]); + expect(client.getCalls).toEqual([{ cwd: "/repo-a" }]); + expect(queryClient.getQueryData(providersSnapshotQueryKey(serverId, "/repo-a"))).toEqual( + providersSnapshot([codexEntry("ready", [readyCodexModel])]), ); - - const { result } = renderProvidersSnapshotHook(); - - await waitForSnapshotReads(1); - - const loadingEntries = [codexEntry("loading")]; - await emitProvidersSnapshotUpdate(loadingEntries); - - await waitForSnapshotEntries(result, loadingEntries); - expect(mockClient.getProvidersSnapshot).toHaveBeenCalledTimes(1); - expect(mockClient.refreshProvidersSnapshot).not.toHaveBeenCalled(); }); - it.each([ - { name: "missing", entries: [] }, - { name: "loading", entries: [codexEntry("loading")] }, - ])( - "ensures a selected provider snapshot on selector open when it is $name", - async ({ entries }) => { - enableProvidersSnapshot(); - mockClient.getProvidersSnapshot - .mockResolvedValueOnce(providersSnapshot(entries)) - .mockResolvedValueOnce(providersSnapshot([codexEntry("ready", [readyCodexModel])])); + it("invalidates every scope under the server when refreshing the home snapshot", async () => { + const client = createClient({ snapshots: [providersSnapshot([])] }); + queryClient.setQueryData(providersSnapshotQueryKey(serverId, "/repo-a"), providersSnapshot([])); + queryClient.setQueryData(providersSnapshotQueryKey(serverId, "/repo-b"), providersSnapshot([])); - const { result } = renderProvidersSnapshotHook(); + await refreshAndApplyProvidersSnapshot({ + client, + queryClient, + serverId, + cwd: null, + }); - await waitForSnapshotEntries(result, entries); - await openSelectorForSelectedProvider(result); - await waitForSnapshotReads(2); + expect( + queryClient.getQueryState(providersSnapshotQueryKey(serverId, "/repo-a"))?.isInvalidated, + ).toBe(true); + expect( + queryClient.getQueryState(providersSnapshotQueryKey(serverId, "/repo-b"))?.isInvalidated, + ).toBe(true); + }); - expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({}); - expect(mockClient.refreshProvidersSnapshot).not.toHaveBeenCalled(); - }, - ); + it("does not invalidate sibling scopes when refreshing a workspace snapshot", async () => { + const client = createClient({ snapshots: [providersSnapshot([])] }); + queryClient.setQueryData(providersSnapshotQueryKey(serverId), providersSnapshot([])); + queryClient.setQueryData(providersSnapshotQueryKey(serverId, "/repo-b"), providersSnapshot([])); - it("does not ensure a selected provider snapshot on selector open when the provider is ready with no models", async () => { - enableProvidersSnapshot(); - mockClient.getProvidersSnapshot.mockResolvedValue(providersSnapshot([codexEntry("ready", [])])); + await refreshAndApplyProvidersSnapshot({ + client, + queryClient, + serverId, + cwd: "/repo-a", + }); - const { result } = renderProvidersSnapshotHook(); + expect(queryClient.getQueryState(providersSnapshotQueryKey(serverId))?.isInvalidated).toBe( + false, + ); + expect( + queryClient.getQueryState(providersSnapshotQueryKey(serverId, "/repo-b"))?.isInvalidated, + ).toBe(false); + }); +}); - await waitForSnapshotEntries(result, [codexEntry("ready", [])]); - await openSelectorForSelectedProvider(result); +describe("applyProvidersSnapshotUpdate", () => { + let queryClient: QueryClient; - expect(mockClient.getProvidersSnapshot).toHaveBeenCalledTimes(1); - expect(mockClient.refreshProvidersSnapshot).not.toHaveBeenCalled(); + beforeEach(() => { + queryClient = new QueryClient(); + }); + + function updateMessage( + entries: ProviderSnapshotEntry[], + cwd?: string, + ): ProvidersSnapshotUpdateMessage { + return { + type: "providers_snapshot_update", + payload: { + ...(cwd ? { cwd } : {}), + entries, + generatedAt: "2026-01-01T00:00:01.000Z", + }, + }; + } + + it("routes updates to the home query cache when the message carries no cwd", () => { + applyProvidersSnapshotUpdate({ + serverId, + queryClient, + message: updateMessage([codexEntry("ready", [readyCodexModel])]), + }); + + expect(queryClient.getQueryData(providersSnapshotQueryKey(serverId))).toEqual({ + entries: [codexEntry("ready", [readyCodexModel])], + generatedAt: "2026-01-01T00:00:01.000Z", + requestId: "providers_snapshot_update", + }); + }); + + it("routes workspace updates to the matching scope without touching siblings", () => { + queryClient.setQueryData(providersSnapshotQueryKey(serverId, "/repo-b"), providersSnapshot([])); + + applyProvidersSnapshotUpdate({ + serverId, + queryClient, + message: updateMessage([codexEntry("ready", [readyCodexModel])], "/repo-a"), + }); + + expect(queryClient.getQueryData(providersSnapshotQueryKey(serverId, "/repo-a"))).toEqual({ + entries: [codexEntry("ready", [readyCodexModel])], + generatedAt: "2026-01-01T00:00:01.000Z", + requestId: "providers_snapshot_update", + }); + expect(queryClient.getQueryData(providersSnapshotQueryKey(serverId, "/repo-b"))).toEqual( + providersSnapshot([]), + ); + }); +}); + +describe("selectorOpenRefetchDecision", () => { + it("refetches stale entries when no provider is selected", () => { + expect( + selectorOpenRefetchDecision({ + entries: [codexEntry("ready", [readyCodexModel])], + selectedProvider: null, + }), + ).toBe("refetch-stale"); + }); + + it("forces a refetch when the selected provider has no entry", () => { + expect(selectorOpenRefetchDecision({ entries: [], selectedProvider: "codex" })).toBe( + "refetch-always", + ); + }); + + it("forces a refetch when the selected provider is still loading", () => { + expect( + selectorOpenRefetchDecision({ + entries: [codexEntry("loading")], + selectedProvider: "codex", + }), + ).toBe("refetch-always"); + }); + + it("keeps a stale-only refetch when the selected provider is ready with no models", () => { + expect( + selectorOpenRefetchDecision({ + entries: [codexEntry("ready", [])], + selectedProvider: "codex", + }), + ).toBe("refetch-stale"); + }); + + it("keeps a stale-only refetch when the selected provider is ready with models", () => { + expect( + selectorOpenRefetchDecision({ + entries: [codexEntry("ready", [readyCodexModel])], + selectedProvider: "codex", + }), + ).toBe("refetch-stale"); }); }); diff --git a/packages/app/src/hooks/use-providers-snapshot.ts b/packages/app/src/hooks/use-providers-snapshot.ts index 7973f704b..ea383e585 100644 --- a/packages/app/src/hooks/use-providers-snapshot.ts +++ b/packages/app/src/hooks/use-providers-snapshot.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query"; import type { AgentProvider, ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; @@ -13,8 +13,85 @@ import { providersSnapshotRequestOptions, } from "@/hooks/providers-snapshot-query"; +type GetProvidersSnapshotResult = Awaited>; +type RefreshProvidersSnapshotResult = Awaited>; + export { providersSnapshotQueryKey, providersSnapshotQueryRoot }; +export type ProvidersSnapshotClient = Pick< + DaemonClient, + "getProvidersSnapshot" | "refreshProvidersSnapshot" +>; + +export interface ProvidersSnapshotUpdateMessage { + type: "providers_snapshot_update"; + payload: { + cwd?: string; + entries: ProviderSnapshotEntry[]; + generatedAt: string; + }; +} + +export async function fetchProvidersSnapshot(input: { + client: ProvidersSnapshotClient; + cwd: string | null; +}): Promise { + return input.client.getProvidersSnapshot(providersSnapshotRequestOptions({ cwd: input.cwd })); +} + +export async function refreshAndApplyProvidersSnapshot(input: { + client: ProvidersSnapshotClient; + queryClient: QueryClient; + serverId: string; + cwd: string | null; + providers?: AgentProvider[]; +}): Promise { + const refreshResult = await input.client.refreshProvidersSnapshot( + providersSnapshotRequestOptions({ cwd: input.cwd, providers: input.providers }), + ); + const snapshot = await fetchProvidersSnapshot({ client: input.client, cwd: input.cwd }); + input.queryClient.setQueryData(providersSnapshotQueryKey(input.serverId, input.cwd), snapshot); + if (isProvidersSnapshotHomeScope(input.cwd)) { + void input.queryClient.invalidateQueries({ + queryKey: providersSnapshotQueryRoot(input.serverId), + exact: false, + }); + } + return refreshResult; +} + +export function applyProvidersSnapshotUpdate(input: { + serverId: string; + queryClient: QueryClient; + message: ProvidersSnapshotUpdateMessage; +}): void { + if (input.message.type !== "providers_snapshot_update") { + return; + } + const queryKey = providersSnapshotQueryKey(input.serverId, input.message.payload.cwd); + input.queryClient.setQueryData(queryKey, { + entries: input.message.payload.entries, + generatedAt: input.message.payload.generatedAt, + requestId: "providers_snapshot_update", + }); +} + +export type SelectorOpenRefetchDecision = "refetch-stale" | "refetch-always"; + +export function selectorOpenRefetchDecision(input: { + entries: ProviderSnapshotEntry[] | undefined; + selectedProvider: AgentProvider | null | undefined; +}): SelectorOpenRefetchDecision { + if (!input.selectedProvider) { + return "refetch-stale"; + } + const selectedEntry = input.entries?.find((entry) => entry.provider === input.selectedProvider); + if (!selectedEntry || selectedEntry.status === "loading") { + return "refetch-always"; + } + return "refetch-stale"; +} + interface UseProvidersSnapshotResult { entries: ProviderSnapshotEntry[] | undefined; isLoading: boolean; @@ -45,8 +122,6 @@ export function useProvidersSnapshot( ); const queryKey = useMemo(() => providersSnapshotQueryKey(serverId, cwd), [cwd, serverId]); - const queryRoot = useMemo(() => providersSnapshotQueryRoot(serverId), [serverId]); - const requestOptions = useMemo(() => providersSnapshotRequestOptions({ cwd }), [cwd]); const snapshotQuery = useQuery({ queryKey, @@ -56,16 +131,22 @@ export function useProvidersSnapshot( if (!client) { throw new Error("Host is not connected"); } - return client.getProvidersSnapshot(requestOptions); + return fetchProvidersSnapshot({ client, cwd }); }, }); const refreshMutation = useMutation({ mutationFn: async (providers?: AgentProvider[]) => { - if (!client) { + if (!client || !serverId) { return; } - await client.refreshProvidersSnapshot(providersSnapshotRequestOptions({ cwd, providers })); + await refreshAndApplyProvidersSnapshot({ + client, + queryClient, + serverId, + cwd, + providers, + }); }, }); const { mutateAsync: refreshSnapshot, isPending: isRefreshing } = refreshMutation; @@ -79,46 +160,27 @@ export function useProvidersSnapshot( if (message.type !== "providers_snapshot_update") { return; } - const updateQueryKey = providersSnapshotQueryKey(serverId, message.payload.cwd); - queryClient.setQueryData(updateQueryKey, { - entries: message.payload.entries, - generatedAt: message.payload.generatedAt, - requestId: "providers_snapshot_update", - }); + applyProvidersSnapshotUpdate({ serverId, queryClient, message }); }); }, [client, enabled, isConnected, queryClient, serverId, supportsSnapshot]); const refresh = useCallback( async (providers?: AgentProvider[]) => { - if (!client) { - return; - } await refreshSnapshot(providers); - const snapshot = await client.getProvidersSnapshot(requestOptions); - queryClient.setQueryData(queryKey, snapshot); - if (isProvidersSnapshotHomeScope(cwd)) { - void queryClient.invalidateQueries({ queryKey: queryRoot, exact: false }); - } }, - [client, cwd, queryClient, queryKey, queryRoot, refreshSnapshot, requestOptions], + [refreshSnapshot], ); const refetchIfStale = useCallback( (selectedProvider?: AgentProvider | null) => { - if (!selectedProvider) { - void queryClient.refetchQueries({ queryKey, type: "active", stale: true }); - return; - } - - const selectedEntry = snapshotQuery.data?.entries.find( - (entry) => entry.provider === selectedProvider, - ); - - if (!selectedEntry || selectedEntry.status === "loading") { + const decision = selectorOpenRefetchDecision({ + entries: snapshotQuery.data?.entries, + selectedProvider, + }); + if (decision === "refetch-always") { void queryClient.refetchQueries({ queryKey, type: "active" }); return; } - void queryClient.refetchQueries({ queryKey, type: "active", stale: true }); }, [queryClient, queryKey, snapshotQuery.data?.entries], @@ -143,10 +205,9 @@ export function prefetchProvidersSnapshot( ): void { const cwd = normalizeProvidersSnapshotCwd(options.cwd); const queryKey = providersSnapshotQueryKey(serverId, cwd); - const requestOptions = providersSnapshotRequestOptions({ cwd }); void singletonQueryClient.prefetchQuery({ queryKey, staleTime: 60_000, - queryFn: () => client.getProvidersSnapshot(requestOptions), + queryFn: () => fetchProvidersSnapshot({ client, cwd }), }); } diff --git a/packages/app/src/hooks/use-settings.test.ts b/packages/app/src/hooks/use-settings.test.ts deleted file mode 100644 index cef8146dc..000000000 --- a/packages/app/src/hooks/use-settings.test.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { QueryClient } from "@tanstack/react-query"; - -const asyncStorageMock = vi.hoisted(() => ({ - getItem: vi.fn<(_: string) => Promise>(), - setItem: vi.fn<(_: string, __: string) => Promise>(), -})); - -const electronRuntimeState = vi.hoisted(() => ({ - isElectron: false, -})); - -const desktopSettingsMock = vi.hoisted(() => ({ - loadDesktopSettings: vi.fn<() => Promise>(), - migrateLegacyDesktopSettings: vi.fn<(_: unknown) => Promise>(), -})); - -vi.mock("@react-native-async-storage/async-storage", () => ({ - default: asyncStorageMock, -})); - -vi.mock("@/desktop/host", () => ({ - isElectronRuntime: () => electronRuntimeState.isElectron, -})); - -vi.mock("@/desktop/settings/desktop-settings", () => desktopSettingsMock); - -describe("use-settings", () => { - beforeEach(() => { - vi.resetModules(); - asyncStorageMock.getItem.mockReset(); - asyncStorageMock.setItem.mockReset(); - electronRuntimeState.isElectron = false; - desktopSettingsMock.loadDesktopSettings.mockReset(); - desktopSettingsMock.migrateLegacyDesktopSettings.mockReset(); - }); - - it("defaults built-in daemon management to enabled when storage is empty", async () => { - asyncStorageMock.getItem.mockResolvedValue(null); - asyncStorageMock.setItem.mockResolvedValue(); - - const mod = await import("./use-settings"); - const result = await mod.loadSettingsFromStorage(); - - expect(result).toEqual(mod.DEFAULT_APP_SETTINGS); - expect(asyncStorageMock.setItem).toHaveBeenCalledWith( - mod.APP_SETTINGS_KEY, - JSON.stringify(mod.DEFAULT_CLIENT_SETTINGS), - ); - }); - - it("defaults theme to auto when storage is empty", async () => { - asyncStorageMock.getItem.mockResolvedValue(null); - asyncStorageMock.setItem.mockResolvedValue(); - - const mod = await import("./use-settings"); - const result = await mod.loadSettingsFromStorage(); - - expect(result.theme).toBe("auto"); - }); - - it("defaults release channel to stable when storage is empty", async () => { - asyncStorageMock.getItem.mockResolvedValue(null); - asyncStorageMock.setItem.mockResolvedValue(); - - const mod = await import("./use-settings"); - const result = await mod.loadSettingsFromStorage(); - - expect(result.releaseChannel).toBe("stable"); - }); - - it("ignores renderer-owned daemon management state outside Electron", async () => { - asyncStorageMock.getItem.mockImplementation(async (key: string) => { - if (key === "@paseo:app-settings") { - return JSON.stringify({ - theme: "light", - manageBuiltInDaemon: false, - }); - } - return null; - }); - - const mod = await import("./use-settings"); - const result = await mod.loadSettingsFromStorage(); - - expect(result).toEqual({ - theme: "light", - manageBuiltInDaemon: true, - sendBehavior: "interrupt", - serviceUrlBehavior: "ask", - terminalScrollbackLines: 10_000, - releaseChannel: "stable", - }); - }); - - it("ignores renderer-owned release channel outside Electron", async () => { - asyncStorageMock.getItem.mockImplementation(async (key: string) => { - if (key === "@paseo:app-settings") { - return JSON.stringify({ - releaseChannel: "beta", - }); - } - return null; - }); - - const mod = await import("./use-settings"); - const result = await mod.loadSettingsFromStorage(); - - expect(result.releaseChannel).toBe("stable"); - }); - - it("keeps legacy AsyncStorage migration for client settings only", async () => { - asyncStorageMock.getItem.mockImplementation(async (key: string) => { - if (key === "@paseo:app-settings") { - return null; - } - if (key === "@paseo:settings") { - return JSON.stringify({ - theme: "dark", - manageBuiltInDaemon: false, - releaseChannel: "beta", - }); - } - return null; - }); - asyncStorageMock.setItem.mockResolvedValue(); - - const mod = await import("./use-settings"); - const result = await mod.loadAppSettingsFromStorage(); - - expect(result).toEqual({ - theme: "dark", - sendBehavior: "interrupt", - serviceUrlBehavior: "ask", - terminalScrollbackLines: 10_000, - }); - expect(asyncStorageMock.setItem).toHaveBeenCalledWith( - mod.APP_SETTINGS_KEY, - JSON.stringify(result), - ); - }); - - it("migrates legacy desktop-owned settings through Electron before reading effective settings", async () => { - electronRuntimeState.isElectron = true; - asyncStorageMock.getItem.mockImplementation(async (key: string) => { - if (key === "@paseo:app-settings") { - return JSON.stringify({ - theme: "light", - manageBuiltInDaemon: false, - releaseChannel: "beta", - }); - } - return null; - }); - desktopSettingsMock.migrateLegacyDesktopSettings.mockResolvedValue(); - desktopSettingsMock.loadDesktopSettings.mockResolvedValue({ - releaseChannel: "beta", - daemon: { - manageBuiltInDaemon: false, - keepRunningAfterQuit: true, - }, - }); - - const mod = await import("./use-settings"); - const result = await mod.loadSettingsFromStorage(); - - expect(desktopSettingsMock.migrateLegacyDesktopSettings).toHaveBeenCalledWith({ - manageBuiltInDaemon: false, - releaseChannel: "beta", - }); - expect(result).toEqual({ - theme: "light", - sendBehavior: "interrupt", - serviceUrlBehavior: "ask", - terminalScrollbackLines: 10_000, - manageBuiltInDaemon: false, - releaseChannel: "beta", - }); - }); - - it("skips desktop IPC when loading effective settings outside Electron", async () => { - asyncStorageMock.getItem.mockResolvedValue( - JSON.stringify({ - theme: "light", - }), - ); - - const mod = await import("./use-settings"); - const result = await mod.loadSettingsFromStorage(); - - expect(result).toEqual({ - theme: "light", - sendBehavior: "interrupt", - serviceUrlBehavior: "ask", - terminalScrollbackLines: 10_000, - manageBuiltInDaemon: true, - releaseChannel: "stable", - }); - }); - - it("loads configured terminal scrollback lines from app settings", async () => { - asyncStorageMock.getItem.mockResolvedValue( - JSON.stringify({ - terminalScrollbackLines: 42_000, - }), - ); - - const mod = await import("./use-settings"); - const result = await mod.loadAppSettingsFromStorage(); - - expect(result.terminalScrollbackLines).toBe(42_000); - }); - - it("saves terminal scrollback through app settings persistence", async () => { - asyncStorageMock.setItem.mockResolvedValue(); - - const mod = await import("./use-settings"); - asyncStorageMock.getItem.mockResolvedValue(JSON.stringify(mod.DEFAULT_CLIENT_SETTINGS)); - const queryClient = new QueryClient(); - - await mod.saveAppSettings({ - queryClient, - updates: { terminalScrollbackLines: 42_000 }, - }); - - expect(asyncStorageMock.setItem).toHaveBeenLastCalledWith( - mod.APP_SETTINGS_KEY, - JSON.stringify({ - ...mod.DEFAULT_CLIENT_SETTINGS, - terminalScrollbackLines: 42_000, - }), - ); - }); - - it("normalizes terminal scrollback lines from storage", async () => { - asyncStorageMock.getItem.mockResolvedValue( - JSON.stringify({ - terminalScrollbackLines: 1_000_000.9, - }), - ); - - const mod = await import("./use-settings"); - const result = await mod.loadAppSettingsFromStorage(); - - expect(result.terminalScrollbackLines).toBe(1_000_000); - expect(mod.parseTerminalScrollbackLines("-10")).toBe(0); - expect(mod.parseTerminalScrollbackLines("abc")).toBeNull(); - }); -}); diff --git a/packages/app/src/hooks/use-settings.ts b/packages/app/src/hooks/use-settings.ts deleted file mode 100644 index ecc3a8d78..000000000 --- a/packages/app/src/hooks/use-settings.ts +++ /dev/null @@ -1,325 +0,0 @@ -import { useCallback } from "react"; -import AsyncStorage from "@react-native-async-storage/async-storage"; -import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query"; -import { queryClient as appQueryClient } from "@/query/query-client"; -import { - DEFAULT_DESKTOP_SETTINGS, - loadDesktopSettings, - migrateLegacyDesktopSettings, - useDesktopSettings, -} from "@/desktop/settings/desktop-settings"; -import { isElectronRuntime } from "@/desktop/host"; -import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme"; - -export const APP_SETTINGS_KEY = "@paseo:app-settings"; -const LEGACY_SETTINGS_KEY = "@paseo:settings"; -const APP_SETTINGS_QUERY_KEY = ["app-settings"]; - -export type SendBehavior = "interrupt" | "queue"; -export type ReleaseChannel = "stable" | "beta"; -export type ServiceUrlBehavior = "ask" | "in-app" | "external"; - -const VALID_THEMES = new Set([...Object.keys(THEME_TO_UNISTYLES), "auto"]); -const VALID_SERVICE_URL_BEHAVIORS = new Set(["ask", "in-app", "external"]); -export const DEFAULT_TERMINAL_SCROLLBACK_LINES = 10_000; -export const MIN_TERMINAL_SCROLLBACK_LINES = 0; -export const MAX_TERMINAL_SCROLLBACK_LINES = 1_000_000; - -export interface AppSettings { - theme: ThemeName | "auto"; - sendBehavior: SendBehavior; - serviceUrlBehavior: ServiceUrlBehavior; - terminalScrollbackLines: number; -} - -export interface Settings extends AppSettings { - manageBuiltInDaemon: boolean; - releaseChannel: ReleaseChannel; -} - -export const DEFAULT_CLIENT_SETTINGS: AppSettings = { - theme: "auto", - sendBehavior: "interrupt", - serviceUrlBehavior: "ask", - terminalScrollbackLines: DEFAULT_TERMINAL_SCROLLBACK_LINES, -}; - -export const DEFAULT_APP_SETTINGS: Settings = { - ...DEFAULT_CLIENT_SETTINGS, - manageBuiltInDaemon: true, - releaseChannel: "stable", -}; - -export interface UseAppSettingsReturn { - settings: AppSettings; - isLoading: boolean; - error: unknown; - updateSettings: (updates: Partial) => Promise; - resetSettings: () => Promise; -} - -export interface UseSettingsReturn { - settings: Settings; - isLoading: boolean; - error: unknown; - updateSettings: (updates: Partial) => Promise; - resetSettings: () => Promise; -} - -export function useAppSettings(): UseAppSettingsReturn { - const queryClient = useQueryClient(); - const { data, isPending, error } = useQuery({ - queryKey: APP_SETTINGS_QUERY_KEY, - queryFn: loadAppSettingsFromStorage, - staleTime: Infinity, - gcTime: Infinity, - }); - - const updateSettings = useCallback( - async (updates: Partial) => { - try { - await saveAppSettings({ queryClient, updates }); - } catch (err) { - console.error("[AppSettings] Failed to save settings:", err); - throw err; - } - }, - [queryClient], - ); - - const resetSettings = useCallback(async () => { - try { - const next = { ...DEFAULT_CLIENT_SETTINGS }; - queryClient.setQueryData(APP_SETTINGS_QUERY_KEY, next); - await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(next)); - } catch (err) { - console.error("[AppSettings] Failed to reset settings:", err); - throw err; - } - }, [queryClient]); - - return { - settings: data ?? DEFAULT_CLIENT_SETTINGS, - isLoading: isPending, - error: error ?? null, - updateSettings, - resetSettings, - }; -} - -export function useSettings(): UseSettingsReturn { - const appSettings = useAppSettings(); - const desktopSettings = useDesktopSettings(); - - const updateSettings = useCallback( - async (updates: Partial) => { - const appUpdates: Partial = {}; - if (updates.theme !== undefined) { - appUpdates.theme = updates.theme; - } - if (updates.sendBehavior !== undefined) { - appUpdates.sendBehavior = updates.sendBehavior; - } - if (updates.serviceUrlBehavior !== undefined) { - appUpdates.serviceUrlBehavior = updates.serviceUrlBehavior; - } - if (updates.terminalScrollbackLines !== undefined) { - appUpdates.terminalScrollbackLines = updates.terminalScrollbackLines; - } - const promises: Promise[] = []; - if (Object.keys(appUpdates).length > 0) { - promises.push(appSettings.updateSettings(appUpdates)); - } - - if (isElectronRuntime()) { - const desktopUpdates: Parameters[0] = {}; - if (updates.manageBuiltInDaemon !== undefined) { - desktopUpdates.daemon = { - manageBuiltInDaemon: updates.manageBuiltInDaemon, - }; - } - if (updates.releaseChannel !== undefined) { - desktopUpdates.releaseChannel = updates.releaseChannel; - } - if (Object.keys(desktopUpdates).length > 0) { - promises.push(desktopSettings.updateSettings(desktopUpdates)); - } - } - - await Promise.all(promises); - }, - [appSettings, desktopSettings], - ); - - const resetSettings = useCallback(async () => { - const resets: Promise[] = [appSettings.resetSettings()]; - if (isElectronRuntime()) { - resets.push(desktopSettings.updateSettings(DEFAULT_DESKTOP_SETTINGS)); - } - await Promise.all(resets); - }, [appSettings, desktopSettings]); - - return { - settings: { - ...DEFAULT_APP_SETTINGS, - ...appSettings.settings, - manageBuiltInDaemon: desktopSettings.settings.daemon.manageBuiltInDaemon, - releaseChannel: desktopSettings.settings.releaseChannel, - }, - isLoading: appSettings.isLoading || desktopSettings.isLoading, - error: appSettings.error ?? desktopSettings.error, - updateSettings, - resetSettings, - }; -} - -export async function persistAppSettings(updates: Partial): Promise { - await saveAppSettings({ queryClient: appQueryClient, updates }); -} - -export async function saveAppSettings(input: { - queryClient: QueryClient; - updates: Partial; -}): Promise { - const current = - input.queryClient.getQueryData(APP_SETTINGS_QUERY_KEY) ?? - (await loadAppSettingsFromStorage()); - const next = { ...current, ...input.updates }; - input.queryClient.setQueryData(APP_SETTINGS_QUERY_KEY, next); - await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(next)); -} - -export async function loadAppSettingsFromStorage(): Promise { - try { - const stored = await AsyncStorage.getItem(APP_SETTINGS_KEY); - if (stored) { - const parsed = JSON.parse(stored) as Partial; - return { ...DEFAULT_CLIENT_SETTINGS, ...pickAppSettings(parsed) }; - } - - const legacyStored = await AsyncStorage.getItem(LEGACY_SETTINGS_KEY); - if (legacyStored) { - const legacyParsed = JSON.parse(legacyStored) as Record; - const next = { - ...DEFAULT_CLIENT_SETTINGS, - ...pickAppSettingsFromLegacy(legacyParsed), - } satisfies AppSettings; - await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(next)); - return next; - } - - await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(DEFAULT_CLIENT_SETTINGS)); - return DEFAULT_CLIENT_SETTINGS; - } catch (error) { - console.error("[AppSettings] Failed to load settings:", error); - throw error; - } -} - -export async function loadSettingsFromStorage(): Promise { - const legacyDesktopSettings = isElectronRuntime() - ? await loadLegacyDesktopSettingsFromStorage() - : null; - const appSettings = await loadAppSettingsFromStorage(); - - if (!isElectronRuntime()) { - return { - ...DEFAULT_APP_SETTINGS, - ...appSettings, - }; - } - - if (legacyDesktopSettings) { - await migrateLegacyDesktopSettings(legacyDesktopSettings); - } - - const desktopSettings = await loadDesktopSettings(); - return { - ...DEFAULT_APP_SETTINGS, - ...appSettings, - manageBuiltInDaemon: desktopSettings.daemon.manageBuiltInDaemon, - releaseChannel: desktopSettings.releaseChannel, - }; -} - -function pickAppSettings(stored: Partial): Partial { - const result: Partial = {}; - if (typeof stored.theme === "string" && VALID_THEMES.has(stored.theme)) { - result.theme = stored.theme; - } - if (stored.sendBehavior === "interrupt" || stored.sendBehavior === "queue") { - result.sendBehavior = stored.sendBehavior; - } - if ( - typeof stored.serviceUrlBehavior === "string" && - VALID_SERVICE_URL_BEHAVIORS.has(stored.serviceUrlBehavior) - ) { - result.serviceUrlBehavior = stored.serviceUrlBehavior; - } - const terminalScrollbackLines = parseTerminalScrollbackLines(stored.terminalScrollbackLines); - if (terminalScrollbackLines !== null) { - result.terminalScrollbackLines = terminalScrollbackLines; - } - return result; -} - -function pickAppSettingsFromLegacy(legacy: Record): Partial { - const result: Partial = {}; - if (legacy.theme === "dark" || legacy.theme === "light" || legacy.theme === "auto") { - result.theme = legacy.theme; - } - return result; -} - -export function parseTerminalScrollbackLines(value: unknown): number | null { - let numericValue = NaN; - if (typeof value === "number") { - numericValue = value; - } else if (typeof value === "string" && value.trim().length > 0) { - numericValue = Number(value); - } - if (!Number.isFinite(numericValue)) { - return null; - } - return Math.min( - MAX_TERMINAL_SCROLLBACK_LINES, - Math.max(MIN_TERMINAL_SCROLLBACK_LINES, Math.floor(numericValue)), - ); -} - -async function loadLegacyDesktopSettingsFromStorage(): Promise<{ - manageBuiltInDaemon?: boolean; - releaseChannel?: ReleaseChannel; -} | null> { - const stored = await loadRendererSettingsPayload(); - if (!stored) { - return null; - } - - const result: { - manageBuiltInDaemon?: boolean; - releaseChannel?: ReleaseChannel; - } = {}; - - if (typeof stored.manageBuiltInDaemon === "boolean") { - result.manageBuiltInDaemon = stored.manageBuiltInDaemon; - } - if (stored.releaseChannel === "stable" || stored.releaseChannel === "beta") { - result.releaseChannel = stored.releaseChannel; - } - - return Object.keys(result).length > 0 ? result : null; -} - -async function loadRendererSettingsPayload(): Promise | null> { - const current = await AsyncStorage.getItem(APP_SETTINGS_KEY); - if (current) { - return JSON.parse(current) as Record; - } - - const legacy = await AsyncStorage.getItem(LEGACY_SETTINGS_KEY); - if (!legacy) { - return null; - } - return JSON.parse(legacy) as Record; -} diff --git a/packages/app/src/hooks/use-settings/fakes.ts b/packages/app/src/hooks/use-settings/fakes.ts new file mode 100644 index 000000000..95d46ad94 --- /dev/null +++ b/packages/app/src/hooks/use-settings/fakes.ts @@ -0,0 +1,57 @@ +import type { DesktopSettings } from "@/desktop/settings/desktop-settings"; +import type { DesktopSettingsBridge, KeyValueStorage, ReleaseChannel } from "./storage"; + +export interface InMemoryKeyValueStorage extends KeyValueStorage { + readonly entries: Map; +} + +export function createInMemoryKeyValueStorage( + initial: Record = {}, +): InMemoryKeyValueStorage { + const entries = new Map(Object.entries(initial)); + return { + entries, + async getItem(key) { + return entries.get(key) ?? null; + }, + async setItem(key, value) { + entries.set(key, value); + }, + }; +} + +export interface FakeDesktopBridge extends DesktopSettingsBridge { + readonly migrationsApplied: Array<{ + manageBuiltInDaemon?: boolean; + releaseChannel?: ReleaseChannel; + }>; +} + +const DEFAULT_DESKTOP: DesktopSettings = { + releaseChannel: "stable", + daemon: { + manageBuiltInDaemon: true, + keepRunningAfterQuit: true, + }, +}; + +export function createFakeDesktopBridge( + options: { + isElectron?: boolean; + settings?: DesktopSettings; + } = {}, +): FakeDesktopBridge { + const isElectron = options.isElectron ?? false; + const settings = options.settings ?? DEFAULT_DESKTOP; + const migrationsApplied: FakeDesktopBridge["migrationsApplied"] = []; + return { + migrationsApplied, + isElectron: () => isElectron, + async loadDesktopSettings() { + return settings; + }, + async migrateLegacyDesktopSettings(input) { + migrationsApplied.push(input); + }, + }; +} diff --git a/packages/app/src/hooks/use-settings/index.ts b/packages/app/src/hooks/use-settings/index.ts new file mode 100644 index 000000000..f58c44483 --- /dev/null +++ b/packages/app/src/hooks/use-settings/index.ts @@ -0,0 +1,208 @@ +import { useCallback } from "react"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query"; +import { queryClient as appQueryClient } from "@/query/query-client"; +import { + DEFAULT_DESKTOP_SETTINGS, + loadDesktopSettings, + migrateLegacyDesktopSettings, + useDesktopSettings, +} from "@/desktop/settings/desktop-settings"; +import { isElectronRuntime } from "@/desktop/host"; +import { + APP_SETTINGS_KEY, + APP_SETTINGS_QUERY_KEY, + DEFAULT_APP_SETTINGS, + DEFAULT_CLIENT_SETTINGS, + DEFAULT_TERMINAL_SCROLLBACK_LINES, + MAX_TERMINAL_SCROLLBACK_LINES, + MIN_TERMINAL_SCROLLBACK_LINES, + loadAppSettingsFromStorage as loadAppSettingsFromStoragePure, + loadSettingsFromStorage as loadSettingsFromStoragePure, + parseTerminalScrollbackLines, + saveAppSettings as saveAppSettingsPure, + type AppSettings, + type DesktopSettingsBridge, + type KeyValueStorage, + type ReleaseChannel, + type SendBehavior, + type ServiceUrlBehavior, + type Settings, + type SettingsDeps, +} from "./storage"; + +export { + APP_SETTINGS_KEY, + DEFAULT_APP_SETTINGS, + DEFAULT_CLIENT_SETTINGS, + DEFAULT_TERMINAL_SCROLLBACK_LINES, + MAX_TERMINAL_SCROLLBACK_LINES, + MIN_TERMINAL_SCROLLBACK_LINES, + parseTerminalScrollbackLines, +}; +export type { + AppSettings, + DesktopSettingsBridge, + KeyValueStorage, + ReleaseChannel, + SendBehavior, + ServiceUrlBehavior, + Settings, + SettingsDeps, +}; + +const productionDeps: SettingsDeps = { + storage: AsyncStorage, + desktop: { + isElectron: isElectronRuntime, + loadDesktopSettings, + migrateLegacyDesktopSettings, + }, +}; + +export interface UseAppSettingsReturn { + settings: AppSettings; + isLoading: boolean; + error: unknown; + updateSettings: (updates: Partial) => Promise; + resetSettings: () => Promise; +} + +export interface UseSettingsReturn { + settings: Settings; + isLoading: boolean; + error: unknown; + updateSettings: (updates: Partial) => Promise; + resetSettings: () => Promise; +} + +export function useAppSettings(): UseAppSettingsReturn { + const queryClient = useQueryClient(); + const { data, isPending, error } = useQuery({ + queryKey: APP_SETTINGS_QUERY_KEY, + queryFn: () => loadAppSettingsFromStorage(), + staleTime: Infinity, + gcTime: Infinity, + }); + + const updateSettings = useCallback( + async (updates: Partial) => { + try { + await saveAppSettings({ queryClient, updates }); + } catch (err) { + console.error("[AppSettings] Failed to save settings:", err); + throw err; + } + }, + [queryClient], + ); + + const resetSettings = useCallback(async () => { + try { + const next = { ...DEFAULT_CLIENT_SETTINGS }; + queryClient.setQueryData(APP_SETTINGS_QUERY_KEY, next); + await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(next)); + } catch (err) { + console.error("[AppSettings] Failed to reset settings:", err); + throw err; + } + }, [queryClient]); + + return { + settings: data ?? DEFAULT_CLIENT_SETTINGS, + isLoading: isPending, + error: error ?? null, + updateSettings, + resetSettings, + }; +} + +export function useSettings(): UseSettingsReturn { + const appSettings = useAppSettings(); + const desktopSettings = useDesktopSettings(); + + const updateSettings = useCallback( + async (updates: Partial) => { + const appUpdates: Partial = {}; + if (updates.theme !== undefined) { + appUpdates.theme = updates.theme; + } + if (updates.sendBehavior !== undefined) { + appUpdates.sendBehavior = updates.sendBehavior; + } + if (updates.serviceUrlBehavior !== undefined) { + appUpdates.serviceUrlBehavior = updates.serviceUrlBehavior; + } + if (updates.terminalScrollbackLines !== undefined) { + appUpdates.terminalScrollbackLines = updates.terminalScrollbackLines; + } + const promises: Promise[] = []; + if (Object.keys(appUpdates).length > 0) { + promises.push(appSettings.updateSettings(appUpdates)); + } + + if (isElectronRuntime()) { + const desktopUpdates: Parameters[0] = {}; + if (updates.manageBuiltInDaemon !== undefined) { + desktopUpdates.daemon = { + manageBuiltInDaemon: updates.manageBuiltInDaemon, + }; + } + if (updates.releaseChannel !== undefined) { + desktopUpdates.releaseChannel = updates.releaseChannel; + } + if (Object.keys(desktopUpdates).length > 0) { + promises.push(desktopSettings.updateSettings(desktopUpdates)); + } + } + + await Promise.all(promises); + }, + [appSettings, desktopSettings], + ); + + const resetSettings = useCallback(async () => { + const resets: Promise[] = [appSettings.resetSettings()]; + if (isElectronRuntime()) { + resets.push(desktopSettings.updateSettings(DEFAULT_DESKTOP_SETTINGS)); + } + await Promise.all(resets); + }, [appSettings, desktopSettings]); + + return { + settings: { + ...DEFAULT_APP_SETTINGS, + ...appSettings.settings, + manageBuiltInDaemon: desktopSettings.settings.daemon.manageBuiltInDaemon, + releaseChannel: desktopSettings.settings.releaseChannel, + }, + isLoading: appSettings.isLoading || desktopSettings.isLoading, + error: appSettings.error ?? desktopSettings.error, + updateSettings, + resetSettings, + }; +} + +export async function persistAppSettings(updates: Partial): Promise { + await saveAppSettings({ queryClient: appQueryClient, updates }); +} + +export async function saveAppSettings(input: { + queryClient: QueryClient; + updates: Partial; + deps?: SettingsDeps; +}): Promise { + await saveAppSettingsPure({ + queryClient: input.queryClient, + updates: input.updates, + deps: input.deps ?? productionDeps, + }); +} + +export async function loadAppSettingsFromStorage(deps?: SettingsDeps): Promise { + return loadAppSettingsFromStoragePure(deps ?? productionDeps); +} + +export async function loadSettingsFromStorage(deps?: SettingsDeps): Promise { + return loadSettingsFromStoragePure(deps ?? productionDeps); +} diff --git a/packages/app/src/hooks/use-settings/storage.test.ts b/packages/app/src/hooks/use-settings/storage.test.ts new file mode 100644 index 000000000..9bd2a2904 --- /dev/null +++ b/packages/app/src/hooks/use-settings/storage.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, it } from "vitest"; +import { QueryClient } from "@tanstack/react-query"; +import { + APP_SETTINGS_KEY, + DEFAULT_APP_SETTINGS, + DEFAULT_CLIENT_SETTINGS, + loadAppSettingsFromStorage, + loadSettingsFromStorage, + parseTerminalScrollbackLines, + saveAppSettings, + type SettingsDeps, +} from "./storage"; +import { createFakeDesktopBridge, createInMemoryKeyValueStorage } from "./fakes"; + +const LEGACY_SETTINGS_KEY = "@paseo:settings"; + +function makeDeps( + overrides: { + storage?: ReturnType; + desktop?: ReturnType; + } = {}, +): SettingsDeps & { + storage: ReturnType; + desktop: ReturnType; +} { + return { + storage: overrides.storage ?? createInMemoryKeyValueStorage(), + desktop: overrides.desktop ?? createFakeDesktopBridge(), + }; +} + +describe("loadAppSettingsFromStorage", () => { + it("defaults theme to auto when storage is empty", async () => { + const deps = makeDeps(); + + const result = await loadAppSettingsFromStorage(deps); + + expect(result.theme).toBe("auto"); + }); + + it("seeds storage with the client defaults when nothing is persisted", async () => { + const deps = makeDeps(); + + const result = await loadAppSettingsFromStorage(deps); + + expect(result).toEqual(DEFAULT_CLIENT_SETTINGS); + expect(deps.storage.entries.get(APP_SETTINGS_KEY)).toBe( + JSON.stringify(DEFAULT_CLIENT_SETTINGS), + ); + }); + + it("loads configured terminal scrollback lines from app settings", async () => { + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ terminalScrollbackLines: 42_000 }), + }), + }); + + const result = await loadAppSettingsFromStorage(deps); + + expect(result.terminalScrollbackLines).toBe(42_000); + }); + + it("normalizes terminal scrollback lines from storage", async () => { + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ terminalScrollbackLines: 1_000_000.9 }), + }), + }); + + const result = await loadAppSettingsFromStorage(deps); + + expect(result.terminalScrollbackLines).toBe(1_000_000); + }); + + it("migrates the legacy theme key into the new settings object", async () => { + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [LEGACY_SETTINGS_KEY]: JSON.stringify({ + theme: "dark", + manageBuiltInDaemon: false, + releaseChannel: "beta", + }), + }), + }); + + const result = await loadAppSettingsFromStorage(deps); + + expect(result).toEqual({ + theme: "dark", + sendBehavior: "interrupt", + serviceUrlBehavior: "ask", + terminalScrollbackLines: 10_000, + }); + expect(deps.storage.entries.get(APP_SETTINGS_KEY)).toBe(JSON.stringify(result)); + }); +}); + +describe("loadSettingsFromStorage", () => { + it("defaults built-in daemon management to enabled when storage is empty", async () => { + const deps = makeDeps(); + + const result = await loadSettingsFromStorage(deps); + + expect(result).toEqual(DEFAULT_APP_SETTINGS); + }); + + it("defaults release channel to stable when storage is empty", async () => { + const deps = makeDeps(); + + const result = await loadSettingsFromStorage(deps); + + expect(result.releaseChannel).toBe("stable"); + }); + + it("ignores renderer-owned daemon management state outside Electron", async () => { + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ + theme: "light", + manageBuiltInDaemon: false, + }), + }), + }); + + const result = await loadSettingsFromStorage(deps); + + expect(result).toEqual({ + theme: "light", + manageBuiltInDaemon: true, + sendBehavior: "interrupt", + serviceUrlBehavior: "ask", + terminalScrollbackLines: 10_000, + releaseChannel: "stable", + }); + }); + + it("ignores renderer-owned release channel outside Electron", async () => { + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ releaseChannel: "beta" }), + }), + }); + + const result = await loadSettingsFromStorage(deps); + + expect(result.releaseChannel).toBe("stable"); + }); + + it("migrates legacy desktop-owned settings through the bridge before reading effective settings", async () => { + const desktop = createFakeDesktopBridge({ + isElectron: true, + settings: { + releaseChannel: "beta", + daemon: { manageBuiltInDaemon: false, keepRunningAfterQuit: true }, + }, + }); + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ + theme: "light", + manageBuiltInDaemon: false, + releaseChannel: "beta", + }), + }), + desktop, + }); + + const result = await loadSettingsFromStorage(deps); + + expect(desktop.migrationsApplied).toEqual([ + { manageBuiltInDaemon: false, releaseChannel: "beta" }, + ]); + expect(result).toEqual({ + theme: "light", + sendBehavior: "interrupt", + serviceUrlBehavior: "ask", + terminalScrollbackLines: 10_000, + manageBuiltInDaemon: false, + releaseChannel: "beta", + }); + }); + + it("does not call the desktop bridge outside Electron", async () => { + const desktop = createFakeDesktopBridge({ isElectron: false }); + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ theme: "light" }), + }), + desktop, + }); + + const result = await loadSettingsFromStorage(deps); + + expect(desktop.migrationsApplied).toEqual([]); + expect(result).toEqual({ + theme: "light", + sendBehavior: "interrupt", + serviceUrlBehavior: "ask", + terminalScrollbackLines: 10_000, + manageBuiltInDaemon: true, + releaseChannel: "stable", + }); + }); +}); + +describe("saveAppSettings", () => { + it("saves terminal scrollback through app settings persistence", async () => { + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify(DEFAULT_CLIENT_SETTINGS), + }), + }); + const queryClient = new QueryClient(); + + await saveAppSettings({ + queryClient, + updates: { terminalScrollbackLines: 42_000 }, + deps, + }); + + expect(deps.storage.entries.get(APP_SETTINGS_KEY)).toBe( + JSON.stringify({ + ...DEFAULT_CLIENT_SETTINGS, + terminalScrollbackLines: 42_000, + }), + ); + }); +}); + +describe("parseTerminalScrollbackLines", () => { + it("clamps negative values to the minimum and rejects non-numeric strings", () => { + expect(parseTerminalScrollbackLines("-10")).toBe(0); + expect(parseTerminalScrollbackLines("abc")).toBeNull(); + }); +}); diff --git a/packages/app/src/hooks/use-settings/storage.ts b/packages/app/src/hooks/use-settings/storage.ts new file mode 100644 index 000000000..86ea66ba9 --- /dev/null +++ b/packages/app/src/hooks/use-settings/storage.ts @@ -0,0 +1,211 @@ +import type { QueryClient } from "@tanstack/react-query"; +import type { DesktopSettings } from "@/desktop/settings/desktop-settings"; +import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme"; + +export const APP_SETTINGS_KEY = "@paseo:app-settings"; +export const APP_SETTINGS_QUERY_KEY = ["app-settings"]; +const LEGACY_SETTINGS_KEY = "@paseo:settings"; + +export type SendBehavior = "interrupt" | "queue"; +export type ReleaseChannel = "stable" | "beta"; +export type ServiceUrlBehavior = "ask" | "in-app" | "external"; + +const VALID_THEMES = new Set([...Object.keys(THEME_TO_UNISTYLES), "auto"]); +const VALID_SERVICE_URL_BEHAVIORS = new Set(["ask", "in-app", "external"]); +export const DEFAULT_TERMINAL_SCROLLBACK_LINES = 10_000; +export const MIN_TERMINAL_SCROLLBACK_LINES = 0; +export const MAX_TERMINAL_SCROLLBACK_LINES = 1_000_000; + +export interface AppSettings { + theme: ThemeName | "auto"; + sendBehavior: SendBehavior; + serviceUrlBehavior: ServiceUrlBehavior; + terminalScrollbackLines: number; +} + +export interface Settings extends AppSettings { + manageBuiltInDaemon: boolean; + releaseChannel: ReleaseChannel; +} + +export const DEFAULT_CLIENT_SETTINGS: AppSettings = { + theme: "auto", + sendBehavior: "interrupt", + serviceUrlBehavior: "ask", + terminalScrollbackLines: DEFAULT_TERMINAL_SCROLLBACK_LINES, +}; + +export const DEFAULT_APP_SETTINGS: Settings = { + ...DEFAULT_CLIENT_SETTINGS, + manageBuiltInDaemon: true, + releaseChannel: "stable", +}; + +export interface KeyValueStorage { + getItem(key: string): Promise; + setItem(key: string, value: string): Promise; +} + +export interface DesktopSettingsBridge { + isElectron(): boolean; + loadDesktopSettings(): Promise; + migrateLegacyDesktopSettings(input: { + manageBuiltInDaemon?: boolean; + releaseChannel?: ReleaseChannel; + }): Promise; +} + +export interface SettingsDeps { + storage: KeyValueStorage; + desktop: DesktopSettingsBridge; +} + +export async function saveAppSettings(input: { + queryClient: QueryClient; + updates: Partial; + deps: SettingsDeps; +}): Promise { + const current = + input.queryClient.getQueryData(APP_SETTINGS_QUERY_KEY) ?? + (await loadAppSettingsFromStorage(input.deps)); + const next = { ...current, ...input.updates }; + input.queryClient.setQueryData(APP_SETTINGS_QUERY_KEY, next); + await input.deps.storage.setItem(APP_SETTINGS_KEY, JSON.stringify(next)); +} + +export async function loadAppSettingsFromStorage(deps: SettingsDeps): Promise { + try { + const stored = await deps.storage.getItem(APP_SETTINGS_KEY); + if (stored) { + const parsed = JSON.parse(stored) as Partial; + return { ...DEFAULT_CLIENT_SETTINGS, ...pickAppSettings(parsed) }; + } + + const legacyStored = await deps.storage.getItem(LEGACY_SETTINGS_KEY); + if (legacyStored) { + const legacyParsed = JSON.parse(legacyStored) as Record; + const next = { + ...DEFAULT_CLIENT_SETTINGS, + ...pickAppSettingsFromLegacy(legacyParsed), + } satisfies AppSettings; + await deps.storage.setItem(APP_SETTINGS_KEY, JSON.stringify(next)); + return next; + } + + await deps.storage.setItem(APP_SETTINGS_KEY, JSON.stringify(DEFAULT_CLIENT_SETTINGS)); + return DEFAULT_CLIENT_SETTINGS; + } catch (error) { + console.error("[AppSettings] Failed to load settings:", error); + throw error; + } +} + +export async function loadSettingsFromStorage(deps: SettingsDeps): Promise { + const legacyDesktopSettings = deps.desktop.isElectron() + ? await loadLegacyDesktopSettingsFromStorage(deps.storage) + : null; + const appSettings = await loadAppSettingsFromStorage(deps); + + if (!deps.desktop.isElectron()) { + return { + ...DEFAULT_APP_SETTINGS, + ...appSettings, + }; + } + + if (legacyDesktopSettings) { + await deps.desktop.migrateLegacyDesktopSettings(legacyDesktopSettings); + } + + const desktopSettings = await deps.desktop.loadDesktopSettings(); + return { + ...DEFAULT_APP_SETTINGS, + ...appSettings, + manageBuiltInDaemon: desktopSettings.daemon.manageBuiltInDaemon, + releaseChannel: desktopSettings.releaseChannel, + }; +} + +function pickAppSettings(stored: Partial): Partial { + const result: Partial = {}; + if (typeof stored.theme === "string" && VALID_THEMES.has(stored.theme)) { + result.theme = stored.theme; + } + if (stored.sendBehavior === "interrupt" || stored.sendBehavior === "queue") { + result.sendBehavior = stored.sendBehavior; + } + if ( + typeof stored.serviceUrlBehavior === "string" && + VALID_SERVICE_URL_BEHAVIORS.has(stored.serviceUrlBehavior) + ) { + result.serviceUrlBehavior = stored.serviceUrlBehavior; + } + const terminalScrollbackLines = parseTerminalScrollbackLines(stored.terminalScrollbackLines); + if (terminalScrollbackLines !== null) { + result.terminalScrollbackLines = terminalScrollbackLines; + } + return result; +} + +function pickAppSettingsFromLegacy(legacy: Record): Partial { + const result: Partial = {}; + if (legacy.theme === "dark" || legacy.theme === "light" || legacy.theme === "auto") { + result.theme = legacy.theme; + } + return result; +} + +export function parseTerminalScrollbackLines(value: unknown): number | null { + let numericValue = NaN; + if (typeof value === "number") { + numericValue = value; + } else if (typeof value === "string" && value.trim().length > 0) { + numericValue = Number(value); + } + if (!Number.isFinite(numericValue)) { + return null; + } + return Math.min( + MAX_TERMINAL_SCROLLBACK_LINES, + Math.max(MIN_TERMINAL_SCROLLBACK_LINES, Math.floor(numericValue)), + ); +} + +async function loadLegacyDesktopSettingsFromStorage(storage: KeyValueStorage): Promise<{ + manageBuiltInDaemon?: boolean; + releaseChannel?: ReleaseChannel; +} | null> { + const stored = await loadRendererSettingsPayload(storage); + if (!stored) { + return null; + } + + const result: { + manageBuiltInDaemon?: boolean; + releaseChannel?: ReleaseChannel; + } = {}; + + if (typeof stored.manageBuiltInDaemon === "boolean") { + result.manageBuiltInDaemon = stored.manageBuiltInDaemon; + } + if (stored.releaseChannel === "stable" || stored.releaseChannel === "beta") { + result.releaseChannel = stored.releaseChannel; + } + + return Object.keys(result).length > 0 ? result : null; +} + +async function loadRendererSettingsPayload( + storage: KeyValueStorage, +): Promise | null> { + const current = await storage.getItem(APP_SETTINGS_KEY); + if (current) { + return JSON.parse(current) as Record; + } + + const legacy = await storage.getItem(LEGACY_SETTINGS_KEY); + if (!legacy) { + return null; + } + return JSON.parse(legacy) as Record; +} diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts deleted file mode 100644 index 3cda6be87..000000000 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts +++ /dev/null @@ -1,298 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import { act } from "@testing-library/react"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { createRoot, type Root } from "react-dom/client"; -import React from "react"; -import { afterEach, describe, expect, it, vi } from "vitest"; - -vi.hoisted(() => { - (globalThis as unknown as { __DEV__: boolean }).__DEV__ = false; -}); - -import { - appendMissingOrderKeys, - applyStoredOrdering, - buildSidebarProjectsFromStructure, - useSidebarWorkspacesList, -} from "./use-sidebar-workspaces-list"; -import type { WorkspaceStructureProject } from "@/stores/session-store-hooks"; -import { getHostRuntimeStore } from "@/runtime/host-runtime"; -import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; -import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; - -interface OrderedItem { - key: string; -} - -function item(key: string): OrderedItem { - return { key }; -} - -function project(input: { - projectKey: string; - projectName?: string; - projectKind?: WorkspaceStructureProject["projectKind"]; - iconWorkingDir?: string; - workspaceKeys: string[]; -}): WorkspaceStructureProject { - return { - projectKey: input.projectKey, - projectName: input.projectName ?? input.projectKey, - projectKind: input.projectKind ?? "git", - iconWorkingDir: input.iconWorkingDir ?? input.projectKey, - workspaceKeys: input.workspaceKeys, - }; -} - -function workspaceDescriptor(id: string): WorkspaceDescriptor { - return { - id, - projectId: "project-1", - projectDisplayName: "Project 1", - projectRootPath: "/repo/main", - workspaceDirectory: `/repo/main/${id}`, - projectKind: "git", - workspaceKind: "worktree", - name: id, - status: "done", - archivingAt: null, - diffStat: null, - scripts: [], - }; -} - -function DisabledHookProbe({ serverId }: { serverId: string }): null { - const result = useSidebarWorkspacesList({ serverId, enabled: false }); - - expect(result.projects).toEqual([]); - expect(result.isLoading).toBe(false); - expect(result.isInitialLoad).toBe(false); - expect(result.isRevalidating).toBe(false); - - return null; -} - -function DisabledRenderCountProbe({ - onRender, - serverId, -}: { - onRender: () => void; - serverId: string; -}): null { - useSidebarWorkspacesList({ serverId, enabled: false }); - onRender(); - return null; -} - -function HookResultProbe({ - onResult, - serverId, -}: { - onResult: (result: ReturnType) => void; - serverId: string; -}): null { - onResult(useSidebarWorkspacesList({ serverId })); - return null; -} - -describe("applyStoredOrdering", () => { - it("keeps unknown items on the baseline while applying stored order", () => { - const result = applyStoredOrdering({ - items: [item("new"), item("a"), item("b")], - storedOrder: ["b", "a"], - getKey: (entry) => entry.key, - }); - - expect(result.map((entry) => entry.key)).toEqual(["new", "b", "a"]); - }); - - it("ignores stale and duplicate stored keys", () => { - const result = applyStoredOrdering({ - items: [item("x"), item("y")], - storedOrder: ["missing", "y", "y", "x"], - getKey: (entry) => entry.key, - }); - - expect(result.map((entry) => entry.key)).toEqual(["y", "x"]); - }); - - it("returns baseline when there is no persisted order", () => { - const baseline = [item("first"), item("second")]; - const result = applyStoredOrdering({ - items: baseline, - storedOrder: [], - getKey: (entry) => entry.key, - }); - - expect(result).toBe(baseline); - }); -}); - -describe("appendMissingOrderKeys", () => { - it("appends unseen keys while preserving existing order", () => { - const result = appendMissingOrderKeys({ - currentOrder: ["project-b", "project-a"], - visibleKeys: ["project-a", "project-b", "project-c"], - }); - - expect(result).toEqual(["project-b", "project-a", "project-c"]); - }); - - it("returns the same array when there are no unseen keys", () => { - const currentOrder = ["project-a", "project-b"]; - - const result = appendMissingOrderKeys({ - currentOrder, - visibleKeys: ["project-b", "project-a"], - }); - - expect(result).toBe(currentOrder); - }); -}); - -describe("buildSidebarProjectsFromStructure", () => { - it("creates structural workspace rows from ordered workspace keys", () => { - const projects = buildSidebarProjectsFromStructure({ - serverId: "srv", - projects: [ - project({ - projectKey: "project-1", - projectName: "Project 1", - iconWorkingDir: "/repo/main", - workspaceKeys: ["ws-main"], - }), - ], - }); - - expect(projects).toHaveLength(1); - expect(projects[0]?.projectName).toBe("Project 1"); - expect(projects[0]?.workspaces[0]).toMatchObject({ - workspaceKey: "srv:ws-main", - serverId: "srv", - workspaceId: "ws-main", - projectRootPath: "/repo/main", - projectKind: "git", - }); - }); - - it("preserves the structure hook project order", () => { - const projects = buildSidebarProjectsFromStructure({ - serverId: "srv", - projects: [ - project({ projectKey: "project-b", workspaceKeys: ["ws-b"] }), - project({ projectKey: "project-a", workspaceKeys: ["ws-a"] }), - ], - }); - - expect(projects.map((entry) => entry.projectKey)).toEqual(["project-b", "project-a"]); - }); - - it("preserves the structure hook workspace order", () => { - const projects = buildSidebarProjectsFromStructure({ - serverId: "srv", - projects: [project({ projectKey: "project-1", workspaceKeys: ["feature", "main"] })], - }); - - expect(projects[0]?.workspaces.map((workspace) => workspace.workspaceId)).toEqual([ - "feature", - "main", - ]); - }); -}); - -describe("useSidebarWorkspacesList", () => { - let root: Root | null = null; - let container: HTMLElement | null = null; - - afterEach(() => { - if (root) { - act(() => { - root?.unmount(); - }); - } - root = null; - container?.remove(); - container = null; - act(() => { - getHostRuntimeStore().syncHosts([]); - useSessionStore.getState().clearSession("srv-disabled"); - useSessionStore.getState().clearSession("srv-loading"); - useSidebarOrderStore.setState({ - projectOrderByServerId: {}, - workspaceOrderByServerAndProject: {}, - }); - }); - }); - - it("honors enabled false without appending persisted order keys", async () => { - act(() => { - useSessionStore.getState().initializeSession("srv-disabled", null as unknown as DaemonClient); - useSessionStore - .getState() - .setWorkspaces("srv-disabled", new Map([["ws-main", workspaceDescriptor("ws-main")]])); - useSessionStore.getState().setHasHydratedWorkspaces("srv-disabled", true); - }); - - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - - await act(async () => { - root?.render(React.createElement(DisabledHookProbe, { serverId: "srv-disabled" })); - }); - - expect(useSidebarOrderStore.getState().projectOrderByServerId).toEqual({}); - expect(useSidebarOrderStore.getState().workspaceOrderByServerAndProject).toEqual({}); - }); - - it("keeps the sidebar in initial load until workspace hydration succeeds", async () => { - const onResult = vi.fn(); - - act(() => { - useSessionStore.getState().initializeSession("srv-loading", null as unknown as DaemonClient); - }); - - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - - await act(async () => { - root?.render(React.createElement(HookResultProbe, { serverId: "srv-loading", onResult })); - }); - - expect(onResult).toHaveBeenLastCalledWith( - expect.objectContaining({ - projects: [], - isLoading: true, - isInitialLoad: true, - }), - ); - }); - - it("does not subscribe to order updates while disabled", async () => { - const onRender = vi.fn(); - - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - - await act(async () => { - root?.render( - React.createElement(DisabledRenderCountProbe, { - serverId: "srv-disabled", - onRender, - }), - ); - }); - - expect(onRender).toHaveBeenCalledTimes(1); - - await act(async () => { - useSidebarOrderStore.getState().setProjectOrder("srv-disabled", ["project-a"]); - }); - - expect(onRender).toHaveBeenCalledTimes(1); - }); -}); diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.ts index 31ddb96e8..5d6d0c4e1 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.ts @@ -5,81 +5,31 @@ import { useSessionStore, type WorkspaceDescriptor, } from "@/stores/session-store"; -import { selectPrHintFromStatus, type PrHint } from "@/git/use-pr-status-query"; -import { - useWorkspaceStructure, - type WorkspaceStructureProject, -} from "@/stores/session-store-hooks"; +import { selectPrHintFromStatus } from "@/git/use-pr-status-query"; +import { useWorkspaceStructure } from "@/stores/session-store-hooks"; import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; import { shouldSuppressWorkspaceForLocalArchive } from "@/contexts/session-workspace-upserts"; +import { + buildSidebarProjectsFromStructure, + computeSidebarOrderUpdates, + deriveSidebarLoadingState, + type SidebarProjectEntry, + type SidebarWorkspaceEntry, +} from "./sidebar-workspaces-view-model"; -const EMPTY_ORDER: string[] = []; -const EMPTY_PROJECTS: SidebarProjectEntry[] = []; - -export type SidebarStateBucket = WorkspaceDescriptor["status"]; - -export interface SidebarWorkspaceEntry { - workspaceKey: string; - serverId: string; - workspaceId: string; - projectKey: string; - projectRootPath?: string; - workspaceDirectory?: string; - projectKind: WorkspaceDescriptor["projectKind"]; - workspaceKind: WorkspaceDescriptor["workspaceKind"]; - name: string; - statusBucket: SidebarStateBucket; - archivingAt: string | null; - diffStat: { additions: number; deletions: number } | null; - prHint: PrHint | null; - archiveHasUncommittedChanges: boolean | null; - archiveUnpushedCommitCount: number | null; - scripts: WorkspaceDescriptor["scripts"]; - hasRunningScripts: boolean; -} - -export interface SidebarProjectEntry { - projectKey: string; - projectName: string; - projectKind: WorkspaceDescriptor["projectKind"]; - iconWorkingDir: string; - workspaces: SidebarWorkspaceEntry[]; -} - -export interface SidebarWorkspacesListResult { - projects: SidebarProjectEntry[]; - isLoading: boolean; - isInitialLoad: boolean; - isRevalidating: boolean; - refreshAll: () => void; -} - -function createStructuralWorkspaceEntry(input: { - serverId: string; - project: WorkspaceStructureProject; - workspaceId: string; -}): SidebarWorkspaceEntry { - return { - workspaceKey: `${input.serverId}:${input.workspaceId}`, - serverId: input.serverId, - workspaceId: input.workspaceId, - projectKey: input.project.projectKey, - projectRootPath: input.project.iconWorkingDir, - workspaceDirectory: undefined, - projectKind: input.project.projectKind, - workspaceKind: "checkout", - name: input.workspaceId, - statusBucket: "done", - archivingAt: null, - diffStat: null, - prHint: null, - archiveHasUncommittedChanges: null, - archiveUnpushedCommitCount: null, - scripts: [], - hasRunningScripts: false, - }; -} +export { + appendMissingOrderKeys, + applyStoredOrdering, + buildSidebarProjectsFromStructure, + computeSidebarOrderUpdates, + deriveSidebarLoadingState, + type SidebarLoadingState, + type SidebarOrderUpdates, + type SidebarProjectEntry, + type SidebarStateBucket, + type SidebarWorkspaceEntry, +} from "./sidebar-workspaces-view-model"; export function createSidebarWorkspaceEntry(input: { serverId: string; @@ -106,91 +56,15 @@ export function createSidebarWorkspaceEntry(input: { }; } -export function buildSidebarProjectsFromStructure(input: { - serverId: string; - projects: WorkspaceStructureProject[]; -}): SidebarProjectEntry[] { - if (input.projects.length === 0) { - return EMPTY_PROJECTS; - } +const EMPTY_ORDER: string[] = []; +const EMPTY_PROJECTS: SidebarProjectEntry[] = []; - return input.projects.map((project) => ({ - projectKey: project.projectKey, - projectName: project.projectName, - projectKind: project.projectKind, - iconWorkingDir: project.iconWorkingDir, - workspaces: project.workspaceKeys.map((workspaceId) => - createStructuralWorkspaceEntry({ - serverId: input.serverId, - project, - workspaceId, - }), - ), - })); -} - -export function applyStoredOrdering(input: { - items: T[]; - storedOrder: string[]; - getKey: (item: T) => string; -}): T[] { - if (input.items.length <= 1 || input.storedOrder.length === 0) { - return input.items; - } - - const itemByKey = new Map(); - for (const item of input.items) { - itemByKey.set(input.getKey(item), item); - } - - const prunedOrder: string[] = []; - const seen = new Set(); - for (const key of input.storedOrder) { - if (!itemByKey.has(key) || seen.has(key)) { - continue; - } - seen.add(key); - prunedOrder.push(key); - } - - if (prunedOrder.length === 0) { - return input.items; - } - - const orderedSet = new Set(prunedOrder); - const ordered: T[] = []; - let orderedIndex = 0; - - for (const item of input.items) { - const key = input.getKey(item); - if (!orderedSet.has(key)) { - ordered.push(item); - continue; - } - - const targetKey = prunedOrder[orderedIndex] ?? key; - orderedIndex += 1; - ordered.push(itemByKey.get(targetKey) ?? item); - } - - return ordered; -} - -export function appendMissingOrderKeys(input: { - currentOrder: string[]; - visibleKeys: string[]; -}): string[] { - if (input.visibleKeys.length === 0) { - return input.currentOrder; - } - - const existingKeys = new Set(input.currentOrder); - const missingKeys = input.visibleKeys.filter((key) => !existingKeys.has(key)); - if (missingKeys.length === 0) { - return input.currentOrder; - } - - return [...input.currentOrder, ...missingKeys]; +export interface SidebarWorkspacesListResult { + projects: SidebarProjectEntry[]; + isLoading: boolean; + isInitialLoad: boolean; + isRevalidating: boolean; + refreshAll: () => void; } function toWorkspaceDescriptor(payload: WorkspaceDescriptorPayload): WorkspaceDescriptor { @@ -252,31 +126,22 @@ export function useSidebarWorkspacesList(options?: { }, [connectionStatus, hasHydratedWorkspaces, projects, serverId]); useEffect(() => { - if (!serverId || projects.length === 0) { + if (!serverId) { return; } - const nextProjectOrder = appendMissingOrderKeys({ - currentOrder: persistedProjectOrder, - visibleKeys: projects.map((project) => project.projectKey), + const orderStore = useSidebarOrderStore.getState(); + const updates = computeSidebarOrderUpdates({ + projects, + persistedProjectOrder, + getWorkspaceOrder: (projectKey) => orderStore.getWorkspaceOrder(serverId, projectKey), }); - if (nextProjectOrder !== persistedProjectOrder) { - useSidebarOrderStore.getState().setProjectOrder(serverId, nextProjectOrder); - } - for (const project of projects) { - const persistedWorkspaceOrder = useSidebarOrderStore - .getState() - .getWorkspaceOrder(serverId, project.projectKey); - const nextWorkspaceOrder = appendMissingOrderKeys({ - currentOrder: persistedWorkspaceOrder, - visibleKeys: project.workspaces.map((workspace) => workspace.workspaceKey), - }); - if (nextWorkspaceOrder !== persistedWorkspaceOrder) { - useSidebarOrderStore - .getState() - .setWorkspaceOrder(serverId, project.projectKey, nextWorkspaceOrder); - } + if (updates.projectOrder) { + orderStore.setProjectOrder(serverId, updates.projectOrder); + } + for (const { projectKey, order } of updates.workspaceOrders) { + orderStore.setWorkspaceOrder(serverId, projectKey, order); } }, [persistedProjectOrder, projects, serverId]); @@ -323,15 +188,16 @@ export function useSidebarWorkspacesList(options?: { })(); }, [connectionStatus, isActive, runtime, serverId]); - const isLoading = isActive && Boolean(serverId) && !hasHydratedWorkspaces; - const isInitialLoad = isLoading && projects.length === 0; - const isRevalidating = false; + const loadingState = deriveSidebarLoadingState({ + isActive, + serverId, + hasHydratedWorkspaces, + hasProjects: projects.length > 0, + }); return { projects, - isLoading, - isInitialLoad, - isRevalidating, + ...loadingState, refreshAll, }; } diff --git a/packages/app/src/hooks/use-workspace-navigation.test.ts b/packages/app/src/hooks/use-workspace-navigation.test.ts deleted file mode 100644 index bb9e7c6e9..000000000 --- a/packages/app/src/hooks/use-workspace-navigation.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import { renderHook } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; - -const navigateToWorkspaceMock = vi.hoisted(() => vi.fn()); - -vi.mock("@/stores/navigation-active-workspace-store", () => ({ - navigateToWorkspace: navigateToWorkspaceMock, -})); - -import { navigateToWorkspace, useWorkspaceNavigation } from "@/hooks/use-workspace-navigation"; - -describe("useWorkspaceNavigation", () => { - it("re-exports the workspace navigation action", () => { - expect(navigateToWorkspace).toBe(navigateToWorkspaceMock); - }); - - it("returns a stable callback wrapper", () => { - const { result } = renderHook(() => useWorkspaceNavigation()); - - result.current.navigateToWorkspace("server-1", "workspace-a"); - - expect(navigateToWorkspaceMock).toHaveBeenCalledWith("server-1", "workspace-a", undefined); - }); -}); diff --git a/packages/app/src/hooks/use-workspace-navigation.ts b/packages/app/src/hooks/use-workspace-navigation.ts deleted file mode 100644 index 829206139..000000000 --- a/packages/app/src/hooks/use-workspace-navigation.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { useCallback } from "react"; -import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; - -export { navigateToWorkspace }; - -export function useWorkspaceNavigation() { - return { - navigateToWorkspace: useCallback( - ( - serverId: string, - workspaceId: string, - options?: Parameters[2], - ) => { - navigateToWorkspace(serverId, workspaceId, options); - }, - [], - ), - }; -} diff --git a/packages/app/src/native/ios-hardware-keyboard-submit.ios.ts b/packages/app/src/native/ios-hardware-keyboard-submit.ios.ts index cffff5c40..981d71933 100644 --- a/packages/app/src/native/ios-hardware-keyboard-submit.ios.ts +++ b/packages/app/src/native/ios-hardware-keyboard-submit.ios.ts @@ -19,11 +19,3 @@ export function setHardwareKeyboardSubmitEnabled(enabled: boolean) { export function addHardwareKeyboardSubmitListener(handler: HardwareKeyboardSubmitHandler) { return module.addListener("onHardwareKeyboardSubmit", handler); } - -export function emitHardwareKeyboardSubmitForTest() {} - -export function resetHardwareKeyboardSubmitForTest() {} - -export function getHardwareKeyboardSubmitEnabledForTest() { - return false; -} diff --git a/packages/app/src/native/ios-hardware-keyboard-submit.ts b/packages/app/src/native/ios-hardware-keyboard-submit.ts index 0a1833b7f..41dde26e5 100644 --- a/packages/app/src/native/ios-hardware-keyboard-submit.ts +++ b/packages/app/src/native/ios-hardware-keyboard-submit.ts @@ -2,33 +2,12 @@ import type { EventSubscription } from "expo-modules-core"; type HardwareKeyboardSubmitHandler = () => void; -const testHandlers = new Set(); -let isEnabledForTest = false; - -export function setHardwareKeyboardSubmitEnabled(enabled: boolean) { - isEnabledForTest = enabled; -} +export function setHardwareKeyboardSubmitEnabled(_enabled: boolean) {} export function addHardwareKeyboardSubmitListener( - handler: HardwareKeyboardSubmitHandler, + _handler: HardwareKeyboardSubmitHandler, ): EventSubscription { - testHandlers.add(handler); return { - remove: () => { - testHandlers.delete(handler); - }, + remove: () => {}, }; } - -export function emitHardwareKeyboardSubmitForTest() { - testHandlers.forEach((handler) => handler()); -} - -export function resetHardwareKeyboardSubmitForTest() { - testHandlers.clear(); - isEnabledForTest = false; -} - -export function getHardwareKeyboardSubmitEnabledForTest() { - return isEnabledForTest; -} diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 9185fa8b1..9f4dd1b08 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -59,7 +59,8 @@ import { type Agent, useSessionStore } from "@/stores/session-store"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store"; import type { Theme } from "@/styles/theme"; -import { SubagentsTrack, useArchiveSubagent, useSubagentsForParent } from "@/subagents"; +import { useArchiveSubagent, useSubagentsForParent } from "@/subagents"; +import { SubagentsTrack } from "@/subagents/track"; import type { PendingPermission } from "@/types/shared"; import type { StreamItem } from "@/types/stream"; import { getInitDeferred, getInitKey } from "@/utils/agent-initialization"; diff --git a/packages/app/src/polyfills/crypto.test.ts b/packages/app/src/polyfills/crypto.test.ts deleted file mode 100644 index 2eec3cc79..000000000 --- a/packages/app/src/polyfills/crypto.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const expoCryptoMock = vi.hoisted(() => ({ - getRandomValues: vi.fn((array: T): T => array), - randomUUID: vi.fn(() => { - throw new Error("ExpoCrypto.randomUUID should not be used for the web fallback"); - }), -})); - -vi.mock("expo-crypto", () => expoCryptoMock); - -describe("polyfillCrypto", () => { - beforeEach(() => { - vi.resetModules(); - vi.unstubAllGlobals(); - expoCryptoMock.getRandomValues.mockClear(); - expoCryptoMock.randomUUID.mockClear(); - }); - - it("generates randomUUID from getRandomValues when Web Crypto randomUUID is unavailable", async () => { - const sourceBytes = Uint8Array.from([ - 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, - 0xff, - ]); - const getRandomValues = vi.fn((array: T): T => { - if (array && ArrayBuffer.isView(array)) { - new Uint8Array(array.buffer, array.byteOffset, array.byteLength).set(sourceBytes); - } - return array; - }); - vi.stubGlobal("crypto", { getRandomValues }); - - const { polyfillCrypto } = await import("./crypto"); - polyfillCrypto(); - - expect(globalThis.crypto.randomUUID()).toBe("00112233-4455-4677-8899-aabbccddeeff"); - expect(getRandomValues).toHaveBeenCalledTimes(1); - expect(expoCryptoMock.randomUUID).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/app/src/polyfills/crypto.ts b/packages/app/src/polyfills/crypto.ts index 968ee68f5..f498281fb 100644 --- a/packages/app/src/polyfills/crypto.ts +++ b/packages/app/src/polyfills/crypto.ts @@ -1,85 +1,11 @@ import * as ExpoCrypto from "expo-crypto"; -import { Buffer } from "buffer"; - -declare global { - interface Crypto { - randomUUID(): `${string}-${string}-${string}-${string}-${string}`; - } -} - -interface MutableGlobal { - TextEncoder?: typeof TextEncoder; - TextDecoder?: typeof TextDecoder; - crypto?: Crypto; -} - -type RandomUUID = `${string}-${string}-${string}-${string}-${string}`; -type FillRandomValues = (array: T) => T; - -function createUuidV4(fillRandomValues: FillRandomValues): RandomUUID { - const bytes = fillRandomValues(new Uint8Array(16)); - bytes[6] = (bytes[6]! & 0x0f) | 0x40; - bytes[8] = (bytes[8]! & 0x3f) | 0x80; - - const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")); - return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex - .slice(6, 8) - .join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10, 16).join("")}` as RandomUUID; -} +import { installCryptoPolyfills, type CryptoPolyfillTarget } from "./install-crypto-polyfills"; export function polyfillCrypto(): void { - const g = globalThis as unknown as MutableGlobal; - const nativeGetRandomValues = - typeof g.crypto?.getRandomValues === "function" - ? g.crypto.getRandomValues.bind(g.crypto) - : null; - - // Ensure TextEncoder/TextDecoder exist for shared E2EE code (tweetnacl + relay transport). - // Hermes may not provide them in all configurations. - if (typeof g.TextEncoder !== "function") { - class BufferTextEncoder { - encode(input = ""): Uint8Array { - return Uint8Array.from(Buffer.from(input, "utf8")); - } - } - g.TextEncoder = BufferTextEncoder as unknown as typeof TextEncoder; - } - - if (typeof g.TextDecoder !== "function") { - class BufferTextDecoder { - decode(input?: ArrayBuffer | ArrayBufferView): string { - if (input == null) return ""; - if (input instanceof ArrayBuffer) { - return Buffer.from(input).toString("utf8"); - } - if (ArrayBuffer.isView(input)) { - return Buffer.from(input.buffer, input.byteOffset, input.byteLength).toString("utf8"); - } - return Buffer.from(String(input), "utf8").toString("utf8"); - } - } - g.TextDecoder = BufferTextDecoder as unknown as typeof TextDecoder; - } - - if (!g.crypto) { - g.crypto = {} as Crypto; - } - - const fillRandomValues: FillRandomValues = (array: T): T => { - if (array === null) return array; - if (nativeGetRandomValues) { - return nativeGetRandomValues(array as unknown as ArrayBufferView) as T; - } - return ExpoCrypto.getRandomValues( - array as unknown as Parameters[0], - ) as unknown as T; - }; - - if (typeof g.crypto.randomUUID !== "function") { - g.crypto.randomUUID = () => createUuidV4(fillRandomValues); - } - - if (typeof g.crypto.getRandomValues !== "function") { - g.crypto.getRandomValues = fillRandomValues; - } + installCryptoPolyfills(globalThis as unknown as CryptoPolyfillTarget, { + expoGetRandomValues: (array: T): T => + ExpoCrypto.getRandomValues( + array as unknown as Parameters[0], + ) as unknown as T, + }); } diff --git a/packages/app/src/polyfills/install-crypto-polyfills.test.ts b/packages/app/src/polyfills/install-crypto-polyfills.test.ts new file mode 100644 index 000000000..3b146faf9 --- /dev/null +++ b/packages/app/src/polyfills/install-crypto-polyfills.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { + installCryptoPolyfills, + type CryptoPolyfillSources, + type CryptoPolyfillTarget, +} from "./install-crypto-polyfills"; + +interface RecordingFillRandomValues { + fill: CryptoPolyfillSources["expoGetRandomValues"]; + calls: number; +} + +function recordingFillFromBytes(bytes: Uint8Array): RecordingFillRandomValues { + const recorder = { calls: 0 } as RecordingFillRandomValues; + recorder.fill = (array: T): T => { + recorder.calls += 1; + if (array && ArrayBuffer.isView(array)) { + new Uint8Array(array.buffer, array.byteOffset, array.byteLength).set(bytes); + } + return array; + }; + return recorder; +} + +const TEST_BYTES = Uint8Array.from([ + 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, +]); + +describe("installCryptoPolyfills", () => { + it("derives randomUUID from the target's native getRandomValues when available", () => { + const native = recordingFillFromBytes(TEST_BYTES); + const expo = recordingFillFromBytes(TEST_BYTES); + const target: CryptoPolyfillTarget = { + crypto: { getRandomValues: native.fill } as unknown as Crypto, + }; + + installCryptoPolyfills(target, { expoGetRandomValues: expo.fill }); + + expect(target.crypto!.randomUUID()).toBe("00112233-4455-4677-8899-aabbccddeeff"); + expect(native.calls).toBe(1); + expect(expo.calls).toBe(0); + }); + + it("falls back to the injected expo source when the target has no crypto", () => { + const expo = recordingFillFromBytes(TEST_BYTES); + const target: CryptoPolyfillTarget = {}; + + installCryptoPolyfills(target, { expoGetRandomValues: expo.fill }); + + expect(target.crypto!.randomUUID()).toBe("00112233-4455-4677-8899-aabbccddeeff"); + expect(expo.calls).toBe(1); + }); + + it("installs getRandomValues that delegates to the expo source", () => { + const expo = recordingFillFromBytes(TEST_BYTES); + const target: CryptoPolyfillTarget = {}; + + installCryptoPolyfills(target, { expoGetRandomValues: expo.fill }); + + const buf = new Uint8Array(16); + target.crypto!.getRandomValues(buf); + expect(Array.from(buf)).toEqual(Array.from(TEST_BYTES)); + expect(expo.calls).toBe(1); + }); + + it("installs Buffer-backed TextEncoder and TextDecoder when missing", () => { + const expo = recordingFillFromBytes(TEST_BYTES); + const target: CryptoPolyfillTarget = {}; + + installCryptoPolyfills(target, { expoGetRandomValues: expo.fill }); + + expect(typeof target.TextEncoder).toBe("function"); + expect(typeof target.TextDecoder).toBe("function"); + const encoded = new target.TextEncoder!().encode("hello"); + expect(Array.from(encoded)).toEqual([104, 101, 108, 108, 111]); + expect(new target.TextDecoder!().decode(encoded)).toBe("hello"); + }); +}); diff --git a/packages/app/src/polyfills/install-crypto-polyfills.ts b/packages/app/src/polyfills/install-crypto-polyfills.ts new file mode 100644 index 000000000..f9fb2b839 --- /dev/null +++ b/packages/app/src/polyfills/install-crypto-polyfills.ts @@ -0,0 +1,88 @@ +import { Buffer } from "buffer"; + +declare global { + interface Crypto { + randomUUID(): `${string}-${string}-${string}-${string}-${string}`; + } +} + +export interface CryptoPolyfillTarget { + TextEncoder?: typeof TextEncoder; + TextDecoder?: typeof TextDecoder; + crypto?: Crypto; +} + +type RandomUUID = `${string}-${string}-${string}-${string}-${string}`; +type FillRandomValues = (array: T) => T; + +export interface CryptoPolyfillSources { + expoGetRandomValues: FillRandomValues; +} + +function createUuidV4(fillRandomValues: FillRandomValues): RandomUUID { + const bytes = fillRandomValues(new Uint8Array(16)); + bytes[6] = (bytes[6]! & 0x0f) | 0x40; + bytes[8] = (bytes[8]! & 0x3f) | 0x80; + + const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")); + return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex + .slice(6, 8) + .join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10, 16).join("")}` as RandomUUID; +} + +export function installCryptoPolyfills( + target: CryptoPolyfillTarget, + sources: CryptoPolyfillSources, +): void { + const nativeGetRandomValues = + typeof target.crypto?.getRandomValues === "function" + ? target.crypto.getRandomValues.bind(target.crypto) + : null; + + // Ensure TextEncoder/TextDecoder exist for shared E2EE code (tweetnacl + relay transport). + // Hermes may not provide them in all configurations. + if (typeof target.TextEncoder !== "function") { + class BufferTextEncoder { + encode(input = ""): Uint8Array { + return Uint8Array.from(Buffer.from(input, "utf8")); + } + } + target.TextEncoder = BufferTextEncoder as unknown as typeof TextEncoder; + } + + if (typeof target.TextDecoder !== "function") { + class BufferTextDecoder { + decode(input?: ArrayBuffer | ArrayBufferView): string { + if (input == null) return ""; + if (input instanceof ArrayBuffer) { + return Buffer.from(input).toString("utf8"); + } + if (ArrayBuffer.isView(input)) { + return Buffer.from(input.buffer, input.byteOffset, input.byteLength).toString("utf8"); + } + return Buffer.from(String(input), "utf8").toString("utf8"); + } + } + target.TextDecoder = BufferTextDecoder as unknown as typeof TextDecoder; + } + + if (!target.crypto) { + target.crypto = {} as Crypto; + } + + const fillRandomValues: FillRandomValues = (array: T): T => { + if (array === null) return array; + if (nativeGetRandomValues) { + return nativeGetRandomValues(array as unknown as ArrayBufferView) as T; + } + return sources.expoGetRandomValues(array); + }; + + if (typeof target.crypto.randomUUID !== "function") { + target.crypto.randomUUID = () => createUuidV4(fillRandomValues); + } + + if (typeof target.crypto.getRandomValues !== "function") { + target.crypto.getRandomValues = fillRandomValues; + } +} diff --git a/packages/app/src/review/state.ts b/packages/app/src/review/state.ts new file mode 100644 index 000000000..422b351b8 --- /dev/null +++ b/packages/app/src/review/state.ts @@ -0,0 +1,187 @@ +export type ReviewDraftMode = "uncommitted" | "base"; +export type ReviewDraftSide = "old" | "new"; + +export interface ReviewDraftComment { + id: string; + filePath: string; + side: ReviewDraftSide; + lineNumber: number; + body: string; + createdAt: string; + updatedAt: string; +} + +export interface ReviewDraftStoreState { + drafts: Record; + activeModesByScope: Record; +} + +export interface SerializedReviewDraftState { + drafts: Record; + activeModesByScope: Record; +} + +export function setActiveModeInState( + state: ReviewDraftStoreState, + input: { scopeKey: string; mode: ReviewDraftMode }, +): ReviewDraftStoreState { + if (state.activeModesByScope[input.scopeKey] === input.mode) { + return state; + } + return { + ...state, + activeModesByScope: { + ...state.activeModesByScope, + [input.scopeKey]: input.mode, + }, + }; +} + +export function addCommentToState( + state: ReviewDraftStoreState, + input: { key: string; comment: ReviewDraftComment }, +): ReviewDraftStoreState { + return { + ...state, + drafts: { + ...state.drafts, + [input.key]: [...(state.drafts[input.key] ?? []), input.comment], + }, + }; +} + +export function updateCommentInState( + state: ReviewDraftStoreState, + input: { + key: string; + id: string; + updates: Partial>; + updatedAt: string; + }, +): ReviewDraftStoreState { + const comments = state.drafts[input.key] ?? []; + if (!comments.some((comment) => comment.id === input.id)) { + return state; + } + return { + ...state, + drafts: { + ...state.drafts, + [input.key]: comments.map((comment) => + applyCommentUpdates(comment, input.id, input.updates, input.updatedAt), + ), + }, + }; +} + +export function deleteCommentFromState( + state: ReviewDraftStoreState, + input: { key: string; id: string }, +): ReviewDraftStoreState { + const comments = state.drafts[input.key] ?? []; + if (!comments.some((comment) => comment.id === input.id)) { + return state; + } + return { + ...state, + drafts: { + ...state.drafts, + [input.key]: comments.filter((comment) => comment.id !== input.id), + }, + }; +} + +export function clearReviewInState( + state: ReviewDraftStoreState, + input: { key: string }, +): ReviewDraftStoreState { + if (!state.drafts[input.key]) { + return state; + } + const nextDrafts = { ...state.drafts }; + delete nextDrafts[input.key]; + return { ...state, drafts: nextDrafts }; +} + +export function serializeReviewDraftState( + state: ReviewDraftStoreState, +): SerializedReviewDraftState { + return { + drafts: state.drafts, + activeModesByScope: state.activeModesByScope, + }; +} + +export function normalizePersistedState(state: unknown): ReviewDraftStoreState { + if (!state || typeof state !== "object") { + return { drafts: {}, activeModesByScope: {} }; + } + const persisted = state as { drafts?: unknown; activeModesByScope?: unknown }; + const drafts = persisted.drafts; + if (!drafts || typeof drafts !== "object" || Array.isArray(drafts)) { + return { drafts: {}, activeModesByScope: {} }; + } + + const normalized: Record = {}; + for (const [key, value] of Object.entries(drafts)) { + if (!Array.isArray(value)) { + continue; + } + normalized[key] = value.filter((comment): comment is ReviewDraftComment => + isReviewDraftComment(comment), + ); + } + + const activeModesByScope: Record = {}; + const persistedActiveModes = persisted.activeModesByScope; + if ( + persistedActiveModes && + typeof persistedActiveModes === "object" && + !Array.isArray(persistedActiveModes) + ) { + for (const [key, mode] of Object.entries(persistedActiveModes)) { + if (mode === "base" || mode === "uncommitted") { + activeModesByScope[key] = mode; + } + } + } + return { drafts: normalized, activeModesByScope }; +} + +export function isReviewDraftComment(value: unknown): value is ReviewDraftComment { + if (!value || typeof value !== "object") { + return false; + } + const record = value as Record; + return ( + typeof record.id === "string" && + typeof record.filePath === "string" && + (record.side === "old" || record.side === "new") && + typeof record.lineNumber === "number" && + Number.isInteger(record.lineNumber) && + record.lineNumber > 0 && + typeof record.body === "string" && + typeof record.createdAt === "string" && + typeof record.updatedAt === "string" + ); +} + +function applyCommentUpdates( + comment: ReviewDraftComment, + targetId: string, + updates: Partial>, + updatedAt: string, +): ReviewDraftComment { + if (comment.id !== targetId) { + return comment; + } + return { + id: comment.id, + filePath: comment.filePath, + side: comment.side, + lineNumber: comment.lineNumber, + body: updates.body ?? comment.body, + createdAt: comment.createdAt, + updatedAt, + }; +} diff --git a/packages/app/src/review/store.test.ts b/packages/app/src/review/store.test.ts index e8dd83079..294c12d42 100644 --- a/packages/app/src/review/store.test.ts +++ b/packages/app/src/review/store.test.ts @@ -1,15 +1,37 @@ -import "@/test/window-local-storage"; -import AsyncStorage from "@react-native-async-storage/async-storage"; import { describe, expect, it } from "vitest"; import type { ParsedDiffFile } from "@/git/use-diff-query"; import { buildReviewAttachmentSnapshot, buildReviewDraftKey, buildReviewDraftScopeKey, - useReviewDraftStore, } from "./store"; +import { + addCommentToState, + clearReviewInState, + deleteCommentFromState, + normalizePersistedState, + type ReviewDraftComment, + type ReviewDraftStoreState, + setActiveModeInState, + updateCommentInState, +} from "./state"; -const STORE_STORAGE_KEY = "@paseo:review-draft-store"; +function emptyState(): ReviewDraftStoreState { + return { drafts: {}, activeModesByScope: {} }; +} + +function makeComment(overrides: Partial = {}): ReviewDraftComment { + return { + id: "comment-1", + filePath: "src/example.ts", + side: "new", + lineNumber: 41, + body: "Please simplify this.", + createdAt: "2026-04-21T00:00:00.000Z", + updatedAt: "2026-04-21T00:00:00.000Z", + ...overrides, + }; +} function makeFile(): ParsedDiffFile { return { @@ -89,44 +111,35 @@ describe("buildReviewDraftKey", () => { }); }); -describe("review draft store", () => { - it("normalizes persisted active review modes with draft comments", async () => { - await AsyncStorage.setItem( - STORE_STORAGE_KEY, - JSON.stringify({ - version: 0, - state: { - drafts: { - "review:key": [ - { - id: "comment-1", - filePath: "src/example.ts", - side: "new", - lineNumber: 41, - body: "Keep me.", - createdAt: "2026-04-21T00:00:00.000Z", - updatedAt: "2026-04-21T00:00:00.000Z", - }, - { id: "bad", filePath: "src/example.ts" }, - ], +describe("normalizePersistedState", () => { + it("filters invalid draft comments and drops unknown active modes", () => { + const normalized = normalizePersistedState({ + drafts: { + "review:key": [ + { + id: "comment-1", + filePath: "src/example.ts", + side: "new", + lineNumber: 41, + body: "Keep me.", + createdAt: "2026-04-21T00:00:00.000Z", + updatedAt: "2026-04-21T00:00:00.000Z", }, - activeModesByScope: { - "review:scope:base": "base", - "review:scope:dirty": "uncommitted", - "review:scope:bad": "other", - }, - }, - }), - ); + { id: "bad", filePath: "src/example.ts" }, + ], + }, + activeModesByScope: { + "review:scope:base": "base", + "review:scope:dirty": "uncommitted", + "review:scope:bad": "other", + }, + }); - await useReviewDraftStore.persist.rehydrate(); - - const state = useReviewDraftStore.getState(); - expect(state.activeModesByScope).toEqual({ + expect(normalized.activeModesByScope).toEqual({ "review:scope:base": "base", "review:scope:dirty": "uncommitted", }); - expect(state.drafts["review:key"]).toEqual([ + expect(normalized.drafts["review:key"]).toEqual([ { id: "comment-1", filePath: "src/example.ts", @@ -139,54 +152,68 @@ describe("review draft store", () => { ]); }); - it("persists compact draft comments separately from workspace attachment wire context", () => { - const key = buildReviewDraftKey({ - serverId: "local", - workspaceId: "workspace-1", - cwd: "/repo", - mode: "uncommitted", - baseRef: null, - ignoreWhitespace: false, + it("returns empty state for null, non-object, or malformed inputs", () => { + expect(normalizePersistedState(null)).toEqual({ drafts: {}, activeModesByScope: {} }); + expect(normalizePersistedState("nope")).toEqual({ drafts: {}, activeModesByScope: {} }); + expect(normalizePersistedState({ drafts: [] })).toEqual({ + drafts: {}, + activeModesByScope: {}, }); + }); +}); - useReviewDraftStore.getState().clearReview({ key }); - useReviewDraftStore.getState().addComment({ - key, - comment: { - id: "comment-1", - filePath: "src/example.ts", - side: "new", - lineNumber: 41, - body: "Please simplify this.", - createdAt: "2026-04-21T00:00:00.000Z", - updatedAt: "2026-04-21T00:00:00.000Z", - }, - }); +describe("review draft reducers", () => { + it("adds, updates, and deletes draft comments by key", () => { + let state = emptyState(); + const comment = makeComment(); - expect(useReviewDraftStore.getState().drafts[key]).toEqual([ - { - id: "comment-1", - filePath: "src/example.ts", - side: "new", - lineNumber: 41, - body: "Please simplify this.", - createdAt: "2026-04-21T00:00:00.000Z", - updatedAt: "2026-04-21T00:00:00.000Z", - }, - ]); + state = addCommentToState(state, { key: "review:key", comment }); + expect(state.drafts["review:key"]).toEqual([comment]); - useReviewDraftStore.getState().updateComment({ - key, - id: "comment-1", + state = updateCommentInState(state, { + key: "review:key", + id: comment.id, updates: { body: "Please simplify this condition." }, updatedAt: "2026-04-21T00:01:00.000Z", }); - expect(useReviewDraftStore.getState().drafts[key]?.[0]?.body).toBe( - "Please simplify this condition.", - ); + expect(state.drafts["review:key"]?.[0]).toEqual({ + ...comment, + body: "Please simplify this condition.", + updatedAt: "2026-04-21T00:01:00.000Z", + }); - useReviewDraftStore.getState().deleteComment({ key, id: "comment-1" }); - expect(useReviewDraftStore.getState().drafts[key]).toEqual([]); + state = deleteCommentFromState(state, { key: "review:key", id: comment.id }); + expect(state.drafts["review:key"]).toEqual([]); + }); + + it("keeps state identity on no-op updates, deletes, and clears", () => { + const state = addCommentToState(emptyState(), { + key: "review:key", + comment: makeComment(), + }); + + expect( + updateCommentInState(state, { + key: "review:key", + id: "missing", + updates: { body: "x" }, + updatedAt: "2026-04-21T00:01:00.000Z", + }), + ).toBe(state); + expect(deleteCommentFromState(state, { key: "review:key", id: "missing" })).toBe(state); + expect(clearReviewInState(state, { key: "other-key" })).toBe(state); + }); + + it("keeps state identity when setActiveMode does not change the mode", () => { + const state = setActiveModeInState(emptyState(), { + scopeKey: "review:scope", + mode: "base", + }); + + expect(setActiveModeInState(state, { scopeKey: "review:scope", mode: "base" })).toBe(state); + expect(setActiveModeInState(state, { scopeKey: "review:scope", mode: "uncommitted" })).not.toBe( + state, + ); }); }); diff --git a/packages/app/src/review/store.ts b/packages/app/src/review/store.ts index 72da9f131..168381a42 100644 --- a/packages/app/src/review/store.ts +++ b/packages/app/src/review/store.ts @@ -4,10 +4,25 @@ import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import type { ComposerAttachment } from "@/attachments/types"; import type { ParsedDiffFile } from "@/git/use-diff-query"; +import { + addCommentToState, + clearReviewInState, + deleteCommentFromState, + normalizePersistedState, + type ReviewDraftComment, + type ReviewDraftMode, + type ReviewDraftSide, + type ReviewDraftStoreState, + serializeReviewDraftState, + setActiveModeInState, + updateCommentInState, +} from "@/review/state"; import { generateMessageId } from "@/types/stream"; import { buildNumberedDiffHunks, type NumberedDiffLine } from "@/utils/diff-layout"; import type { AgentAttachment } from "@getpaseo/protocol/messages"; +export type { ReviewDraftComment, ReviewDraftMode, ReviewDraftSide } from "@/review/state"; + const STORE_VERSION = 1; const CONTEXT_RADIUS = 3; const EMPTY_REVIEW_DRAFT_COMMENTS: ReviewDraftComment[] = []; @@ -16,19 +31,6 @@ type ReviewAttachment = Extract; type ReviewAttachmentContextLine = ReviewAttachment["comments"][number]["context"]["targetLine"]; type ReviewComposerAttachment = Extract; -export type ReviewDraftMode = "uncommitted" | "base"; -export type ReviewDraftSide = "old" | "new"; - -export interface ReviewDraftComment { - id: string; - filePath: string; - side: ReviewDraftSide; - lineNumber: number; - body: string; - createdAt: string; - updatedAt: string; -} - export interface BuildReviewDraftKeyInput { serverId: string; workspaceId?: string | null; @@ -49,10 +51,8 @@ export interface BuildReviewAttachmentSnapshotInput { diffFiles: readonly ParsedDiffFile[]; } -interface ReviewDraftStoreState { - drafts: Record; - activeModesByScope: Record; -} +export type ReviewDraftCommentInput = Omit & + Partial>; interface ReviewDraftStoreActions { setActiveMode: (input: { scopeKey: string; mode: ReviewDraftMode }) => void; @@ -67,9 +67,6 @@ interface ReviewDraftStoreActions { clearReview: (input: { key: string }) => void; } -export type ReviewDraftCommentInput = Omit & - Partial>; - type ReviewDraftStore = ReviewDraftStoreState & ReviewDraftStoreActions; function encodeKeyPart(value: string): string { @@ -128,158 +125,41 @@ function createDraftComment(input: ReviewDraftCommentInput): ReviewDraftComment }; } -function applyCommentUpdates( - comment: ReviewDraftComment, - targetId: string, - updates: Partial>, - updatedAt: string, -): ReviewDraftComment { - if (comment.id !== targetId) { - return comment; - } - return { - id: comment.id, - filePath: comment.filePath, - side: comment.side, - lineNumber: comment.lineNumber, - body: updates.body ?? comment.body, - createdAt: comment.createdAt, - updatedAt, - }; -} - -function normalizePersistedState(state: unknown): ReviewDraftStoreState { - if (!state || typeof state !== "object") { - return { drafts: {}, activeModesByScope: {} }; - } - const persisted = state as { drafts?: unknown; activeModesByScope?: unknown }; - const drafts = persisted.drafts; - if (!drafts || typeof drafts !== "object" || Array.isArray(drafts)) { - return { drafts: {}, activeModesByScope: {} }; - } - - const normalized: Record = {}; - for (const [key, value] of Object.entries(drafts)) { - if (!Array.isArray(value)) { - continue; - } - normalized[key] = value.filter((comment): comment is ReviewDraftComment => - isReviewDraftComment(comment), - ); - } - - const activeModesByScope: Record = {}; - const persistedActiveModes = persisted.activeModesByScope; - if ( - persistedActiveModes && - typeof persistedActiveModes === "object" && - !Array.isArray(persistedActiveModes) - ) { - for (const [key, mode] of Object.entries(persistedActiveModes)) { - if (mode === "base" || mode === "uncommitted") { - activeModesByScope[key] = mode; - } - } - } - return { drafts: normalized, activeModesByScope }; -} - -function isReviewDraftComment(value: unknown): value is ReviewDraftComment { - if (!value || typeof value !== "object") { - return false; - } - const record = value as Record; - return ( - typeof record.id === "string" && - typeof record.filePath === "string" && - (record.side === "old" || record.side === "new") && - typeof record.lineNumber === "number" && - Number.isInteger(record.lineNumber) && - record.lineNumber > 0 && - typeof record.body === "string" && - typeof record.createdAt === "string" && - typeof record.updatedAt === "string" - ); -} - export const useReviewDraftStore = create()( persist( (set) => ({ drafts: {}, activeModesByScope: {}, - setActiveMode: ({ scopeKey, mode }) => { - set((state) => { - if (state.activeModesByScope[scopeKey] === mode) { - return state; - } - return { - activeModesByScope: { - ...state.activeModesByScope, - [scopeKey]: mode, - }, - }; - }); + setActiveMode: (input) => { + set((state) => setActiveModeInState(state, input)); }, addComment: ({ key, comment }) => { const nextComment = createDraftComment(comment); - set((state) => ({ - drafts: { - ...state.drafts, - [key]: [...(state.drafts[key] ?? []), nextComment], - }, - })); + set((state) => addCommentToState(state, { key, comment: nextComment })); return nextComment; }, updateComment: ({ key, id, updates, updatedAt }) => { - set((state) => { - const comments = state.drafts[key] ?? []; - if (!comments.some((comment) => comment.id === id)) { - return state; - } - const nextUpdatedAt = updatedAt ?? new Date().toISOString(); - return { - drafts: { - ...state.drafts, - [key]: comments.map((comment) => - applyCommentUpdates(comment, id, updates, nextUpdatedAt), - ), - }, - }; - }); + set((state) => + updateCommentInState(state, { + key, + id, + updates, + updatedAt: updatedAt ?? new Date().toISOString(), + }), + ); }, - deleteComment: ({ key, id }) => { - set((state) => { - const comments = state.drafts[key] ?? []; - if (!comments.some((comment) => comment.id === id)) { - return state; - } - return { - drafts: { - ...state.drafts, - [key]: comments.filter((comment) => comment.id !== id), - }, - }; - }); + deleteComment: (input) => { + set((state) => deleteCommentFromState(state, input)); }, - clearReview: ({ key }) => { - set((state) => { - if (!state.drafts[key]) { - return state; - } - const next = { ...state.drafts }; - delete next[key]; - return { drafts: next }; - }); + clearReview: (input) => { + set((state) => clearReviewInState(state, input)); }, }), { name: "@paseo:review-draft-store", version: STORE_VERSION, storage: createJSONStorage(() => AsyncStorage), - partialize: (state) => ({ - drafts: state.drafts, - activeModesByScope: state.activeModesByScope, - }), + partialize: (state) => serializeReviewDraftState(state), migrate: async (state) => normalizePersistedState(state), }, ), diff --git a/packages/app/src/screens/new-workspace-empty.test.ts b/packages/app/src/screens/new-workspace-empty.test.ts index 3b56e184e..8065464bb 100644 --- a/packages/app/src/screens/new-workspace-empty.test.ts +++ b/packages/app/src/screens/new-workspace-empty.test.ts @@ -1,16 +1,7 @@ -import { beforeAll, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { ComposerAttachment } from "@/attachments/types"; import type { MessagePayload } from "@/composer/types"; - -const navigateToWorkspace = vi.hoisted(() => vi.fn()); -vi.mock("@/hooks/use-workspace-navigation", () => ({ navigateToWorkspace })); - -let isEmptyWorkspaceSubmission: typeof import("./new-workspace-empty").isEmptyWorkspaceSubmission; -let runCreateEmptyWorkspace: typeof import("./new-workspace-empty").runCreateEmptyWorkspace; - -beforeAll(async () => { - ({ isEmptyWorkspaceSubmission, runCreateEmptyWorkspace } = await import("./new-workspace-empty")); -}); +import { isEmptyWorkspaceSubmission, runCreateEmptyWorkspace } from "./new-workspace-empty"; function payload( input: { text?: string; attachments?: ComposerAttachment[] } = {}, @@ -18,12 +9,28 @@ function payload( return { text: input.text ?? "", attachments: input.attachments ?? [], cwd: "/sample/repo" }; } +function createRecordingNavigate() { + const recorded: Array<{ serverId: string; workspaceId: string }> = []; + return { + recorded, + navigate: (serverId: string, workspaceId: string) => { + recorded.push({ serverId, workspaceId }); + }, + }; +} + describe("runCreateEmptyWorkspace", () => { it("creates a workspace without prompt or attachments and navigates to it", async () => { const workspace = { id: "workspace-123" }; const ensureWorkspace = vi.fn().mockResolvedValue(workspace); + const { navigate, recorded } = createRecordingNavigate(); - await runCreateEmptyWorkspace({ payload: payload(), ensureWorkspace, serverId: "server-abc" }); + await runCreateEmptyWorkspace({ + payload: payload(), + ensureWorkspace, + serverId: "server-abc", + navigate, + }); expect(ensureWorkspace).toHaveBeenCalledOnce(); expect(ensureWorkspace).toHaveBeenCalledWith({ @@ -31,8 +38,7 @@ describe("runCreateEmptyWorkspace", () => { prompt: "", attachments: [], }); - expect(navigateToWorkspace).toHaveBeenCalledOnce(); - expect(navigateToWorkspace).toHaveBeenCalledWith("server-abc", workspace.id); + expect(recorded).toEqual([{ serverId: "server-abc", workspaceId: "workspace-123" }]); }); }); diff --git a/packages/app/src/screens/new-workspace-empty.ts b/packages/app/src/screens/new-workspace-empty.ts index 72c49985b..40fdcdf27 100644 --- a/packages/app/src/screens/new-workspace-empty.ts +++ b/packages/app/src/screens/new-workspace-empty.ts @@ -1,4 +1,3 @@ -import { navigateToWorkspace } from "@/hooks/use-workspace-navigation"; import type { normalizeWorkspaceDescriptor } from "@/stores/session-store"; import type { MessagePayload } from "@/composer/types"; import type { AgentAttachment } from "@getpaseo/protocol/messages"; @@ -15,14 +14,15 @@ export interface CreateEmptyWorkspaceInput { attachments: AgentAttachment[]; }) => Promise>; serverId: string; + navigate: (serverId: string, workspaceId: string) => void; } export async function runCreateEmptyWorkspace(input: CreateEmptyWorkspaceInput): Promise { - const { payload, ensureWorkspace, serverId } = input; + const { payload, ensureWorkspace, serverId, navigate } = input; const ensuredWorkspace = await ensureWorkspace({ cwd: payload.cwd, prompt: "", attachments: [], }); - navigateToWorkspace(serverId, ensuredWorkspace.id); + navigate(serverId, ensuredWorkspace.id); } diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 7380aa777..cc0971dd7 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -21,6 +21,7 @@ import { useToast } from "@/contexts/toast-context"; import { useAgentInputDraft } from "@/composer/draft/input-draft"; import { useGithubSearchQuery } from "@/git/use-github-search-query"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store"; import { generateDraftId } from "@/stores/draft-keys"; import { useDraftStore } from "@/stores/draft-store"; @@ -759,6 +760,7 @@ export function NewWorkspaceScreen({ payload, ensureWorkspace, serverId, + navigate: navigateToWorkspace, }); return; } diff --git a/packages/app/src/stores/browser-store.test.ts b/packages/app/src/stores/browser-store.test.ts deleted file mode 100644 index d7d039656..000000000 --- a/packages/app/src/stores/browser-store.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { normalizeWorkspaceBrowserUrl, useBrowserStore } from "./browser-store"; - -vi.mock("@react-native-async-storage/async-storage", () => ({ - default: { - getItem: vi.fn(), - setItem: vi.fn(), - removeItem: vi.fn(), - }, -})); - -describe("browser store", () => { - it("normalizes local development hosts to http by default", () => { - expect(normalizeWorkspaceBrowserUrl("localhost:8081")).toBe("http://localhost:8081"); - expect(normalizeWorkspaceBrowserUrl("localhost/path")).toBe("http://localhost/path"); - expect(normalizeWorkspaceBrowserUrl("127.0.0.1:3000/path")).toBe("http://127.0.0.1:3000/path"); - expect(normalizeWorkspaceBrowserUrl("192.168.0.8")).toBe("http://192.168.0.8"); - expect(normalizeWorkspaceBrowserUrl("[::1]:5173")).toBe("http://[::1]:5173"); - }); - - it("normalizes public hosts to https by default", () => { - expect(normalizeWorkspaceBrowserUrl("example.com")).toBe("https://example.com"); - expect(normalizeWorkspaceBrowserUrl("//example.com/path")).toBe("https://example.com/path"); - }); - - it("keeps explicit protocols unchanged", () => { - expect(normalizeWorkspaceBrowserUrl("http://localhost:8081")).toBe("http://localhost:8081"); - expect(normalizeWorkspaceBrowserUrl("https://localhost:8081")).toBe("https://localhost:8081"); - expect(normalizeWorkspaceBrowserUrl("file:///tmp/example.html")).toBe( - "file:///tmp/example.html", - ); - }); - - it("normalizes browser URLs when creating and updating records", () => { - useBrowserStore.setState({ browsersById: {} }); - - const browserId = useBrowserStore.getState().createBrowser({ initialUrl: "localhost:8081" }); - expect(useBrowserStore.getState().browsersById[browserId]?.url).toBe("http://localhost:8081"); - - useBrowserStore.getState().updateBrowser(browserId, { url: "example.com/path" }); - expect(useBrowserStore.getState().browsersById[browserId]?.url).toBe( - "https://example.com/path", - ); - }); -}); diff --git a/packages/app/src/stores/browser-store.ts b/packages/app/src/stores/browser-store.ts deleted file mode 100644 index e18d359cb..000000000 --- a/packages/app/src/stores/browser-store.ts +++ /dev/null @@ -1,177 +0,0 @@ -import AsyncStorage from "@react-native-async-storage/async-storage"; -import { create } from "zustand"; -import { createJSONStorage, persist } from "zustand/middleware"; - -export interface BrowserRecord { - browserId: string; - url: string; - title: string; - isLoading: boolean; - canGoBack: boolean; - canGoForward: boolean; - faviconUrl: string | null; - lastError: string | null; - createdAt: number; -} - -type BrowserRecordPatch = Partial>; - -interface BrowserStoreState { - browsersById: Record; - createBrowser: (input?: { initialUrl?: string }) => string; - updateBrowser: (browserId: string, patch: BrowserRecordPatch) => void; - removeBrowser: (browserId: string) => void; -} - -function trimNonEmpty(value: string | null | undefined): string | null { - if (typeof value !== "string") { - return null; - } - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -function normalizeBrowserUrl(value: string | null | undefined): string { - const trimmed = trimNonEmpty(value); - if (!trimmed) { - return "https://example.com"; - } - if (/^(localhost|\d{1,3}(?:\.\d{1,3}){3}|\[[\da-fA-F:.]+])(?::\d+)?(?:[/?#]|$)/.test(trimmed)) { - return `http://${trimmed}`; - } - if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(trimmed)) { - return trimmed; - } - if (trimmed.startsWith("//")) { - return `https:${trimmed}`; - } - return `https://${trimmed}`; -} - -function createBrowserId(): string { - if (typeof globalThis.crypto?.randomUUID === "function") { - return globalThis.crypto.randomUUID(); - } - return `${Date.now()}-${Math.random().toString(16).slice(2)}`; -} - -export const useBrowserStore = create()( - persist( - (set) => ({ - browsersById: {}, - createBrowser: (input) => { - const browserId = createBrowserId(); - const now = Date.now(); - const initialUrl = normalizeBrowserUrl(input?.initialUrl); - - set((state) => ({ - browsersById: { - ...state.browsersById, - [browserId]: { - browserId, - url: initialUrl, - title: "", - isLoading: false, - canGoBack: false, - canGoForward: false, - faviconUrl: null, - lastError: null, - createdAt: now, - }, - }, - })); - - return browserId; - }, - updateBrowser: (browserId, patch) => { - const normalizedBrowserId = trimNonEmpty(browserId); - if (!normalizedBrowserId) { - return; - } - - set((state) => { - const existing = state.browsersById[normalizedBrowserId]; - if (!existing) { - return state; - } - - const nextRecord: BrowserRecord = { - ...existing, - ...patch, - url: normalizeBrowserUrl(patch.url ?? existing.url), - }; - - if ( - nextRecord.url === existing.url && - nextRecord.title === existing.title && - nextRecord.isLoading === existing.isLoading && - nextRecord.canGoBack === existing.canGoBack && - nextRecord.canGoForward === existing.canGoForward && - nextRecord.faviconUrl === existing.faviconUrl && - nextRecord.lastError === existing.lastError - ) { - return state; - } - - return { - browsersById: { - ...state.browsersById, - [normalizedBrowserId]: nextRecord, - }, - }; - }); - }, - removeBrowser: (browserId) => { - const normalizedBrowserId = trimNonEmpty(browserId); - if (!normalizedBrowserId) { - return; - } - - set((state) => { - if (!state.browsersById[normalizedBrowserId]) { - return state; - } - const next = { ...state.browsersById }; - delete next[normalizedBrowserId]; - return { browsersById: next }; - }); - }, - }), - { - name: "workspace-browser-store", - storage: createJSONStorage(() => AsyncStorage), - partialize: (state) => ({ - browsersById: Object.fromEntries( - Object.entries(state.browsersById).map(([browserId, browser]) => [ - browserId, - { ...browser, isLoading: false, lastError: null }, - ]), - ), - }), - }, - ), -); - -export function getBrowserRecord(browserId: string): BrowserRecord | null { - const normalizedBrowserId = trimNonEmpty(browserId); - if (!normalizedBrowserId) { - return null; - } - return useBrowserStore.getState().browsersById[normalizedBrowserId] ?? null; -} - -export function createWorkspaceBrowser(input?: { initialUrl?: string }): { - browserId: string; - url: string; -} { - const browserId = useBrowserStore.getState().createBrowser(input); - const record = getBrowserRecord(browserId); - return { - browserId, - url: record?.url ?? normalizeBrowserUrl(input?.initialUrl), - }; -} - -export function normalizeWorkspaceBrowserUrl(value: string | null | undefined): string { - return normalizeBrowserUrl(value); -} diff --git a/packages/app/src/stores/browser-store/index.ts b/packages/app/src/stores/browser-store/index.ts new file mode 100644 index 000000000..c16a6f4a0 --- /dev/null +++ b/packages/app/src/stores/browser-store/index.ts @@ -0,0 +1,89 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; +import { + applyBrowserPatch, + type BrowserIndexState, + type BrowserRecord, + type BrowserRecordPatch, + createBrowserRecord, + normalizeBrowserUrl, + removeBrowserFromIndex, + sanitizeBrowsersForPersist, + trimNonEmpty, +} from "./state"; + +export type { BrowserRecord } from "./state"; + +interface BrowserStoreState extends BrowserIndexState { + createBrowser: (input?: { initialUrl?: string }) => string; + updateBrowser: (browserId: string, patch: BrowserRecordPatch) => void; + removeBrowser: (browserId: string) => void; +} + +function createBrowserId(): string { + if (typeof globalThis.crypto?.randomUUID === "function") { + return globalThis.crypto.randomUUID(); + } + return `${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +export const useBrowserStore = create()( + persist( + (set) => ({ + browsersById: {}, + createBrowser: (input) => { + const browserId = createBrowserId(); + const record = createBrowserRecord({ + browserId, + initialUrl: input?.initialUrl, + now: Date.now(), + }); + + set((state) => ({ + browsersById: { + ...state.browsersById, + [browserId]: record, + }, + })); + + return browserId; + }, + updateBrowser: (browserId, patch) => { + set((state) => applyBrowserPatch(state, browserId, patch)); + }, + removeBrowser: (browserId) => { + set((state) => removeBrowserFromIndex(state, browserId)); + }, + }), + { + name: "workspace-browser-store", + storage: createJSONStorage(() => AsyncStorage), + partialize: (state) => sanitizeBrowsersForPersist(state), + }, + ), +); + +export function getBrowserRecord(browserId: string): BrowserRecord | null { + const normalizedBrowserId = trimNonEmpty(browserId); + if (!normalizedBrowserId) { + return null; + } + return useBrowserStore.getState().browsersById[normalizedBrowserId] ?? null; +} + +export function createWorkspaceBrowser(input?: { initialUrl?: string }): { + browserId: string; + url: string; +} { + const browserId = useBrowserStore.getState().createBrowser(input); + const record = getBrowserRecord(browserId); + return { + browserId, + url: record?.url ?? normalizeBrowserUrl(input?.initialUrl), + }; +} + +export function normalizeWorkspaceBrowserUrl(value: string | null | undefined): string { + return normalizeBrowserUrl(value); +} diff --git a/packages/app/src/stores/browser-store/state.test.ts b/packages/app/src/stores/browser-store/state.test.ts new file mode 100644 index 000000000..02a258796 --- /dev/null +++ b/packages/app/src/stores/browser-store/state.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from "vitest"; +import { + applyBrowserPatch, + type BrowserIndexState, + createBrowserRecord, + normalizeBrowserUrl, + removeBrowserFromIndex, + sanitizeBrowsersForPersist, +} from "./state"; + +function withRecords(records: ReturnType[]): BrowserIndexState { + return { + browsersById: Object.fromEntries(records.map((record) => [record.browserId, record])), + }; +} + +describe("normalizeBrowserUrl", () => { + it("normalizes local development hosts to http by default", () => { + expect(normalizeBrowserUrl("localhost:8081")).toBe("http://localhost:8081"); + expect(normalizeBrowserUrl("localhost/path")).toBe("http://localhost/path"); + expect(normalizeBrowserUrl("127.0.0.1:3000/path")).toBe("http://127.0.0.1:3000/path"); + expect(normalizeBrowserUrl("192.168.0.8")).toBe("http://192.168.0.8"); + expect(normalizeBrowserUrl("[::1]:5173")).toBe("http://[::1]:5173"); + }); + + it("normalizes public hosts to https by default", () => { + expect(normalizeBrowserUrl("example.com")).toBe("https://example.com"); + expect(normalizeBrowserUrl("//example.com/path")).toBe("https://example.com/path"); + }); + + it("keeps explicit protocols unchanged", () => { + expect(normalizeBrowserUrl("http://localhost:8081")).toBe("http://localhost:8081"); + expect(normalizeBrowserUrl("https://localhost:8081")).toBe("https://localhost:8081"); + expect(normalizeBrowserUrl("file:///tmp/example.html")).toBe("file:///tmp/example.html"); + }); + + it("falls back to a default URL when input is blank", () => { + expect(normalizeBrowserUrl(null)).toBe("https://example.com"); + expect(normalizeBrowserUrl(" ")).toBe("https://example.com"); + }); +}); + +describe("createBrowserRecord", () => { + it("normalizes the initial URL and starts with idle state", () => { + const record = createBrowserRecord({ + browserId: "b1", + initialUrl: "localhost:8081", + now: 1000, + }); + + expect(record).toEqual({ + browserId: "b1", + url: "http://localhost:8081", + title: "", + isLoading: false, + canGoBack: false, + canGoForward: false, + faviconUrl: null, + lastError: null, + createdAt: 1000, + }); + }); +}); + +describe("applyBrowserPatch", () => { + it("normalizes URL updates", () => { + const initial = withRecords([ + createBrowserRecord({ browserId: "b1", initialUrl: "https://a.test", now: 0 }), + ]); + + const next = applyBrowserPatch(initial, "b1", { url: "example.com/path" }); + + expect(next.browsersById.b1?.url).toBe("https://example.com/path"); + }); + + it("returns the same state reference when nothing changes", () => { + const initial = withRecords([ + createBrowserRecord({ browserId: "b1", initialUrl: "https://a.test", now: 0 }), + ]); + + const next = applyBrowserPatch(initial, "b1", { url: "https://a.test", title: "" }); + + expect(next).toBe(initial); + }); + + it("returns the same state when the browser id is unknown", () => { + const initial = withRecords([ + createBrowserRecord({ browserId: "b1", initialUrl: "https://a.test", now: 0 }), + ]); + + const next = applyBrowserPatch(initial, "missing", { title: "x" }); + + expect(next).toBe(initial); + }); + + it("returns the same state when the browser id is blank", () => { + const initial = withRecords([ + createBrowserRecord({ browserId: "b1", initialUrl: "https://a.test", now: 0 }), + ]); + + expect(applyBrowserPatch(initial, " ", { title: "x" })).toBe(initial); + }); +}); + +describe("removeBrowserFromIndex", () => { + it("removes the named browser", () => { + const initial = withRecords([ + createBrowserRecord({ browserId: "b1", initialUrl: "https://a.test", now: 0 }), + createBrowserRecord({ browserId: "b2", initialUrl: "https://b.test", now: 0 }), + ]); + + const next = removeBrowserFromIndex(initial, "b1"); + + expect(Object.keys(next.browsersById)).toEqual(["b2"]); + }); + + it("returns the same state when the browser id is unknown", () => { + const initial = withRecords([ + createBrowserRecord({ browserId: "b1", initialUrl: "https://a.test", now: 0 }), + ]); + + expect(removeBrowserFromIndex(initial, "missing")).toBe(initial); + }); + + it("returns the same state when the browser id is blank", () => { + const initial = withRecords([ + createBrowserRecord({ browserId: "b1", initialUrl: "https://a.test", now: 0 }), + ]); + + expect(removeBrowserFromIndex(initial, " ")).toBe(initial); + }); +}); + +describe("sanitizeBrowsersForPersist", () => { + it("clears transient fields on every record", () => { + const base = createBrowserRecord({ browserId: "b1", initialUrl: "https://a.test", now: 0 }); + const state: BrowserIndexState = { + browsersById: { + b1: { ...base, isLoading: true, lastError: "network down" }, + }, + }; + + const persisted = sanitizeBrowsersForPersist(state); + + expect(persisted.browsersById.b1?.isLoading).toBe(false); + expect(persisted.browsersById.b1?.lastError).toBe(null); + }); +}); diff --git a/packages/app/src/stores/browser-store/state.ts b/packages/app/src/stores/browser-store/state.ts new file mode 100644 index 000000000..1c4125e17 --- /dev/null +++ b/packages/app/src/stores/browser-store/state.ts @@ -0,0 +1,130 @@ +export interface BrowserRecord { + browserId: string; + url: string; + title: string; + isLoading: boolean; + canGoBack: boolean; + canGoForward: boolean; + faviconUrl: string | null; + lastError: string | null; + createdAt: number; +} + +export type BrowserRecordPatch = Partial>; + +export interface BrowserIndexState { + browsersById: Record; +} + +export function trimNonEmpty(value: string | null | undefined): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +export function normalizeBrowserUrl(value: string | null | undefined): string { + const trimmed = trimNonEmpty(value); + if (!trimmed) { + return "https://example.com"; + } + if (/^(localhost|\d{1,3}(?:\.\d{1,3}){3}|\[[\da-fA-F:.]+])(?::\d+)?(?:[/?#]|$)/.test(trimmed)) { + return `http://${trimmed}`; + } + if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(trimmed)) { + return trimmed; + } + if (trimmed.startsWith("//")) { + return `https:${trimmed}`; + } + return `https://${trimmed}`; +} + +export function createBrowserRecord(input: { + browserId: string; + initialUrl: string | null | undefined; + now: number; +}): BrowserRecord { + return { + browserId: input.browserId, + url: normalizeBrowserUrl(input.initialUrl), + title: "", + isLoading: false, + canGoBack: false, + canGoForward: false, + faviconUrl: null, + lastError: null, + createdAt: input.now, + }; +} + +export function applyBrowserPatch( + state: S, + browserId: string, + patch: BrowserRecordPatch, +): S { + const normalizedBrowserId = trimNonEmpty(browserId); + if (!normalizedBrowserId) { + return state; + } + const existing = state.browsersById[normalizedBrowserId]; + if (!existing) { + return state; + } + + const nextRecord: BrowserRecord = { + ...existing, + ...patch, + url: normalizeBrowserUrl(patch.url ?? existing.url), + }; + + if ( + nextRecord.url === existing.url && + nextRecord.title === existing.title && + nextRecord.isLoading === existing.isLoading && + nextRecord.canGoBack === existing.canGoBack && + nextRecord.canGoForward === existing.canGoForward && + nextRecord.faviconUrl === existing.faviconUrl && + nextRecord.lastError === existing.lastError + ) { + return state; + } + + return { + ...state, + browsersById: { + ...state.browsersById, + [normalizedBrowserId]: nextRecord, + }, + }; +} + +export function removeBrowserFromIndex( + state: S, + browserId: string, +): S { + const normalizedBrowserId = trimNonEmpty(browserId); + if (!normalizedBrowserId) { + return state; + } + if (!state.browsersById[normalizedBrowserId]) { + return state; + } + const next = { ...state.browsersById }; + delete next[normalizedBrowserId]; + return { ...state, browsersById: next }; +} + +export function sanitizeBrowsersForPersist(state: BrowserIndexState): { + browsersById: Record; +} { + return { + browsersById: Object.fromEntries( + Object.entries(state.browsersById).map(([browserId, browser]) => [ + browserId, + { ...browser, isLoading: false, lastError: null }, + ]), + ), + }; +} diff --git a/packages/app/src/stores/draft-store.ts b/packages/app/src/stores/draft-store/index.ts similarity index 52% rename from packages/app/src/stores/draft-store.ts rename to packages/app/src/stores/draft-store/index.ts index f56941e6d..6199772ac 100644 --- a/packages/app/src/stores/draft-store.ts +++ b/packages/app/src/stores/draft-store/index.ts @@ -1,8 +1,7 @@ import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import AsyncStorage from "@react-native-async-storage/async-storage"; -import type { AttachmentMetadata, UserComposerAttachment } from "@/attachments/types"; -import { GitHubSearchItemSchema } from "@getpaseo/protocol/messages"; +import type { AttachmentMetadata } from "@/attachments/types"; import { garbageCollectAttachments, persistAttachmentFromDataUrl, @@ -10,37 +9,24 @@ import { } from "@/attachments/service"; import { useCreateFlowStore } from "@/stores/create-flow-store"; import { useSessionStore, type SessionState } from "@/stores/session-store"; +import { + applyClearDraftRecord, + collectReferencedAttachmentIdsFromState, + DRAFT_STORE_VERSION, + isAttachmentMetadata, + isCanonicalDraftInput, + isLegacyDraftImage, + normalizeComposerAttachment, + pruneFinalizedDraftRecords, + toDraftInputIfReady, + type DraftInput, + type DraftLifecycleState, + type DraftRecord, + type DraftStoreState, +} from "./state"; +import { migrateDraftInput, migratePersistedState, type MigrateLegacyImages } from "./migration"; -const DRAFT_STORE_VERSION = 4; -const FINALIZED_DRAFT_TTL_MS = 5 * 60 * 1000; - -interface LegacyDraftImage { - uri: string; - mimeType?: string; -} - -type PersistedDraftImage = AttachmentMetadata | LegacyDraftImage; - -export interface DraftInput { - text: string; - attachments: UserComposerAttachment[]; -} - -export type DraftLifecycleState = "active" | "abandoned" | "sent"; - -type CanonicalDraftInput = DraftInput; - -interface DraftRecord { - input: CanonicalDraftInput; - lifecycle: DraftLifecycleState; - updatedAt: number; - version: number; -} - -interface DraftStoreState { - drafts: Record; - createModalDraft: DraftRecord | null; -} +export type { DraftInput, DraftLifecycleState } from "./state"; interface DraftStoreActions { getDraftInput: (draftKey: string) => DraftInput | undefined; @@ -79,189 +65,44 @@ function createDraftRecord(input: { }; } -function isAttachmentMetadata(value: unknown): value is AttachmentMetadata { - if (!value || typeof value !== "object") { - return false; +const migrateLegacyImages: MigrateLegacyImages = async (images) => { + if (images.length === 0) { + return []; } - const record = value as Record; - return ( - typeof record.id === "string" && - typeof record.mimeType === "string" && - typeof record.storageType === "string" && - typeof record.storageKey === "string" && - typeof record.createdAt === "number" - ); -} -function isLegacyDraftImage(value: unknown): value is LegacyDraftImage { - if (!value || typeof value !== "object") { - return false; - } - const record = value as Record; - return typeof record.uri === "string"; -} - -function normalizeAttachmentMetadata(image: AttachmentMetadata): AttachmentMetadata { - return { - id: image.id, - mimeType: image.mimeType, - storageType: image.storageType, - storageKey: image.storageKey, - createdAt: image.createdAt, - ...(typeof image.fileName === "string" || image.fileName === null - ? { fileName: image.fileName } - : {}), - ...(typeof image.byteSize === "number" || image.byteSize === null - ? { byteSize: image.byteSize } - : {}), - }; -} - -function normalizePersistedImage(value: unknown): PersistedDraftImage | null { - if (isAttachmentMetadata(value)) { - return normalizeAttachmentMetadata(value); - } - if (isLegacyDraftImage(value)) { - return { - uri: value.uri, - ...(value.mimeType ? { mimeType: value.mimeType } : {}), - }; - } - return null; -} - -function isUserComposerAttachment(value: unknown): value is UserComposerAttachment { - if (!value || typeof value !== "object") { - return false; - } - const record = value as Record; - if (record.kind === "image") { - const metadata = record.metadata; - return isAttachmentMetadata(metadata); - } - if (record.kind !== "github_issue" && record.kind !== "github_pr") { - return false; - } - return GitHubSearchItemSchema.safeParse(record.item).success; -} - -function normalizeComposerAttachment(attachment: UserComposerAttachment): UserComposerAttachment { - if (attachment.kind === "image") { - return { - kind: "image", - metadata: normalizeAttachmentMetadata(attachment.metadata), - }; - } - return attachment; -} - -function normalizePersistedComposerAttachment(value: unknown): UserComposerAttachment | null { - if (!isUserComposerAttachment(value)) { - return null; - } - return normalizeComposerAttachment(value); -} - -function legacyImagesToAttachments( - images: readonly AttachmentMetadata[], -): UserComposerAttachment[] { - return images.map((metadata) => ({ - kind: "image", - metadata, - })); -} - -function isCanonicalDraftInput(value: unknown): value is CanonicalDraftInput { - if (!value || typeof value !== "object") { - return false; - } - const input = value as Record; - // COMPAT(draft-cwd): accept legacy persisted drafts that include cwd. Stop accepting after 2026-11-09. - return ( - typeof input.text === "string" && - Array.isArray(input.attachments) && - input.attachments.every(isUserComposerAttachment) - ); -} - -function toDraftInputIfReady(record: DraftRecord | null | undefined): DraftInput | undefined { - if (!record) { - return undefined; - } - if (record.lifecycle !== "active") { - return undefined; - } - if (!isCanonicalDraftInput(record.input)) { - return undefined; - } - return { - text: record.input.text, - attachments: record.input.attachments.map(normalizeComposerAttachment), - }; -} - -function collectReferencedAttachmentIdsFromState(state: DraftStoreState): Set { - const referencedIds = new Set(); - - for (const draftRecord of Object.values(state.drafts)) { - if (draftRecord.lifecycle !== "active") { - continue; - } - if (!isCanonicalDraftInput(draftRecord.input)) { - continue; - } - for (const attachment of draftRecord.input.attachments) { - if (attachment.kind === "image") { - referencedIds.add(attachment.metadata.id); + const migrated = await Promise.all( + images.map(async (entry) => { + if (isAttachmentMetadata(entry)) { + return entry; } - } - } - - const modalRecord = state.createModalDraft; - if (modalRecord?.lifecycle === "active" && isCanonicalDraftInput(modalRecord.input)) { - for (const attachment of modalRecord.input.attachments) { - if (attachment.kind === "image") { - referencedIds.add(attachment.metadata.id); + if (!isLegacyDraftImage(entry)) { + return null; } - } - } - return referencedIds; -} + try { + if (entry.uri.startsWith("data:")) { + return await persistAttachmentFromDataUrl({ + dataUrl: entry.uri, + mimeType: entry.mimeType, + }); + } -function pruneFinalizedDraftRecords(input: { - drafts: Record; - nowMs: number; -}): Record { - let changed = false; - const next: Record = {}; - for (const [draftKey, record] of Object.entries(input.drafts)) { - if (record.lifecycle !== "active" && input.nowMs - record.updatedAt >= FINALIZED_DRAFT_TTL_MS) { - changed = true; - continue; - } - next[draftKey] = record; - } - return changed ? next : input.drafts; -} + return await persistAttachmentFromFileUri({ + uri: entry.uri, + mimeType: entry.mimeType, + }); + } catch (error) { + console.warn("[DraftStore] Failed to migrate legacy draft attachment", { + uri: entry.uri, + error, + }); + return null; + } + }), + ); -function applyClearDraftRecord(input: { - record: DraftRecord; - lifecycle?: Exclude; - nowMs: number; -}): DraftRecord | null { - if (!input.lifecycle) { - return null; - } - - return { - ...input.record, - input: { text: "", attachments: [] }, - lifecycle: input.lifecycle, - updatedAt: input.nowMs, - version: input.record.version + 1, - }; -} + return migrated.filter((entry): entry is AttachmentMetadata => entry !== null); +}; async function runAttachmentGc(): Promise { gcScheduled = false; @@ -370,120 +211,6 @@ async function migrateAllLegacyDrafts(): Promise { } } -async function migrateLegacyImages( - images: readonly PersistedDraftImage[], -): Promise { - if (images.length === 0) { - return []; - } - - const migrated = await Promise.all( - images.map(async (entry) => { - if (isAttachmentMetadata(entry)) { - return entry; - } - if (!isLegacyDraftImage(entry)) { - return null; - } - - try { - if (entry.uri.startsWith("data:")) { - return await persistAttachmentFromDataUrl({ - dataUrl: entry.uri, - mimeType: entry.mimeType, - }); - } - - return await persistAttachmentFromFileUri({ - uri: entry.uri, - mimeType: entry.mimeType, - }); - } catch (error) { - console.warn("[DraftStore] Failed to migrate legacy draft attachment", { - uri: entry.uri, - error, - }); - return null; - } - }), - ); - - return migrated.filter((entry): entry is AttachmentMetadata => entry !== null); -} - -async function migrateDraftInput(input: { rawInput: unknown }): Promise { - const rawInput = - input.rawInput && typeof input.rawInput === "object" - ? (input.rawInput as Record) - : {}; - const attachments = Array.isArray(rawInput.attachments) - ? rawInput.attachments - .map((entry) => normalizePersistedComposerAttachment(entry)) - .filter((entry): entry is UserComposerAttachment => entry !== null) - : []; - const legacyImages = Array.isArray(rawInput.images) - ? rawInput.images - .map((entry) => normalizePersistedImage(entry)) - .filter((entry): entry is PersistedDraftImage => entry !== null) - : []; - const migratedImages = await migrateLegacyImages(legacyImages); - - return { - text: typeof rawInput.text === "string" ? rawInput.text : "", - attachments: [...attachments, ...legacyImagesToAttachments(migratedImages)], - }; -} - -function resolvePersistedLifecycle(lifecycle: unknown): DraftLifecycleState { - if (lifecycle === "sent" || lifecycle === "abandoned") { - return lifecycle as DraftLifecycleState; - } - return "active"; -} - -function extractRawInput(record: Record): unknown { - if ("input" in record && record.input && typeof record.input === "object") { - return record.input; - } - return record; -} - -async function buildMigratedDraftRecord(parsed: Record): Promise { - return { - input: await migrateDraftInput({ rawInput: extractRawInput(parsed) }), - lifecycle: resolvePersistedLifecycle(parsed.lifecycle), - updatedAt: typeof parsed.updatedAt === "number" ? parsed.updatedAt : Date.now(), - version: typeof parsed.version === "number" ? parsed.version : 1, - }; -} - -async function migratePersistedState(state: unknown): Promise { - const input = (state ?? {}) as { - drafts?: Record; - createModalDraft?: unknown; - }; - - const nextDrafts: Record = {}; - for (const [draftKey, rawRecord] of Object.entries(input.drafts ?? {})) { - if (!rawRecord || typeof rawRecord !== "object") { - continue; - } - nextDrafts[draftKey] = await buildMigratedDraftRecord(rawRecord as Record); - } - - let createModalDraft: DraftRecord | null = null; - if (input.createModalDraft && typeof input.createModalDraft === "object") { - createModalDraft = await buildMigratedDraftRecord( - input.createModalDraft as Record, - ); - } - - return { - drafts: nextDrafts, - createModalDraft, - }; -} - export const useDraftStore = create()( persist( (set, get) => ({ @@ -508,9 +235,10 @@ export const useDraftStore = create()( return ready; } - const migratedDraft = await migrateDraftInput({ - rawInput: current.input, - }); + const migratedDraft = await migrateDraftInput( + { rawInput: current.input }, + { migrateLegacyImages }, + ); set((state) => { const existing = state.drafts[draftKey]; @@ -639,7 +367,10 @@ export const useDraftStore = create()( version: DRAFT_STORE_VERSION, storage: createJSONStorage(() => AsyncStorage), migrate: (persistedState) => { - return migratePersistedState(persistedState); + return migratePersistedState(persistedState, { + migrateLegacyImages, + nowMs: Date.now(), + }); }, onRehydrateStorage: () => { return () => { @@ -650,11 +381,3 @@ export const useDraftStore = create()( }, ), ); - -export const __draftStoreTestUtils = { - migrateDraftInput, - migratePersistedState, - normalizeAttachmentMetadata, - pruneFinalizedDraftRecords, - applyClearDraftRecord, -}; diff --git a/packages/app/src/stores/draft-store.migration.test.ts b/packages/app/src/stores/draft-store/migration.test.ts similarity index 63% rename from packages/app/src/stores/draft-store.migration.test.ts rename to packages/app/src/stores/draft-store/migration.test.ts index b445ede65..0ddee1fe0 100644 --- a/packages/app/src/stores/draft-store.migration.test.ts +++ b/packages/app/src/stores/draft-store/migration.test.ts @@ -1,7 +1,10 @@ -import "@/test/window-local-storage"; -import { beforeEach, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import type { ComposerAttachment } from "@/attachments/types"; -import { __draftStoreTestUtils, useDraftStore } from "./draft-store"; +import { migratePersistedState, type MigrateLegacyImages } from "./migration"; +import { isAttachmentMetadata } from "./state"; + +const passThroughMigrateLegacyImages: MigrateLegacyImages = async (images) => + images.filter(isAttachmentMetadata); function workspaceReviewAttachment(): Extract { return { @@ -44,34 +47,33 @@ function workspaceReviewAttachment(): Extract { - beforeEach(() => { - useDraftStore.setState({ drafts: {}, createModalDraft: null }); - }); - it("normalizes legacy image metadata into image attachments and strips persisted preview URLs", async () => { - const migrated = await __draftStoreTestUtils.migratePersistedState({ - drafts: { - "agent:server:agent": { - input: { - text: "hello", - images: [ - { - id: "att-1", - mimeType: "image/png", - storageType: "desktop-file", - storageKey: "/tmp/att-1.png", - createdAt: 1700000000000, - previewUri: "asset://should-not-persist", - }, - ], + const migrated = await migratePersistedState( + { + drafts: { + "agent:server:agent": { + input: { + text: "hello", + images: [ + { + id: "att-1", + mimeType: "image/png", + storageType: "desktop-file", + storageKey: "/tmp/att-1.png", + createdAt: 1700000000000, + previewUri: "asset://should-not-persist", + }, + ], + }, + lifecycle: "active", + updatedAt: 1700000000001, + version: 1, }, - lifecycle: "active", - updatedAt: 1700000000001, - version: 1, }, + createModalDraft: null, }, - createModalDraft: null, - }); + { migrateLegacyImages: passThroughMigrateLegacyImages, nowMs: 1700000000002 }, + ); expect(migrated.drafts["agent:server:agent"]?.input).toEqual({ text: "hello", @@ -118,8 +120,9 @@ describe("draft-store migration", () => { createModalDraft: null, }; - const once = await __draftStoreTestUtils.migratePersistedState(original); - const twice = await __draftStoreTestUtils.migratePersistedState(once); + const ports = { migrateLegacyImages: passThroughMigrateLegacyImages, nowMs: 1700000000002 }; + const once = await migratePersistedState(original, ports); + const twice = await migratePersistedState(once, ports); expect(twice).toEqual(once); expect(twice.drafts["agent:server:agent"]?.input).toEqual({ @@ -140,20 +143,23 @@ describe("draft-store migration", () => { }); it("rejects workspace review attachments from migrated draft attachments", async () => { - const migrated = await __draftStoreTestUtils.migratePersistedState({ - drafts: { - "agent:server:agent": { - input: { - text: "hello", - attachments: [workspaceReviewAttachment()], + const migrated = await migratePersistedState( + { + drafts: { + "agent:server:agent": { + input: { + text: "hello", + attachments: [workspaceReviewAttachment()], + }, + lifecycle: "active", + updatedAt: 1700000000001, + version: 2, }, - lifecycle: "active", - updatedAt: 1700000000001, - version: 2, }, + createModalDraft: null, }, - createModalDraft: null, - }); + { migrateLegacyImages: passThroughMigrateLegacyImages, nowMs: 1700000000002 }, + ); expect(migrated.drafts["agent:server:agent"]?.input.attachments).toEqual([]); }); diff --git a/packages/app/src/stores/draft-store/migration.ts b/packages/app/src/stores/draft-store/migration.ts new file mode 100644 index 000000000..015c1ca82 --- /dev/null +++ b/packages/app/src/stores/draft-store/migration.ts @@ -0,0 +1,135 @@ +import type { AttachmentMetadata, UserComposerAttachment } from "@/attachments/types"; +import { + isAttachmentMetadata, + isLegacyDraftImage, + isUserComposerAttachment, + normalizeAttachmentMetadata, + normalizeComposerAttachment, + type CanonicalDraftInput, + type DraftLifecycleState, + type DraftRecord, + type DraftStoreState, + type PersistedDraftImage, +} from "./state"; + +export type MigrateLegacyImages = ( + images: readonly PersistedDraftImage[], +) => Promise; + +function normalizePersistedImage(value: unknown): PersistedDraftImage | null { + if (isAttachmentMetadata(value)) { + return normalizeAttachmentMetadata(value); + } + if (isLegacyDraftImage(value)) { + return { + uri: value.uri, + ...(value.mimeType ? { mimeType: value.mimeType } : {}), + }; + } + return null; +} + +function normalizePersistedComposerAttachment(value: unknown): UserComposerAttachment | null { + if (!isUserComposerAttachment(value)) { + return null; + } + return normalizeComposerAttachment(value); +} + +function legacyImagesToAttachments( + images: readonly AttachmentMetadata[], +): UserComposerAttachment[] { + return images.map((metadata) => ({ + kind: "image", + metadata, + })); +} + +export async function migrateDraftInput( + input: { rawInput: unknown }, + ports: { migrateLegacyImages: MigrateLegacyImages }, +): Promise { + const rawInput = + input.rawInput && typeof input.rawInput === "object" + ? (input.rawInput as Record) + : {}; + const attachments = Array.isArray(rawInput.attachments) + ? rawInput.attachments + .map((entry) => normalizePersistedComposerAttachment(entry)) + .filter((entry): entry is UserComposerAttachment => entry !== null) + : []; + const legacyImages = Array.isArray(rawInput.images) + ? rawInput.images + .map((entry) => normalizePersistedImage(entry)) + .filter((entry): entry is PersistedDraftImage => entry !== null) + : []; + const migratedImages = await ports.migrateLegacyImages(legacyImages); + + return { + text: typeof rawInput.text === "string" ? rawInput.text : "", + attachments: [...attachments, ...legacyImagesToAttachments(migratedImages)], + }; +} + +function resolvePersistedLifecycle(lifecycle: unknown): DraftLifecycleState { + if (lifecycle === "sent" || lifecycle === "abandoned") { + return lifecycle as DraftLifecycleState; + } + return "active"; +} + +function extractRawInput(record: Record): unknown { + if ("input" in record && record.input && typeof record.input === "object") { + return record.input; + } + return record; +} + +async function buildMigratedDraftRecord( + parsed: Record, + ports: { migrateLegacyImages: MigrateLegacyImages }, + nowMs: number, +): Promise { + return { + input: await migrateDraftInput({ rawInput: extractRawInput(parsed) }, ports), + lifecycle: resolvePersistedLifecycle(parsed.lifecycle), + updatedAt: typeof parsed.updatedAt === "number" ? parsed.updatedAt : nowMs, + version: typeof parsed.version === "number" ? parsed.version : 1, + }; +} + +export async function migratePersistedState( + state: unknown, + ports: { migrateLegacyImages: MigrateLegacyImages; nowMs: number }, +): Promise { + const input = (state ?? {}) as { + drafts?: Record; + createModalDraft?: unknown; + }; + + const nextDrafts: Record = {}; + for (const [draftKey, rawRecord] of Object.entries(input.drafts ?? {})) { + if (!rawRecord || typeof rawRecord !== "object") { + continue; + } + nextDrafts[draftKey] = await buildMigratedDraftRecord( + rawRecord as Record, + ports, + ports.nowMs, + ); + } + + let createModalDraft: DraftRecord | null = null; + if (input.createModalDraft && typeof input.createModalDraft === "object") { + createModalDraft = await buildMigratedDraftRecord( + input.createModalDraft as Record, + ports, + ports.nowMs, + ); + } + + return { + drafts: nextDrafts, + createModalDraft, + }; +} diff --git a/packages/app/src/stores/draft-store.lifecycle.test.ts b/packages/app/src/stores/draft-store/state.test.ts similarity index 87% rename from packages/app/src/stores/draft-store.lifecycle.test.ts rename to packages/app/src/stores/draft-store/state.test.ts index f79fdf13e..996292d13 100644 --- a/packages/app/src/stores/draft-store.lifecycle.test.ts +++ b/packages/app/src/stores/draft-store/state.test.ts @@ -1,6 +1,5 @@ -import "@/test/window-local-storage"; import { describe, expect, it } from "vitest"; -import { __draftStoreTestUtils } from "./draft-store"; +import { applyClearDraftRecord, pruneFinalizedDraftRecords } from "./state"; describe("draft-store lifecycle", () => { it("prunes finalized tombstones after TTL", () => { @@ -26,7 +25,7 @@ describe("draft-store lifecycle", () => { }, }; - const pruned = __draftStoreTestUtils.pruneFinalizedDraftRecords({ + const pruned = pruneFinalizedDraftRecords({ drafts, nowMs: nowMs + 6 * 60 * 1000, }); @@ -37,7 +36,7 @@ describe("draft-store lifecycle", () => { }); it("normalizes clear-with-lifecycle into a tombstone without attachments", () => { - const cleared = __draftStoreTestUtils.applyClearDraftRecord({ + const cleared = applyClearDraftRecord({ record: { input: { text: "hello", diff --git a/packages/app/src/stores/draft-store/state.ts b/packages/app/src/stores/draft-store/state.ts new file mode 100644 index 000000000..647fc59eb --- /dev/null +++ b/packages/app/src/stores/draft-store/state.ts @@ -0,0 +1,192 @@ +import type { AttachmentMetadata, UserComposerAttachment } from "@/attachments/types"; +import { GitHubSearchItemSchema } from "@getpaseo/protocol/messages"; + +export const DRAFT_STORE_VERSION = 4; +export const FINALIZED_DRAFT_TTL_MS = 5 * 60 * 1000; + +export interface LegacyDraftImage { + uri: string; + mimeType?: string; +} + +export type PersistedDraftImage = AttachmentMetadata | LegacyDraftImage; + +export interface DraftInput { + text: string; + attachments: UserComposerAttachment[]; +} + +export type DraftLifecycleState = "active" | "abandoned" | "sent"; + +export type CanonicalDraftInput = DraftInput; + +export interface DraftRecord { + input: CanonicalDraftInput; + lifecycle: DraftLifecycleState; + updatedAt: number; + version: number; +} + +export interface DraftStoreState { + drafts: Record; + createModalDraft: DraftRecord | null; +} + +export function isAttachmentMetadata(value: unknown): value is AttachmentMetadata { + if (!value || typeof value !== "object") { + return false; + } + const record = value as Record; + return ( + typeof record.id === "string" && + typeof record.mimeType === "string" && + typeof record.storageType === "string" && + typeof record.storageKey === "string" && + typeof record.createdAt === "number" + ); +} + +export function isLegacyDraftImage(value: unknown): value is LegacyDraftImage { + if (!value || typeof value !== "object") { + return false; + } + const record = value as Record; + return typeof record.uri === "string"; +} + +export function normalizeAttachmentMetadata(image: AttachmentMetadata): AttachmentMetadata { + return { + id: image.id, + mimeType: image.mimeType, + storageType: image.storageType, + storageKey: image.storageKey, + createdAt: image.createdAt, + ...(typeof image.fileName === "string" || image.fileName === null + ? { fileName: image.fileName } + : {}), + ...(typeof image.byteSize === "number" || image.byteSize === null + ? { byteSize: image.byteSize } + : {}), + }; +} + +export function isUserComposerAttachment(value: unknown): value is UserComposerAttachment { + if (!value || typeof value !== "object") { + return false; + } + const record = value as Record; + if (record.kind === "image") { + const metadata = record.metadata; + return isAttachmentMetadata(metadata); + } + if (record.kind !== "github_issue" && record.kind !== "github_pr") { + return false; + } + return GitHubSearchItemSchema.safeParse(record.item).success; +} + +export function normalizeComposerAttachment( + attachment: UserComposerAttachment, +): UserComposerAttachment { + if (attachment.kind === "image") { + return { + kind: "image", + metadata: normalizeAttachmentMetadata(attachment.metadata), + }; + } + return attachment; +} + +export function isCanonicalDraftInput(value: unknown): value is CanonicalDraftInput { + if (!value || typeof value !== "object") { + return false; + } + const input = value as Record; + // COMPAT(draft-cwd): accept legacy persisted drafts that include cwd. Stop accepting after 2026-11-09. + return ( + typeof input.text === "string" && + Array.isArray(input.attachments) && + input.attachments.every(isUserComposerAttachment) + ); +} + +export function toDraftInputIfReady( + record: DraftRecord | null | undefined, +): DraftInput | undefined { + if (!record) { + return undefined; + } + if (record.lifecycle !== "active") { + return undefined; + } + if (!isCanonicalDraftInput(record.input)) { + return undefined; + } + return { + text: record.input.text, + attachments: record.input.attachments.map(normalizeComposerAttachment), + }; +} + +export function collectReferencedAttachmentIdsFromState(state: DraftStoreState): Set { + const referencedIds = new Set(); + + for (const draftRecord of Object.values(state.drafts)) { + if (draftRecord.lifecycle !== "active") { + continue; + } + if (!isCanonicalDraftInput(draftRecord.input)) { + continue; + } + for (const attachment of draftRecord.input.attachments) { + if (attachment.kind === "image") { + referencedIds.add(attachment.metadata.id); + } + } + } + + const modalRecord = state.createModalDraft; + if (modalRecord?.lifecycle === "active" && isCanonicalDraftInput(modalRecord.input)) { + for (const attachment of modalRecord.input.attachments) { + if (attachment.kind === "image") { + referencedIds.add(attachment.metadata.id); + } + } + } + + return referencedIds; +} + +export function pruneFinalizedDraftRecords(input: { + drafts: Record; + nowMs: number; +}): Record { + let changed = false; + const next: Record = {}; + for (const [draftKey, record] of Object.entries(input.drafts)) { + if (record.lifecycle !== "active" && input.nowMs - record.updatedAt >= FINALIZED_DRAFT_TTL_MS) { + changed = true; + continue; + } + next[draftKey] = record; + } + return changed ? next : input.drafts; +} + +export function applyClearDraftRecord(input: { + record: DraftRecord; + lifecycle?: Exclude; + nowMs: number; +}): DraftRecord | null { + if (!input.lifecycle) { + return null; + } + + return { + ...input.record, + input: { text: "", attachments: [] }, + lifecycle: input.lifecycle, + updatedAt: input.nowMs, + version: input.record.version + 1, + }; +} diff --git a/packages/app/src/stores/navigation-active-workspace-store.test.ts b/packages/app/src/stores/navigation-active-workspace-store.test.ts deleted file mode 100644 index 3fbb93f7b..000000000 --- a/packages/app/src/stores/navigation-active-workspace-store.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import { renderHook } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const routerMock = vi.hoisted(() => ({ - dismissTo: vi.fn(), -})); -const pathnameState = vi.hoisted(() => ({ - value: "/", -})); -const localParamsState = vi.hoisted(() => ({ - value: {} as { serverId?: string | string[]; workspaceId?: string | string[] }, -})); -const asyncStorageMock = vi.hoisted(() => ({ - getItem: vi.fn(async () => null as string | null), - setItem: vi.fn(async () => undefined), -})); - -vi.mock("expo-router", () => ({ - router: routerMock, - useLocalSearchParams: () => localParamsState.value, - usePathname: () => pathnameState.value, -})); - -vi.mock("@react-native-async-storage/async-storage", () => ({ - default: asyncStorageMock, -})); - -import { - navigateToLastWorkspace, - navigateToWorkspace, - useActiveWorkspaceSelection, -} from "@/stores/navigation-active-workspace-store"; - -describe("workspace navigation", () => { - beforeEach(() => { - routerMock.dismissTo.mockReset(); - asyncStorageMock.setItem.mockClear(); - pathnameState.value = "/"; - localParamsState.value = {}; - }); - - it("reports when no last workspace is known", () => { - expect(navigateToLastWorkspace()).toBe(false); - }); - - it("navigates to a workspace route", () => { - navigateToWorkspace("server-1", "workspace-a"); - - expect(routerMock.dismissTo).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a"); - expect(asyncStorageMock.setItem).toHaveBeenCalledWith( - "paseo:last-workspace-route-selection", - JSON.stringify({ serverId: "server-1", workspaceId: "workspace-a" }), - ); - }); - - it("reads the active workspace from the current route", () => { - pathnameState.value = "/h/server-1/workspace/workspace-a"; - - const { result } = renderHook(() => useActiveWorkspaceSelection()); - - expect(result.current).toEqual({ - serverId: "server-1", - workspaceId: "workspace-a", - }); - }); - - it("falls back to workspace route params during cold route mount", () => { - localParamsState.value = { - serverId: "server-1", - workspaceId: "b64_L3RtcC9wYXNlby1taXNzaW5nLXdvcmtzcGFjZQ", - }; - - const { result } = renderHook(() => useActiveWorkspaceSelection()); - - expect(result.current).toEqual({ - serverId: "server-1", - workspaceId: "/tmp/paseo-missing-workspace", - }); - }); - - it("navigates to the last workspace observed by the route reader", () => { - pathnameState.value = "/h/server-1/workspace/workspace-a"; - renderHook(() => useActiveWorkspaceSelection()); - - expect(navigateToLastWorkspace()).toBe(true); - expect(routerMock.dismissTo).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a"); - }); -}); diff --git a/packages/app/src/stores/navigation-active-workspace-store.ts b/packages/app/src/stores/navigation-active-workspace-store/index.ts similarity index 53% rename from packages/app/src/stores/navigation-active-workspace-store.ts rename to packages/app/src/stores/navigation-active-workspace-store/index.ts index 698e1a148..ba07f70a7 100644 --- a/packages/app/src/stores/navigation-active-workspace-store.ts +++ b/packages/app/src/stores/navigation-active-workspace-store/index.ts @@ -6,18 +6,14 @@ import { type ActiveWorkspaceSelection, type LastWorkspaceSelectionStorage, } from "@/stores/last-workspace-selection"; +import { + navigateToLastWorkspace as navigateToLastWorkspacePure, + navigateToWorkspace as navigateToWorkspacePure, + parseActiveWorkspaceSelection, + type NavigateToWorkspaceDeps, +} from "./navigation"; import { useSessionStore } from "@/stores/session-store"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; -import { pickAttentionAgent } from "@/utils/agent-attention"; -import { - buildHostWorkspaceRoute, - decodeWorkspaceIdFromPathSegment, - parseHostWorkspaceRouteFromPathname, -} from "@/utils/host-routes"; -import { - resolveWorkspaceIdByExecutionDirectory, - resolveWorkspaceMapKeyByIdentity, -} from "@/utils/workspace-execution"; export type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection"; @@ -36,6 +32,19 @@ const lastWorkspaceSelectionStore = createLastWorkspaceSelectionStore( lastWorkspaceSelectionStorage, ); +function navigateDeps(): NavigateToWorkspaceDeps { + return { + getSessionWorkspaces: (serverId) => useSessionStore.getState().sessions[serverId]?.workspaces, + getSessionAgents: (serverId) => + useSessionStore.getState().sessions[serverId]?.agents.values() ?? [], + openWorkspaceAgentTab: (workspaceKey, agentId) => { + useWorkspaceLayoutStore.getState().openTabFocused(workspaceKey, { kind: "agent", agentId }); + }, + rememberLastWorkspace: (selection) => lastWorkspaceSelectionStore.remember(selection), + navigateToRoute: (route) => router.dismissTo(route as Href), + }; +} + export function hydrateLastWorkspaceSelection(): Promise { return lastWorkspaceSelectionStore.hydrate(); } @@ -48,69 +57,19 @@ export function getIsLastWorkspaceSelectionHydrated(): boolean { return lastWorkspaceSelectionStore.isHydrated(); } -function getParamValue(value: string | string[] | undefined): string { - if (typeof value === "string") { - return value.trim(); - } - if (Array.isArray(value)) { - const firstValue = value[0]; - return typeof firstValue === "string" ? firstValue.trim() : ""; - } - return ""; -} - -function parseWorkspaceSelectionFromRouteParams(params: { - serverId?: string | string[]; - workspaceId?: string | string[]; -}): ActiveWorkspaceSelection | null { - const serverId = getParamValue(params.serverId); - const workspaceValue = getParamValue(params.workspaceId); - const workspaceId = workspaceValue ? decodeWorkspaceIdFromPathSegment(workspaceValue) : null; - if (!serverId || !workspaceId) { - return null; - } - return { serverId, workspaceId }; -} - export function navigateToWorkspace( serverId: string, workspaceId: string, _options: NavigateToWorkspaceOptions = {}, ) { - const session = useSessionStore.getState().sessions[serverId]; - const resolvedWorkspaceId = resolveWorkspaceMapKeyByIdentity({ - workspaces: session?.workspaces, - workspaceId, - }); - const workspaceAgents = resolvedWorkspaceId - ? Array.from(session?.agents.values() ?? []).filter( - (agent) => - resolveWorkspaceIdByExecutionDirectory({ - workspaces: session?.workspaces?.values(), - workspaceDirectory: agent.cwd, - }) === resolvedWorkspaceId, - ) - : []; - const attentionAgentId = pickAttentionAgent(workspaceAgents); - if (attentionAgentId && resolvedWorkspaceId) { - useWorkspaceLayoutStore.getState().openTabFocused(`${serverId}:${resolvedWorkspaceId}`, { - kind: "agent", - agentId: attentionAgentId, - }); - } - - lastWorkspaceSelectionStore.remember({ serverId, workspaceId }); - const route = buildHostWorkspaceRoute(serverId, workspaceId) as Href; - router.dismissTo(route); + navigateToWorkspacePure(serverId, workspaceId, navigateDeps()); } export function navigateToLastWorkspace(): boolean { - const selection = lastWorkspaceSelectionStore.getSelection(); - if (!selection) { - return false; - } - navigateToWorkspace(selection.serverId, selection.workspaceId); - return true; + return navigateToLastWorkspacePure({ + ...navigateDeps(), + getLastWorkspaceSelection: () => lastWorkspaceSelectionStore.getSelection(), + }); } export function useActiveWorkspaceSelection(): ActiveWorkspaceSelection | null { @@ -118,9 +77,7 @@ export function useActiveWorkspaceSelection(): ActiveWorkspaceSelection | null { serverId?: string | string[]; workspaceId?: string | string[]; }>(); - const selection = - parseHostWorkspaceRouteFromPathname(usePathname()) ?? - parseWorkspaceSelectionFromRouteParams(params); + const selection = parseActiveWorkspaceSelection({ pathname: usePathname(), params }); const serverId = selection?.serverId ?? null; const workspaceId = selection?.workspaceId ?? null; useEffect(() => { diff --git a/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts b/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts new file mode 100644 index 000000000..a84379957 --- /dev/null +++ b/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; +import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection"; +import { + navigateToLastWorkspace, + navigateToWorkspace, + parseActiveWorkspaceSelection, + type NavigateToLastWorkspaceDeps, + type NavigateToWorkspaceDeps, +} from "./navigation"; +import type { Agent, WorkspaceDescriptor } from "@/stores/session-store"; + +interface RecordedAgentTab { + workspaceKey: string; + agentId: string; +} + +function createFakeDeps(overrides: Partial = {}) { + const navigations: string[] = []; + const remembered: ActiveWorkspaceSelection[] = []; + const openedAgentTabs: RecordedAgentTab[] = []; + const deps: NavigateToWorkspaceDeps = { + getSessionWorkspaces: () => null, + getSessionAgents: () => [] as Agent[], + openWorkspaceAgentTab: (workspaceKey, agentId) => + openedAgentTabs.push({ workspaceKey, agentId }), + rememberLastWorkspace: (selection) => remembered.push(selection), + navigateToRoute: (route) => navigations.push(route), + ...overrides, + }; + return { deps, navigations, remembered, openedAgentTabs }; +} + +function createLastSelectionDeps( + initial: ActiveWorkspaceSelection | null, + overrides: Partial = {}, +): { + deps: NavigateToLastWorkspaceDeps; + navigations: string[]; + remembered: ActiveWorkspaceSelection[]; +} { + let lastSelection = initial; + const base = createFakeDeps({ + rememberLastWorkspace: (selection) => { + lastSelection = selection; + base.remembered.push(selection); + }, + ...overrides, + }); + return { + deps: { ...base.deps, getLastWorkspaceSelection: () => lastSelection }, + navigations: base.navigations, + remembered: base.remembered, + }; +} + +describe("workspace navigation", () => { + it("reports when no last workspace is known", () => { + const { deps } = createLastSelectionDeps(null); + + expect(navigateToLastWorkspace(deps)).toBe(false); + }); + + it("navigates to a workspace route and remembers the selection", () => { + const { deps, navigations, remembered } = createFakeDeps(); + + navigateToWorkspace("server-1", "workspace-a", deps); + + expect(navigations).toEqual(["/h/server-1/workspace/workspace-a"]); + expect(remembered).toEqual([{ serverId: "server-1", workspaceId: "workspace-a" }]); + }); + + it("focuses the attention agent's tab when a workspace has one", () => { + const workspace = { + id: "workspace-a", + workspaceDirectory: "/repo/workspace-a", + } as WorkspaceDescriptor; + const agent = { + id: "agent-1", + cwd: "/repo/workspace-a", + requiresAttention: true, + attentionReason: "permission", + } as unknown as Agent; + const { deps, openedAgentTabs } = createFakeDeps({ + getSessionWorkspaces: () => new Map([[workspace.id, workspace]]), + getSessionAgents: () => [agent], + }); + + navigateToWorkspace("server-1", "workspace-a", deps); + + expect(openedAgentTabs).toEqual([{ workspaceKey: "server-1:workspace-a", agentId: "agent-1" }]); + }); + + it("reads the active workspace from the current route", () => { + const selection = parseActiveWorkspaceSelection({ + pathname: "/h/server-1/workspace/workspace-a", + params: {}, + }); + + expect(selection).toEqual({ serverId: "server-1", workspaceId: "workspace-a" }); + }); + + it("falls back to workspace route params during cold route mount", () => { + const selection = parseActiveWorkspaceSelection({ + pathname: "/", + params: { + serverId: "server-1", + workspaceId: "b64_L3RtcC9wYXNlby1taXNzaW5nLXdvcmtzcGFjZQ", + }, + }); + + expect(selection).toEqual({ + serverId: "server-1", + workspaceId: "/tmp/paseo-missing-workspace", + }); + }); + + it("navigates to the last workspace once a route observation has been remembered", () => { + const { deps, navigations } = createLastSelectionDeps(null); + + const observed = parseActiveWorkspaceSelection({ + pathname: "/h/server-1/workspace/workspace-a", + params: {}, + }); + expect(observed).not.toBeNull(); + if (observed) { + deps.rememberLastWorkspace(observed); + } + + expect(navigateToLastWorkspace(deps)).toBe(true); + expect(navigations).toEqual(["/h/server-1/workspace/workspace-a"]); + }); +}); diff --git a/packages/app/src/stores/navigation-active-workspace-store/navigation.ts b/packages/app/src/stores/navigation-active-workspace-store/navigation.ts new file mode 100644 index 000000000..e1dc11bf7 --- /dev/null +++ b/packages/app/src/stores/navigation-active-workspace-store/navigation.ts @@ -0,0 +1,102 @@ +import type { Agent, WorkspaceDescriptor } from "@/stores/session-store"; +import { pickAttentionAgent } from "@/utils/agent-attention"; +import { + buildHostWorkspaceRoute, + decodeWorkspaceIdFromPathSegment, + parseHostWorkspaceRouteFromPathname, +} from "@/utils/host-routes"; +import { + resolveWorkspaceIdByExecutionDirectory, + resolveWorkspaceMapKeyByIdentity, +} from "@/utils/workspace-execution"; +import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection"; + +export interface RouteSelectionInput { + pathname: string; + params: { + serverId?: string | string[]; + workspaceId?: string | string[]; + }; +} + +export interface NavigateToWorkspaceDeps { + getSessionWorkspaces: (serverId: string) => Map | null | undefined; + getSessionAgents: (serverId: string) => Iterable; + openWorkspaceAgentTab: (workspaceKey: string, agentId: string) => void; + rememberLastWorkspace: (selection: ActiveWorkspaceSelection) => void; + navigateToRoute: (route: string) => void; +} + +export interface NavigateToLastWorkspaceDeps extends NavigateToWorkspaceDeps { + getLastWorkspaceSelection: () => ActiveWorkspaceSelection | null; +} + +function getParamValue(value: string | string[] | undefined): string { + if (typeof value === "string") { + return value.trim(); + } + if (Array.isArray(value)) { + const firstValue = value[0]; + return typeof firstValue === "string" ? firstValue.trim() : ""; + } + return ""; +} + +function parseWorkspaceSelectionFromRouteParams(params: { + serverId?: string | string[]; + workspaceId?: string | string[]; +}): ActiveWorkspaceSelection | null { + const serverId = getParamValue(params.serverId); + const workspaceValue = getParamValue(params.workspaceId); + const workspaceId = workspaceValue ? decodeWorkspaceIdFromPathSegment(workspaceValue) : null; + if (!serverId || !workspaceId) { + return null; + } + return { serverId, workspaceId }; +} + +export function parseActiveWorkspaceSelection( + input: RouteSelectionInput, +): ActiveWorkspaceSelection | null { + return ( + parseHostWorkspaceRouteFromPathname(input.pathname) ?? + parseWorkspaceSelectionFromRouteParams(input.params) + ); +} + +export function navigateToWorkspace( + serverId: string, + workspaceId: string, + deps: NavigateToWorkspaceDeps, +): void { + const workspaces = deps.getSessionWorkspaces(serverId); + const resolvedWorkspaceId = resolveWorkspaceMapKeyByIdentity({ + workspaces, + workspaceId, + }); + const workspaceAgents = resolvedWorkspaceId + ? Array.from(deps.getSessionAgents(serverId)).filter( + (agent) => + resolveWorkspaceIdByExecutionDirectory({ + workspaces: workspaces?.values(), + workspaceDirectory: agent.cwd, + }) === resolvedWorkspaceId, + ) + : []; + const attentionAgentId = pickAttentionAgent(workspaceAgents); + if (attentionAgentId && resolvedWorkspaceId) { + deps.openWorkspaceAgentTab(`${serverId}:${resolvedWorkspaceId}`, attentionAgentId); + } + + deps.rememberLastWorkspace({ serverId, workspaceId }); + deps.navigateToRoute(buildHostWorkspaceRoute(serverId, workspaceId)); +} + +export function navigateToLastWorkspace(deps: NavigateToLastWorkspaceDeps): boolean { + const selection = deps.getLastWorkspaceSelection(); + if (!selection) { + return false; + } + navigateToWorkspace(selection.serverId, selection.workspaceId, deps); + return true; +} diff --git a/packages/app/src/stores/panel-store.ts b/packages/app/src/stores/panel-store/index.ts similarity index 59% rename from packages/app/src/stores/panel-store.ts rename to packages/app/src/stores/panel-store/index.ts index c64f157f8..8be293ef3 100644 --- a/packages/app/src/stores/panel-store.ts +++ b/packages/app/src/stores/panel-store/index.ts @@ -4,66 +4,61 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { buildExplorerCheckoutKey, coerceExplorerTabForCheckout, - isExplorerTab, resolveExplorerTabForCheckout, type ExplorerTab, -} from "./explorer-tab-memory"; -import { type ExplorerCheckoutContext } from "./explorer-checkout-context"; +} from "../explorer-tab-memory"; +import { type ExplorerCheckoutContext } from "../explorer-checkout-context"; +import { + buildOpenFileExplorerPatch, + buildToggleFileExplorerPatch, + clampExplorerFilesSplitRatio, + clampExplorerWidth, + clampSidebarWidth, + DEFAULT_EXPLORER_FILES_SPLIT_RATIO, + DEFAULT_EXPLORER_SIDEBAR_WIDTH, + DEFAULT_SIDEBAR_WIDTH, + MAX_EXPLORER_FILES_SPLIT_RATIO, + MAX_EXPLORER_SIDEBAR_WIDTH, + MAX_SIDEBAR_WIDTH, + MIN_EXPLORER_FILES_SPLIT_RATIO, + MIN_EXPLORER_SIDEBAR_WIDTH, + MIN_SIDEBAR_WIDTH, + migratePanelState, + selectIsAgentListOpen, + selectIsFileExplorerOpen, + selectPanelVisibility, + type DesktopSidebarState, + type ExplorerPanelIntent, + type MobilePanelView, + type PanelLayoutInput, + type PanelVisibilityState, + type SortOption, +} from "./state"; import { isWeb } from "@/constants/platform"; -export type { ExplorerTab } from "./explorer-tab-memory"; -export type { ExplorerCheckoutContext } from "./explorer-checkout-context"; - -/** - * Mobile panel state machine. - * - * On mobile, exactly one panel can be visible at a time: - * - 'agent': Main agent view (no overlay panel) - * - 'agent-list': Agent list sidebar (left overlay) - * - 'file-explorer': File explorer sidebar (right overlay) - * - * This makes impossible states unrepresentable - you cannot have both - * sidebars open at the same time on mobile. - */ -export type MobilePanelView = "agent" | "agent-list" | "file-explorer"; - -/** - * Desktop sidebar state. - * - * On desktop, sidebars are independent toggleable panels that don't overlay - * the main content - they sit alongside it. Both can be open simultaneously. - */ -interface DesktopSidebarState { - agentListOpen: boolean; - fileExplorerOpen: boolean; - focusModeEnabled: boolean; -} - -export type SortOption = "name" | "modified" | "size"; -export const DEFAULT_SIDEBAR_WIDTH = 320; -export const MIN_SIDEBAR_WIDTH = 200; -export const MAX_SIDEBAR_WIDTH = 600; - -export const DEFAULT_EXPLORER_SIDEBAR_WIDTH = 400; -export const MIN_EXPLORER_SIDEBAR_WIDTH = 280; -// Upper bound is intentionally generous; desktop resizing enforces a min-chat-width constraint. -export const MAX_EXPLORER_SIDEBAR_WIDTH = 2000; - -export const DEFAULT_EXPLORER_FILES_SPLIT_RATIO = 0.38; -export const MIN_EXPLORER_FILES_SPLIT_RATIO = 0.2; -export const MAX_EXPLORER_FILES_SPLIT_RATIO = 0.8; - -export interface PanelVisibilityState { - isAgentListOpen: boolean; - isFileExplorerOpen: boolean; -} - -export interface PanelLayoutInput { - isCompact: boolean; -} - -export interface ExplorerPanelIntent extends PanelLayoutInput { - checkout: ExplorerCheckoutContext; -} +export type { ExplorerTab } from "../explorer-tab-memory"; +export type { ExplorerCheckoutContext } from "../explorer-checkout-context"; +export type { + DesktopSidebarState, + ExplorerPanelIntent, + MobilePanelView, + PanelLayoutInput, + PanelVisibilityState, + SortOption, +} from "./state"; +export { + DEFAULT_EXPLORER_FILES_SPLIT_RATIO, + DEFAULT_EXPLORER_SIDEBAR_WIDTH, + DEFAULT_SIDEBAR_WIDTH, + MAX_EXPLORER_FILES_SPLIT_RATIO, + MAX_EXPLORER_SIDEBAR_WIDTH, + MAX_SIDEBAR_WIDTH, + MIN_EXPLORER_FILES_SPLIT_RATIO, + MIN_EXPLORER_SIDEBAR_WIDTH, + MIN_SIDEBAR_WIDTH, + selectIsAgentListOpen, + selectIsFileExplorerOpen, + selectPanelVisibility, +}; export interface PanelState { // Mobile: which panel is currently shown @@ -109,173 +104,6 @@ export interface PanelState { setExplorerFilesSplitRatio: (ratio: number) => void; } -function clampNumber(value: number, min: number, max: number): number { - if (!Number.isFinite(value)) { - return min; - } - return Math.max(min, Math.min(max, value)); -} - -function clampSidebarWidth(width: number): number { - return clampNumber(width, MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH); -} - -function clampWidth(width: number): number { - return clampNumber(width, MIN_EXPLORER_SIDEBAR_WIDTH, MAX_EXPLORER_SIDEBAR_WIDTH); -} - -function clampExplorerFilesSplitRatio(ratio: number): number { - return clampNumber(ratio, MIN_EXPLORER_FILES_SPLIT_RATIO, MAX_EXPLORER_FILES_SPLIT_RATIO); -} - -function resolveExplorerTabFromCheckout(state: PanelState, checkout: ExplorerCheckoutContext) { - return resolveExplorerTabForCheckout({ - serverId: checkout.serverId, - cwd: checkout.cwd, - isGit: checkout.isGit, - explorerTabByCheckout: state.explorerTabByCheckout, - }); -} - -function buildOpenFileExplorerPatch( - state: PanelState, - input: ExplorerPanelIntent, -): Partial { - const resolvedTab = resolveExplorerTabFromCheckout(state, input.checkout); - if (input.isCompact) { - return { - mobileView: "file-explorer", - explorerTab: resolvedTab, - }; - } - return { - desktop: { ...state.desktop, fileExplorerOpen: true }, - explorerTab: resolvedTab, - }; -} - -export function selectPanelVisibility( - state: PanelState, - input: PanelLayoutInput, -): PanelVisibilityState { - if (input.isCompact) { - return { - isAgentListOpen: state.mobileView === "agent-list", - isFileExplorerOpen: state.mobileView === "file-explorer", - }; - } - return { - isAgentListOpen: state.desktop.agentListOpen, - isFileExplorerOpen: state.desktop.fileExplorerOpen, - }; -} - -export function selectIsAgentListOpen(state: PanelState, input: PanelLayoutInput): boolean { - return selectPanelVisibility(state, input).isAgentListOpen; -} - -export function selectIsFileExplorerOpen(state: PanelState, input: PanelLayoutInput): boolean { - return selectPanelVisibility(state, input).isFileExplorerOpen; -} - -type MigratablePanelState = Partial & Record; - -function migratePanelV2Explorer(state: MigratablePanelState): void { - if (isWeb && typeof state.explorerWidth === "number" && state.explorerWidth === 400) { - state.explorerWidth = DEFAULT_EXPLORER_SIDEBAR_WIDTH; - } - if (typeof state.explorerFilesSplitRatio !== "number") { - state.explorerFilesSplitRatio = DEFAULT_EXPLORER_FILES_SPLIT_RATIO; - } else { - state.explorerFilesSplitRatio = clampExplorerFilesSplitRatio(state.explorerFilesSplitRatio); - } -} - -function migratePanelV3Explorer(state: MigratablePanelState): void { - if ( - isWeb && - typeof state.explorerWidth === "number" && - (state.explorerWidth === 400 || state.explorerWidth === 520) - ) { - state.explorerWidth = DEFAULT_EXPLORER_SIDEBAR_WIDTH; - } -} - -function migratePanelExplorerTabByCheckout(state: MigratablePanelState, version: number): void { - if ( - version < 4 || - typeof state.explorerTabByCheckout !== "object" || - !state.explorerTabByCheckout - ) { - state.explorerTabByCheckout = {}; - return; - } - const entries = Object.entries(state.explorerTabByCheckout as Record); - const next: Record = {}; - for (const [key, value] of entries) { - if (!isExplorerTab(value)) { - continue; - } - next[key] = value; - } - state.explorerTabByCheckout = next; -} - -function migratePanelDesktopFocusMode(state: MigratablePanelState): void { - const desktop = state.desktop as Record | undefined; - if (!desktop) { - return; - } - if ("zoomed" in desktop) { - desktop.focusModeEnabled = desktop.zoomed; - delete desktop.zoomed; - } - if ("focused" in desktop) { - desktop.focusModeEnabled = desktop.focused; - delete desktop.focused; - } - if (typeof desktop.focusModeEnabled !== "boolean") { - desktop.focusModeEnabled = false; - } -} - -function migratePanelState(persistedState: unknown, version: number): PanelState { - const state = persistedState as MigratablePanelState; - - if (version < 2) { - migratePanelV2Explorer(state); - } - if (version < 3) { - migratePanelV3Explorer(state); - } - if (!isExplorerTab(state.explorerTab)) { - state.explorerTab = "changes"; - } - migratePanelExplorerTabByCheckout(state, version); - if (version < 8) { - migratePanelDesktopFocusMode(state); - } - if (version < 6 || typeof state.sidebarWidth !== "number") { - state.sidebarWidth = DEFAULT_SIDEBAR_WIDTH; - } - if ( - version < 9 || - typeof state.expandedPathsByWorkspace !== "object" || - !state.expandedPathsByWorkspace - ) { - state.expandedPathsByWorkspace = {}; - } - if ( - version < 10 || - typeof state.diffExpandedPathsByWorkspace !== "object" || - !state.diffExpandedPathsByWorkspace - ) { - state.diffExpandedPathsByWorkspace = {}; - } - - return state as PanelState; -} - const DEFAULT_DESKTOP_OPEN = isWeb; export const usePanelStore = create()( @@ -392,16 +220,7 @@ export const usePanelStore = create()( set((state) => buildOpenFileExplorerPatch(state, input)), toggleFileExplorerForCheckout: (input) => - set((state) => { - const isOpen = selectIsFileExplorerOpen(state, input); - if (!isOpen) { - return buildOpenFileExplorerPatch(state, input); - } - if (input.isCompact) { - return { mobileView: "agent" as const }; - } - return { desktop: { ...state.desktop, fileExplorerOpen: false } }; - }), + set((state) => buildToggleFileExplorerPatch(state, input)), setExplorerTab: (tab) => set({ explorerTab: tab }), setExplorerTabForCheckout: ({ serverId, cwd, isGit, tab }) => @@ -441,7 +260,7 @@ export const usePanelStore = create()( }), })), setSidebarWidth: (width) => set({ sidebarWidth: clampSidebarWidth(width) }), - setExplorerWidth: (width) => set({ explorerWidth: clampWidth(width) }), + setExplorerWidth: (width) => set({ explorerWidth: clampExplorerWidth(width) }), setExplorerSortOption: (option) => set({ explorerSortOption: option }), setExplorerFilesSplitRatio: (ratio) => set({ @@ -454,7 +273,8 @@ export const usePanelStore = create()( name: "panel-state", version: 10, storage: createJSONStorage(() => AsyncStorage), - migrate: (persistedState, version) => migratePanelState(persistedState, version), + migrate: (persistedState, version) => + migratePanelState(persistedState, version, { isWeb }) as unknown as PanelState, partialize: (state) => ({ mobileView: state.mobileView, desktop: state.desktop, diff --git a/packages/app/src/stores/panel-store.test.ts b/packages/app/src/stores/panel-store/state.test.ts similarity index 62% rename from packages/app/src/stores/panel-store.test.ts rename to packages/app/src/stores/panel-store/state.test.ts index 6d940b0b2..0d2764574 100644 --- a/packages/app/src/stores/panel-store.test.ts +++ b/packages/app/src/stores/panel-store/state.test.ts @@ -1,35 +1,20 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("@react-native-async-storage/async-storage", () => { - const storage = new Map(); - return { - default: { - getItem: vi.fn(async (key: string) => storage.get(key) ?? null), - setItem: vi.fn(async (key: string, value: string) => { - storage.set(key, value); - }), - removeItem: vi.fn(async (key: string) => { - storage.delete(key); - }), - }, - }; -}); - +import { describe, expect, it } from "vitest"; import { buildExplorerCheckoutKey, resolveExplorerTabForCheckout, type ExplorerTab, } from "@/stores/explorer-tab-memory"; import { + buildOpenFileExplorerPatch, + buildToggleFileExplorerPatch, selectIsAgentListOpen, selectIsFileExplorerOpen, selectPanelVisibility, - usePanelStore, - type PanelState, -} from "@/stores/panel-store"; + type PanelCoreState, +} from "./state"; -function resetPanelStore() { - usePanelStore.setState({ +function makePanelState(overrides: Partial = {}): PanelCoreState { + return { mobileView: "agent", desktop: { agentListOpen: false, @@ -38,29 +23,10 @@ function resetPanelStore() { }, explorerTab: "changes", explorerTabByCheckout: {}, - }); -} - -function createPanelState(input: { - mobileView: PanelState["mobileView"]; - agentListOpen: boolean; - fileExplorerOpen: boolean; -}): PanelState { - return { - ...usePanelStore.getState(), - mobileView: input.mobileView, - desktop: { - ...usePanelStore.getState().desktop, - agentListOpen: input.agentListOpen, - fileExplorerOpen: input.fileExplorerOpen, - }, + ...overrides, }; } -beforeEach(() => { - resetPanelStore(); -}); - describe("panel-store explorer tab resolution", () => { const serverId = "server-1"; const cwd = "/tmp/repo"; @@ -132,10 +98,9 @@ describe("panel-store explorer tab resolution", () => { describe("panel-store visibility selectors", () => { it("uses mobileView for compact layout visibility", () => { - const state = createPanelState({ + const state = makePanelState({ mobileView: "file-explorer", - agentListOpen: true, - fileExplorerOpen: false, + desktop: { agentListOpen: true, fileExplorerOpen: false, focusModeEnabled: false }, }); expect(selectPanelVisibility(state, { isCompact: true })).toEqual({ @@ -147,10 +112,9 @@ describe("panel-store visibility selectors", () => { }); it("uses desktop flags for expanded layout visibility", () => { - const state = createPanelState({ + const state = makePanelState({ mobileView: "file-explorer", - agentListOpen: true, - fileExplorerOpen: false, + desktop: { agentListOpen: true, fileExplorerOpen: false, focusModeEnabled: false }, }); expect(selectPanelVisibility(state, { isCompact: false })).toEqual({ @@ -166,83 +130,71 @@ describe("panel-store checkout-intent file explorer actions", () => { it("opens the compact explorer and resolves the tab from the explicit checkout", () => { const checkout = { serverId: "server-1", cwd: "/tmp/repo", isGit: true }; const key = buildExplorerCheckoutKey(checkout.serverId, checkout.cwd)!; - usePanelStore.setState({ + const state = makePanelState({ explorerTab: "changes", explorerTabByCheckout: { [key]: "files" }, }); - usePanelStore.getState().openFileExplorerForCheckout({ - isCompact: true, - checkout, - }); + const patch = buildOpenFileExplorerPatch(state, { isCompact: true, checkout }); - expect(usePanelStore.getState().mobileView).toBe("file-explorer"); - expect(usePanelStore.getState().desktop.fileExplorerOpen).toBe(false); - expect(usePanelStore.getState().explorerTab).toBe("files"); + expect(patch.mobileView).toBe("file-explorer"); + expect(patch.desktop).toBeUndefined(); + expect(patch.explorerTab).toBe("files"); }); it("opens the expanded explorer and resolves the tab from the explicit checkout", () => { const checkout = { serverId: "server-1", cwd: "/tmp/repo", isGit: true }; const key = buildExplorerCheckoutKey(checkout.serverId, checkout.cwd)!; - usePanelStore.setState({ + const state = makePanelState({ explorerTab: "changes", explorerTabByCheckout: { [key]: "files" }, }); - usePanelStore.getState().openFileExplorerForCheckout({ - isCompact: false, - checkout, - }); + const patch = buildOpenFileExplorerPatch(state, { isCompact: false, checkout }); - expect(usePanelStore.getState().mobileView).toBe("agent"); - expect(usePanelStore.getState().desktop.fileExplorerOpen).toBe(true); - expect(usePanelStore.getState().explorerTab).toBe("files"); + expect(patch.mobileView).toBeUndefined(); + expect(patch.desktop?.fileExplorerOpen).toBe(true); + expect(patch.explorerTab).toBe("files"); }); it("toggles the explorer closed without changing the active tab", () => { - usePanelStore.setState({ - desktop: { - agentListOpen: false, - fileExplorerOpen: true, - focusModeEnabled: false, - }, + const state = makePanelState({ + desktop: { agentListOpen: false, fileExplorerOpen: true, focusModeEnabled: false }, explorerTab: "files", }); - usePanelStore.getState().toggleFileExplorerForCheckout({ + const patch = buildToggleFileExplorerPatch(state, { isCompact: false, checkout: { serverId: "server-1", cwd: "/tmp/repo", isGit: true }, }); - expect(usePanelStore.getState().desktop.fileExplorerOpen).toBe(false); - expect(usePanelStore.getState().explorerTab).toBe("files"); + expect(patch).toEqual({ + desktop: { agentListOpen: false, fileExplorerOpen: false, focusModeEnabled: false }, + }); }); it("coerces changes to files for a non-git checkout", () => { const checkout = { serverId: "server-1", cwd: "/tmp/repo", isGit: false }; const key = buildExplorerCheckoutKey(checkout.serverId, checkout.cwd)!; - usePanelStore.setState({ + const state = makePanelState({ explorerTab: "changes", explorerTabByCheckout: { [key]: "changes" }, }); - usePanelStore.getState().openFileExplorerForCheckout({ - isCompact: false, - checkout, - }); + const patch = buildOpenFileExplorerPatch(state, { isCompact: false, checkout }); - expect(usePanelStore.getState().explorerTab).toBe("files"); + expect(patch.explorerTab).toBe("files"); }); it("opens with the default files tab for an explicit non-git checkout with no stored tab", () => { - usePanelStore.setState({ explorerTab: "changes", explorerTabByCheckout: {} }); + const state = makePanelState({ explorerTab: "changes", explorerTabByCheckout: {} }); - usePanelStore.getState().openFileExplorerForCheckout({ + const patch = buildOpenFileExplorerPatch(state, { isCompact: false, checkout: { serverId: "server-1", cwd: "/tmp/non-git", isGit: false }, }); - expect(usePanelStore.getState().desktop.fileExplorerOpen).toBe(true); - expect(usePanelStore.getState().explorerTab).toBe("files"); + expect(patch.desktop?.fileExplorerOpen).toBe(true); + expect(patch.explorerTab).toBe("files"); }); }); diff --git a/packages/app/src/stores/panel-store/state.ts b/packages/app/src/stores/panel-store/state.ts new file mode 100644 index 000000000..c8f28b2ea --- /dev/null +++ b/packages/app/src/stores/panel-store/state.ts @@ -0,0 +1,253 @@ +import { + buildExplorerCheckoutKey, + isExplorerTab, + resolveExplorerTabForCheckout, + type ExplorerTab, +} from "../explorer-tab-memory"; +import { type ExplorerCheckoutContext } from "../explorer-checkout-context"; + +export type MobilePanelView = "agent" | "agent-list" | "file-explorer"; + +export interface DesktopSidebarState { + agentListOpen: boolean; + fileExplorerOpen: boolean; + focusModeEnabled: boolean; +} + +export type SortOption = "name" | "modified" | "size"; + +export const DEFAULT_SIDEBAR_WIDTH = 320; +export const MIN_SIDEBAR_WIDTH = 200; +export const MAX_SIDEBAR_WIDTH = 600; + +export const DEFAULT_EXPLORER_SIDEBAR_WIDTH = 400; +export const MIN_EXPLORER_SIDEBAR_WIDTH = 280; +// Upper bound is intentionally generous; desktop resizing enforces a min-chat-width constraint. +export const MAX_EXPLORER_SIDEBAR_WIDTH = 2000; + +export const DEFAULT_EXPLORER_FILES_SPLIT_RATIO = 0.38; +export const MIN_EXPLORER_FILES_SPLIT_RATIO = 0.2; +export const MAX_EXPLORER_FILES_SPLIT_RATIO = 0.8; + +export interface PanelVisibilityState { + isAgentListOpen: boolean; + isFileExplorerOpen: boolean; +} + +export interface PanelLayoutInput { + isCompact: boolean; +} + +export interface ExplorerPanelIntent extends PanelLayoutInput { + checkout: ExplorerCheckoutContext; +} + +export interface PanelCoreState { + mobileView: MobilePanelView; + desktop: DesktopSidebarState; + explorerTab: ExplorerTab; + explorerTabByCheckout: Record; +} + +function clampNumber(value: number, min: number, max: number): number { + if (!Number.isFinite(value)) { + return min; + } + return Math.max(min, Math.min(max, value)); +} + +export function clampSidebarWidth(width: number): number { + return clampNumber(width, MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH); +} + +export function clampExplorerWidth(width: number): number { + return clampNumber(width, MIN_EXPLORER_SIDEBAR_WIDTH, MAX_EXPLORER_SIDEBAR_WIDTH); +} + +export function clampExplorerFilesSplitRatio(ratio: number): number { + return clampNumber(ratio, MIN_EXPLORER_FILES_SPLIT_RATIO, MAX_EXPLORER_FILES_SPLIT_RATIO); +} + +export function selectPanelVisibility( + state: PanelCoreState, + input: PanelLayoutInput, +): PanelVisibilityState { + if (input.isCompact) { + return { + isAgentListOpen: state.mobileView === "agent-list", + isFileExplorerOpen: state.mobileView === "file-explorer", + }; + } + return { + isAgentListOpen: state.desktop.agentListOpen, + isFileExplorerOpen: state.desktop.fileExplorerOpen, + }; +} + +export function selectIsAgentListOpen(state: PanelCoreState, input: PanelLayoutInput): boolean { + return selectPanelVisibility(state, input).isAgentListOpen; +} + +export function selectIsFileExplorerOpen(state: PanelCoreState, input: PanelLayoutInput): boolean { + return selectPanelVisibility(state, input).isFileExplorerOpen; +} + +function resolveExplorerTabFromCheckout( + state: PanelCoreState, + checkout: ExplorerCheckoutContext, +): ExplorerTab { + return resolveExplorerTabForCheckout({ + serverId: checkout.serverId, + cwd: checkout.cwd, + isGit: checkout.isGit, + explorerTabByCheckout: state.explorerTabByCheckout, + }); +} + +export interface OpenFileExplorerPatch { + mobileView?: MobilePanelView; + desktop?: DesktopSidebarState; + explorerTab: ExplorerTab; +} + +export function buildOpenFileExplorerPatch( + state: PanelCoreState, + input: ExplorerPanelIntent, +): OpenFileExplorerPatch { + const resolvedTab = resolveExplorerTabFromCheckout(state, input.checkout); + if (input.isCompact) { + return { + mobileView: "file-explorer", + explorerTab: resolvedTab, + }; + } + return { + desktop: { ...state.desktop, fileExplorerOpen: true }, + explorerTab: resolvedTab, + }; +} + +export type ToggleFileExplorerPatch = + | OpenFileExplorerPatch + | { mobileView: MobilePanelView } + | { desktop: DesktopSidebarState }; + +export function buildToggleFileExplorerPatch( + state: PanelCoreState, + input: ExplorerPanelIntent, +): ToggleFileExplorerPatch { + const isOpen = selectIsFileExplorerOpen(state, input); + if (!isOpen) { + return buildOpenFileExplorerPatch(state, input); + } + if (input.isCompact) { + return { mobileView: "agent" }; + } + return { desktop: { ...state.desktop, fileExplorerOpen: false } }; +} + +type MigratablePanelState = Record; + +function migratePanelV2Explorer(state: MigratablePanelState, isWeb: boolean): void { + if (isWeb && typeof state.explorerWidth === "number" && state.explorerWidth === 400) { + state.explorerWidth = DEFAULT_EXPLORER_SIDEBAR_WIDTH; + } + if (typeof state.explorerFilesSplitRatio !== "number") { + state.explorerFilesSplitRatio = DEFAULT_EXPLORER_FILES_SPLIT_RATIO; + } else { + state.explorerFilesSplitRatio = clampExplorerFilesSplitRatio(state.explorerFilesSplitRatio); + } +} + +function migratePanelV3Explorer(state: MigratablePanelState, isWeb: boolean): void { + if ( + isWeb && + typeof state.explorerWidth === "number" && + (state.explorerWidth === 400 || state.explorerWidth === 520) + ) { + state.explorerWidth = DEFAULT_EXPLORER_SIDEBAR_WIDTH; + } +} + +function migratePanelExplorerTabByCheckout(state: MigratablePanelState, version: number): void { + if ( + version < 4 || + typeof state.explorerTabByCheckout !== "object" || + !state.explorerTabByCheckout + ) { + state.explorerTabByCheckout = {}; + return; + } + const entries = Object.entries(state.explorerTabByCheckout as Record); + const next: Record = {}; + for (const [key, value] of entries) { + if (!isExplorerTab(value)) { + continue; + } + next[key] = value; + } + state.explorerTabByCheckout = next; +} + +function migratePanelDesktopFocusMode(state: MigratablePanelState): void { + const desktop = state.desktop as Record | undefined; + if (!desktop) { + return; + } + if ("zoomed" in desktop) { + desktop.focusModeEnabled = desktop.zoomed; + delete desktop.zoomed; + } + if ("focused" in desktop) { + desktop.focusModeEnabled = desktop.focused; + delete desktop.focused; + } + if (typeof desktop.focusModeEnabled !== "boolean") { + desktop.focusModeEnabled = false; + } +} + +export function migratePanelState( + persistedState: unknown, + version: number, + options: { isWeb: boolean }, +): MigratablePanelState { + const state = (persistedState ?? {}) as MigratablePanelState; + const { isWeb } = options; + + if (version < 2) { + migratePanelV2Explorer(state, isWeb); + } + if (version < 3) { + migratePanelV3Explorer(state, isWeb); + } + if (!isExplorerTab(state.explorerTab)) { + state.explorerTab = "changes"; + } + migratePanelExplorerTabByCheckout(state, version); + if (version < 8) { + migratePanelDesktopFocusMode(state); + } + if (version < 6 || typeof state.sidebarWidth !== "number") { + state.sidebarWidth = DEFAULT_SIDEBAR_WIDTH; + } + if ( + version < 9 || + typeof state.expandedPathsByWorkspace !== "object" || + !state.expandedPathsByWorkspace + ) { + state.expandedPathsByWorkspace = {}; + } + if ( + version < 10 || + typeof state.diffExpandedPathsByWorkspace !== "object" || + !state.diffExpandedPathsByWorkspace + ) { + state.diffExpandedPathsByWorkspace = {}; + } + + return state; +} + +export { buildExplorerCheckoutKey, resolveExplorerTabForCheckout }; +export type { ExplorerTab, ExplorerCheckoutContext }; diff --git a/packages/app/src/stores/session-store-hooks.test.ts b/packages/app/src/stores/session-store-hooks.test.ts deleted file mode 100644 index 10d948c18..000000000 --- a/packages/app/src/stores/session-store-hooks.test.ts +++ /dev/null @@ -1,366 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import { act, renderHook } from "@testing-library/react"; -import { afterEach, describe, expect, it } from "vitest"; - -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { - useHasWorkspaces, - useRecommendedProjectPaths, - useResolveWorkspaceIdByCwd, - useWorkspace, - useWorkspaceExecutionAuthority, - useWorkspaceFields, - useWorkspaceKeys, - useWorkspaceStatusesForBadges, - useWorkspaceStructure, -} from "./session-store-hooks"; -import { useSidebarOrderStore } from "./sidebar-order-store"; -import { useSessionStore, type WorkspaceDescriptor } from "./session-store"; - -const SERVER_ID = "test-server"; - -function createWorkspace( - input: Partial & Pick, -): WorkspaceDescriptor { - return { - id: input.id, - projectId: input.projectId ?? "project-1", - projectDisplayName: input.projectDisplayName ?? "Project 1", - projectRootPath: input.projectRootPath ?? "/repo", - workspaceDirectory: input.workspaceDirectory ?? "/repo", - projectKind: input.projectKind ?? "git", - workspaceKind: input.workspaceKind ?? "local_checkout", - name: input.name ?? "main", - status: input.status ?? "done", - archivingAt: input.archivingAt ?? null, - diffStat: input.diffStat ?? null, - scripts: input.scripts ?? [], - }; -} - -function initializeWorkspaces(workspaces: WorkspaceDescriptor[]): void { - act(() => { - useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient); - useSessionStore - .getState() - .setWorkspaces(SERVER_ID, new Map(workspaces.map((workspace) => [workspace.id, workspace]))); - }); -} - -afterEach(() => { - act(() => { - useSessionStore.getState().clearSession(SERVER_ID); - useSidebarOrderStore.setState({ - projectOrderByServerId: {}, - workspaceOrderByServerAndProject: {}, - }); - }); -}); - -describe("useWorkspace", () => { - it("resolves a descriptor when the route id matches workspace identity but not the map key", () => { - const workspace = createWorkspace({ id: "workspace-a" }); - act(() => { - useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient); - useSessionStore.getState().setWorkspaces(SERVER_ID, new Map([["map-key-a", workspace]])); - }); - - const { result } = renderHook(() => useWorkspace(SERVER_ID, workspace.id)); - - expect(result.current).toBe(workspace); - }); - - it("keeps the descriptor reference for unrelated workspace updates", () => { - const workspaceA = createWorkspace({ id: "workspace-a", name: "A" }); - const workspaceB = createWorkspace({ id: "workspace-b", name: "B" }); - initializeWorkspaces([workspaceA, workspaceB]); - - const { result } = renderHook(() => useWorkspace(SERVER_ID, workspaceA.id)); - const before = result.current; - - act(() => { - useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceB, status: "running" }]); - }); - expect(result.current).toBe(before); - - act(() => { - useSessionStore - .getState() - .mergeWorkspaces(SERVER_ID, [{ ...workspaceA, status: "attention" }]); - }); - expect(result.current).not.toBe(before); - }); - - it("keeps the descriptor reference when the observed workspace is rewritten with content-equal data", () => { - const workspace = createWorkspace({ id: "workspace-a", scripts: [] }); - initializeWorkspaces([workspace]); - - const { result } = renderHook(() => useWorkspace(SERVER_ID, workspace.id)); - const before = result.current; - - act(() => { - useSessionStore - .getState() - .setWorkspaces(SERVER_ID, new Map([[workspace.id, { ...workspace, scripts: [] }]])); - }); - expect(result.current).toBe(before); - }); -}); - -describe("useWorkspaceExecutionAuthority", () => { - it("preserves the old missing-workspace message with the requested id", () => { - initializeWorkspaces([]); - - const { result } = renderHook(() => useWorkspaceExecutionAuthority(SERVER_ID, "missing-id")); - - expect(result.current).toMatchObject({ - ok: false, - message: "Workspace not found: missing-id", - }); - }); - - it("keeps deep-equal authority references under unrelated workspace updates", () => { - const workspaceA = createWorkspace({ id: "workspace-a", name: "A" }); - const workspaceB = createWorkspace({ id: "workspace-b", name: "B" }); - initializeWorkspaces([workspaceA, workspaceB]); - - const { result } = renderHook(() => useWorkspaceExecutionAuthority(SERVER_ID, workspaceA.id)); - const before = result.current; - - act(() => { - useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceB, status: "running" }]); - }); - - expect(result.current).toBe(before); - }); -}); - -describe("useWorkspaceFields", () => { - it("keeps deep-equal projection references until projected fields change", () => { - const workspace = createWorkspace({ id: "workspace-a", name: "A", status: "done" }); - initializeWorkspaces([workspace]); - - const selectIdentity = (current: { id: string; name: string }) => ({ - identity: { - id: current.id, - name: current.name, - }, - }); - const { result } = renderHook(() => - useWorkspaceFields(SERVER_ID, workspace.id, selectIdentity), - ); - const before = result.current; - - act(() => { - useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspace, status: "running" }]); - }); - expect(result.current).toBe(before); - - act(() => { - useSessionStore.getState().mergeWorkspaces(SERVER_ID, [ - { - ...workspace, - name: "A renamed", - status: "running", - }, - ]); - }); - expect(result.current).not.toBe(before); - }); -}); - -describe("useWorkspaceStructure", () => { - it("changes for membership updates but not status-only updates", () => { - const workspaceA = createWorkspace({ id: "workspace-a", name: "A" }); - const workspaceB = createWorkspace({ id: "workspace-b", name: "B" }); - initializeWorkspaces([workspaceA]); - - const { result } = renderHook(() => useWorkspaceStructure(SERVER_ID)); - const before = result.current; - - act(() => { - useSessionStore.getState().mergeWorkspaces(SERVER_ID, [workspaceB]); - }); - const afterAdd = result.current; - expect(afterAdd).not.toBe(before); - expect(afterAdd.projects[0]?.workspaceKeys).toEqual(["workspace-a", "workspace-b"]); - - act(() => { - useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceA, status: "running" }]); - }); - expect(result.current).toBe(afterAdd); - }); - - it("changes when a structure-relevant project identity field changes", () => { - const workspace = createWorkspace({ - id: "workspace-a", - projectDisplayName: "Project 1", - }); - initializeWorkspaces([workspace]); - - const { result } = renderHook(() => useWorkspaceStructure(SERVER_ID)); - const before = result.current; - - act(() => { - useSessionStore - .getState() - .mergeWorkspaces(SERVER_ID, [{ ...workspace, projectDisplayName: "Project Renamed" }]); - }); - expect(result.current).not.toBe(before); - }); - - it("changes when persisted sidebar project order changes", () => { - const workspaceA = createWorkspace({ - id: "workspace-a", - projectId: "project-a", - projectDisplayName: "Project A", - }); - const workspaceB = createWorkspace({ - id: "workspace-b", - projectId: "project-b", - projectDisplayName: "Project B", - }); - initializeWorkspaces([workspaceA, workspaceB]); - - const { result } = renderHook(() => useWorkspaceStructure(SERVER_ID)); - const before = result.current; - - act(() => { - useSidebarOrderStore.getState().setProjectOrder(SERVER_ID, ["project-b", "project-a"]); - }); - expect(result.current).not.toBe(before); - }); -}); - -describe("useWorkspaceKeys", () => { - it("changes for reorder updates but not content-only updates", () => { - const workspaceA = createWorkspace({ id: "workspace-a", name: "A" }); - const workspaceB = createWorkspace({ id: "workspace-b", name: "B" }); - initializeWorkspaces([workspaceA, workspaceB]); - - const { result } = renderHook(() => useWorkspaceKeys(SERVER_ID)); - const before = result.current; - expect(before).toEqual(["workspace-a", "workspace-b"]); - - act(() => { - useSessionStore.getState().setWorkspaces( - SERVER_ID, - new Map([ - [workspaceB.id, workspaceB], - [workspaceA.id, workspaceA], - ]), - ); - }); - const afterReorder = result.current; - expect(afterReorder).not.toBe(before); - expect(afterReorder).toEqual(["workspace-b", "workspace-a"]); - - act(() => { - useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceA, status: "running" }]); - }); - expect(result.current).toBe(afterReorder); - }); -}); - -describe("useRecommendedProjectPaths", () => { - it("updates when an existing workspace project root changes", () => { - const workspace = createWorkspace({ id: "workspace-a", projectRootPath: "/repo/a" }); - initializeWorkspaces([workspace]); - - const { result } = renderHook(() => useRecommendedProjectPaths(SERVER_ID)); - - act(() => { - useSessionStore - .getState() - .mergeWorkspaces(SERVER_ID, [{ ...workspace, projectRootPath: "/repo/b" }]); - }); - - expect(result.current).toEqual(["/repo/b"]); - }); - - it("keeps the path list reference under unrelated workspace updates", () => { - const workspace = createWorkspace({ id: "workspace-a", projectRootPath: "/repo/a" }); - initializeWorkspaces([workspace]); - - const { result } = renderHook(() => useRecommendedProjectPaths(SERVER_ID)); - const before = result.current; - - act(() => { - useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspace, status: "running" }]); - }); - - expect(result.current).toBe(before); - }); -}); - -describe("useHasWorkspaces", () => { - it("stays stable when workspace membership changes without flipping the boolean", () => { - const workspaceA = createWorkspace({ id: "workspace-a" }); - const workspaceB = createWorkspace({ id: "workspace-b" }); - initializeWorkspaces([workspaceA]); - - const { result } = renderHook(() => useHasWorkspaces(SERVER_ID)); - const before = result.current; - - act(() => { - useSessionStore.getState().mergeWorkspaces(SERVER_ID, [workspaceB]); - }); - - expect(result.current).toBe(before); - }); -}); - -describe("useResolveWorkspaceIdByCwd", () => { - it("resolves by cwd and stays stable under unrelated updates", () => { - const workspaceA = createWorkspace({ - id: "workspace-a", - workspaceDirectory: "/repo/a", - }); - const workspaceB = createWorkspace({ - id: "workspace-b", - workspaceDirectory: "/repo/b", - }); - initializeWorkspaces([workspaceA, workspaceB]); - - const { result } = renderHook(() => useResolveWorkspaceIdByCwd(SERVER_ID, "/repo/a")); - const before = result.current; - expect(before).toBe("workspace-a"); - - act(() => { - useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceB, status: "running" }]); - }); - expect(result.current).toBe(before); - }); -}); - -describe("useWorkspaceStatusesForBadges", () => { - it("tracks status changes without changing for no-ops or unrelated descriptor updates", () => { - const workspaceA = createWorkspace({ id: "workspace-a", status: "done" }); - const workspaceB = createWorkspace({ id: "workspace-b", status: "attention" }); - initializeWorkspaces([workspaceA, workspaceB]); - - const { result } = renderHook(() => useWorkspaceStatusesForBadges()); - const before = result.current; - expect(before).toEqual(["done", "attention"]); - - act(() => { - useSessionStore - .getState() - .mergeWorkspaces(SERVER_ID, [{ ...workspaceA, scripts: [...workspaceA.scripts] }]); - }); - expect(result.current).toBe(before); - - act(() => { - useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceB, name: "Renamed" }]); - }); - expect(result.current).toBe(before); - - act(() => { - useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceA, status: "failed" }]); - }); - expect(result.current).not.toBe(before); - expect(result.current).toEqual(["failed", "attention"]); - }); -}); diff --git a/packages/app/src/stores/session-store-hooks/index.ts b/packages/app/src/stores/session-store-hooks/index.ts new file mode 100644 index 000000000..cb2a72d20 --- /dev/null +++ b/packages/app/src/stores/session-store-hooks/index.ts @@ -0,0 +1,138 @@ +import { useMemo } from "react"; +import { useStoreWithEqualityFn } from "zustand/traditional"; +import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; +import { + composeWorkspaceStructure, + selectHasWorkspaces, + selectProjectOrder, + selectRecommendedProjectPaths, + selectResolveWorkspaceIdByCwd, + selectWorkspace, + selectWorkspaceExecutionAuthority, + selectWorkspaceFields, + selectWorkspaceKeys, + selectWorkspaceOrderByScopeForServer, + selectWorkspaceStatusesForBadges, + selectWorkspaceStructureProjects, + workspaceEqualityFns, + type WorkspaceStructure, +} from "./selectors"; +import { useSessionStore, type WorkspaceDescriptor } from "../session-store"; +import type { WorkspaceExecutionAuthorityResult } from "@/utils/workspace-execution"; +import type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state"; + +// These are the ONLY supported ways to read workspaces from the session store. +// Do not write raw `useSessionStore` selectors that return the workspaces Map, a session object, +// or the sessions dict — it breaks re-render isolation. + +export type { + DesktopBadgeWorkspaceStatus, + WorkspaceStructure, + WorkspaceStructureProject, +} from "./selectors"; + +export function useWorkspace( + serverId: string | null, + workspaceId: string | null, +): WorkspaceDescriptor | null { + return useStoreWithEqualityFn( + useSessionStore, + (state) => selectWorkspace(state, serverId, workspaceId), + workspaceEqualityFns.identity, + ); +} + +export function useWorkspaceFields( + serverId: string | null, + workspaceId: string | null, + project: (w: WorkspaceDescriptor) => T, +): T | null { + return useStoreWithEqualityFn( + useSessionStore, + (state) => selectWorkspaceFields(state, serverId, workspaceId, project), + workspaceEqualityFns.deep, + ); +} + +export function useWorkspaceExecutionAuthority( + serverId: string | null, + workspaceId: string | null, +): WorkspaceExecutionAuthorityResult | null { + return useStoreWithEqualityFn( + useSessionStore, + (state) => selectWorkspaceExecutionAuthority(state, serverId, workspaceId), + workspaceEqualityFns.deep, + ); +} + +export function useWorkspaceStructure(serverId: string | null): WorkspaceStructure { + const projects = useStoreWithEqualityFn( + useSessionStore, + (state) => selectWorkspaceStructureProjects(state, serverId), + workspaceEqualityFns.deep, + ); + const projectOrder = useStoreWithEqualityFn( + useSidebarOrderStore, + (state) => selectProjectOrder(state, serverId), + workspaceEqualityFns.deep, + ); + const workspaceOrderByScope = useStoreWithEqualityFn( + useSidebarOrderStore, + (state) => selectWorkspaceOrderByScopeForServer(state, serverId), + workspaceEqualityFns.deep, + ); + + return useMemo( + () => + composeWorkspaceStructure({ + serverId, + projects, + projectOrder, + workspaceOrderByScope, + }), + [projectOrder, projects, serverId, workspaceOrderByScope], + ); +} + +export function useWorkspaceKeys(serverId: string | null): string[] { + return useStoreWithEqualityFn( + useSessionStore, + (state) => selectWorkspaceKeys(state, serverId), + workspaceEqualityFns.deep, + ); +} + +export function useRecommendedProjectPaths(serverId: string | null): string[] { + return useStoreWithEqualityFn( + useSessionStore, + (state) => selectRecommendedProjectPaths(state, serverId), + workspaceEqualityFns.deep, + ); +} + +export function useHasWorkspaces(serverId: string | null): boolean { + return useStoreWithEqualityFn( + useSessionStore, + (state) => selectHasWorkspaces(state, serverId), + workspaceEqualityFns.identity, + ); +} + +export function useResolveWorkspaceIdByCwd( + serverId: string | null, + cwd: string | null | undefined, +): string | null { + return useStoreWithEqualityFn( + useSessionStore, + (state) => selectResolveWorkspaceIdByCwd(state, serverId, cwd), + workspaceEqualityFns.identity, + ); +} + +export function useWorkspaceStatusesForBadges(): DesktopBadgeWorkspaceStatus[] { + return useStoreWithEqualityFn( + useSessionStore, + (state) => selectWorkspaceStatusesForBadges(state), + workspaceEqualityFns.deep, + ); +} diff --git a/packages/app/src/stores/session-store-hooks/selectors.test.ts b/packages/app/src/stores/session-store-hooks/selectors.test.ts new file mode 100644 index 000000000..9f4012482 --- /dev/null +++ b/packages/app/src/stores/session-store-hooks/selectors.test.ts @@ -0,0 +1,423 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { + composeWorkspaceStructure, + selectHasWorkspaces, + selectProjectOrder, + selectRecommendedProjectPaths, + selectResolveWorkspaceIdByCwd, + selectWorkspace, + selectWorkspaceExecutionAuthority, + selectWorkspaceFields, + selectWorkspaceKeys, + selectWorkspaceOrderByScopeForServer, + selectWorkspaceStatusesForBadges, + selectWorkspaceStructureProjects, + workspaceEqualityFns, + type SidebarOrderSnapshot, +} from "./selectors"; +import { useSessionStore, type WorkspaceDescriptor } from "../session-store"; + +const SERVER_ID = "test-server"; + +function createWorkspace( + input: Partial & Pick, +): WorkspaceDescriptor { + return { + id: input.id, + projectId: input.projectId ?? "project-1", + projectDisplayName: input.projectDisplayName ?? "Project 1", + projectRootPath: input.projectRootPath ?? "/repo", + workspaceDirectory: input.workspaceDirectory ?? "/repo", + projectKind: input.projectKind ?? "git", + workspaceKind: input.workspaceKind ?? "local_checkout", + name: input.name ?? "main", + status: input.status ?? "done", + archivingAt: input.archivingAt ?? null, + diffStat: input.diffStat ?? null, + scripts: input.scripts ?? [], + }; +} + +function initializeWorkspaces(workspaces: WorkspaceDescriptor[]): void { + useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient); + useSessionStore + .getState() + .setWorkspaces(SERVER_ID, new Map(workspaces.map((workspace) => [workspace.id, workspace]))); +} + +interface Subscribable { + getState(): S; + subscribe(listener: (state: S, prev: S) => void): () => void; +} + +interface TrackedSelector { + readonly current: T; + stop(): void; +} + +// Mirrors what useStoreWithEqualityFn does: hold onto the previous selection and only +// publish a new value when the equality function rejects it. Lets tests assert reference +// stability without React or a DOM. +function trackSelector( + store: Subscribable, + select: (state: S) => T, + eq: (a: T, b: T) => boolean, +): TrackedSelector { + let current = select(store.getState()); + const stop = store.subscribe((state) => { + const candidate = select(state); + if (!eq(candidate, current)) { + current = candidate; + } + }); + return { + get current() { + return current; + }, + stop, + }; +} + +function emptySidebarOrder(): SidebarOrderSnapshot { + return { + projectOrderByServerId: {}, + workspaceOrderByServerAndProject: {}, + }; +} + +afterEach(() => { + useSessionStore.getState().clearSession(SERVER_ID); +}); + +describe("selectWorkspace", () => { + it("resolves a descriptor when the route id matches workspace identity but not the map key", () => { + const workspace = createWorkspace({ id: "workspace-a" }); + useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient); + useSessionStore.getState().setWorkspaces(SERVER_ID, new Map([["map-key-a", workspace]])); + + expect(selectWorkspace(useSessionStore.getState(), SERVER_ID, workspace.id)).toBe(workspace); + }); + + it("keeps the descriptor reference for unrelated workspace updates", () => { + const workspaceA = createWorkspace({ id: "workspace-a", name: "A" }); + const workspaceB = createWorkspace({ id: "workspace-b", name: "B" }); + initializeWorkspaces([workspaceA, workspaceB]); + + const tracked = trackSelector( + useSessionStore, + (state) => selectWorkspace(state, SERVER_ID, workspaceA.id), + workspaceEqualityFns.identity, + ); + const before = tracked.current; + + useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceB, status: "running" }]); + expect(tracked.current).toBe(before); + + useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceA, status: "attention" }]); + expect(tracked.current).not.toBe(before); + + tracked.stop(); + }); + + it("keeps the descriptor reference when the observed workspace is rewritten with content-equal data", () => { + const workspace = createWorkspace({ id: "workspace-a", scripts: [] }); + initializeWorkspaces([workspace]); + + const tracked = trackSelector( + useSessionStore, + (state) => selectWorkspace(state, SERVER_ID, workspace.id), + workspaceEqualityFns.identity, + ); + const before = tracked.current; + + useSessionStore + .getState() + .setWorkspaces(SERVER_ID, new Map([[workspace.id, { ...workspace, scripts: [] }]])); + expect(tracked.current).toBe(before); + + tracked.stop(); + }); +}); + +describe("selectWorkspaceExecutionAuthority", () => { + it("preserves the old missing-workspace message with the requested id", () => { + initializeWorkspaces([]); + + expect( + selectWorkspaceExecutionAuthority(useSessionStore.getState(), SERVER_ID, "missing-id"), + ).toMatchObject({ + ok: false, + message: "Workspace not found: missing-id", + }); + }); + + it("keeps deep-equal authority references under unrelated workspace updates", () => { + const workspaceA = createWorkspace({ id: "workspace-a", name: "A" }); + const workspaceB = createWorkspace({ id: "workspace-b", name: "B" }); + initializeWorkspaces([workspaceA, workspaceB]); + + const tracked = trackSelector( + useSessionStore, + (state) => selectWorkspaceExecutionAuthority(state, SERVER_ID, workspaceA.id), + workspaceEqualityFns.deep, + ); + const before = tracked.current; + + useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceB, status: "running" }]); + expect(tracked.current).toBe(before); + + tracked.stop(); + }); +}); + +describe("selectWorkspaceFields", () => { + it("keeps deep-equal projection references until projected fields change", () => { + const workspace = createWorkspace({ id: "workspace-a", name: "A", status: "done" }); + initializeWorkspaces([workspace]); + + const selectIdentity = (current: { id: string; name: string }) => ({ + identity: { id: current.id, name: current.name }, + }); + const tracked = trackSelector( + useSessionStore, + (state) => selectWorkspaceFields(state, SERVER_ID, workspace.id, selectIdentity), + workspaceEqualityFns.deep, + ); + const before = tracked.current; + + useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspace, status: "running" }]); + expect(tracked.current).toBe(before); + + useSessionStore + .getState() + .mergeWorkspaces(SERVER_ID, [{ ...workspace, name: "A renamed", status: "running" }]); + expect(tracked.current).not.toBe(before); + + tracked.stop(); + }); +}); + +describe("workspace structure composition", () => { + function snapshotStructure( + serverId: string, + sidebar: SidebarOrderSnapshot, + ): ReturnType { + return composeWorkspaceStructure({ + serverId, + projects: selectWorkspaceStructureProjects(useSessionStore.getState(), serverId), + projectOrder: selectProjectOrder(sidebar, serverId), + workspaceOrderByScope: selectWorkspaceOrderByScopeForServer(sidebar, serverId), + }); + } + + it("changes for membership updates but not status-only updates", () => { + const workspaceA = createWorkspace({ id: "workspace-a", name: "A" }); + const workspaceB = createWorkspace({ id: "workspace-b", name: "B" }); + initializeWorkspaces([workspaceA]); + + const tracked = trackSelector( + useSessionStore, + (state) => selectWorkspaceStructureProjects(state, SERVER_ID), + workspaceEqualityFns.deep, + ); + const before = tracked.current; + + useSessionStore.getState().mergeWorkspaces(SERVER_ID, [workspaceB]); + const afterAdd = tracked.current; + expect(afterAdd).not.toBe(before); + expect(afterAdd[0]?.workspaceKeys).toEqual(["workspace-a", "workspace-b"]); + + useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceA, status: "running" }]); + expect(tracked.current).toBe(afterAdd); + + tracked.stop(); + }); + + it("changes when a structure-relevant project identity field changes", () => { + const workspace = createWorkspace({ + id: "workspace-a", + projectDisplayName: "Project 1", + }); + initializeWorkspaces([workspace]); + + const tracked = trackSelector( + useSessionStore, + (state) => selectWorkspaceStructureProjects(state, SERVER_ID), + workspaceEqualityFns.deep, + ); + const before = tracked.current; + + useSessionStore + .getState() + .mergeWorkspaces(SERVER_ID, [{ ...workspace, projectDisplayName: "Project Renamed" }]); + expect(tracked.current).not.toBe(before); + + tracked.stop(); + }); + + it("changes the composed structure when persisted sidebar project order changes", () => { + const workspaceA = createWorkspace({ + id: "workspace-a", + projectId: "project-a", + projectDisplayName: "Project A", + }); + const workspaceB = createWorkspace({ + id: "workspace-b", + projectId: "project-b", + projectDisplayName: "Project B", + }); + initializeWorkspaces([workspaceA, workspaceB]); + + const before = snapshotStructure(SERVER_ID, emptySidebarOrder()); + const after = snapshotStructure(SERVER_ID, { + ...emptySidebarOrder(), + projectOrderByServerId: { [SERVER_ID]: ["project-b", "project-a"] }, + }); + + expect(after.projects.map((project) => project.projectKey)).toEqual(["project-b", "project-a"]); + expect(after).not.toEqual(before); + }); +}); + +describe("selectWorkspaceKeys", () => { + it("changes for reorder updates but not content-only updates", () => { + const workspaceA = createWorkspace({ id: "workspace-a", name: "A" }); + const workspaceB = createWorkspace({ id: "workspace-b", name: "B" }); + initializeWorkspaces([workspaceA, workspaceB]); + + const tracked = trackSelector( + useSessionStore, + (state) => selectWorkspaceKeys(state, SERVER_ID), + workspaceEqualityFns.deep, + ); + const before = tracked.current; + expect(before).toEqual(["workspace-a", "workspace-b"]); + + useSessionStore.getState().setWorkspaces( + SERVER_ID, + new Map([ + [workspaceB.id, workspaceB], + [workspaceA.id, workspaceA], + ]), + ); + const afterReorder = tracked.current; + expect(afterReorder).not.toBe(before); + expect(afterReorder).toEqual(["workspace-b", "workspace-a"]); + + useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceA, status: "running" }]); + expect(tracked.current).toBe(afterReorder); + + tracked.stop(); + }); +}); + +describe("selectRecommendedProjectPaths", () => { + it("updates when an existing workspace project root changes", () => { + const workspace = createWorkspace({ id: "workspace-a", projectRootPath: "/repo/a" }); + initializeWorkspaces([workspace]); + + useSessionStore + .getState() + .mergeWorkspaces(SERVER_ID, [{ ...workspace, projectRootPath: "/repo/b" }]); + + expect(selectRecommendedProjectPaths(useSessionStore.getState(), SERVER_ID)).toEqual([ + "/repo/b", + ]); + }); + + it("keeps the path list reference under unrelated workspace updates", () => { + const workspace = createWorkspace({ id: "workspace-a", projectRootPath: "/repo/a" }); + initializeWorkspaces([workspace]); + + const tracked = trackSelector( + useSessionStore, + (state) => selectRecommendedProjectPaths(state, SERVER_ID), + workspaceEqualityFns.deep, + ); + const before = tracked.current; + + useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspace, status: "running" }]); + expect(tracked.current).toBe(before); + + tracked.stop(); + }); +}); + +describe("selectHasWorkspaces", () => { + it("stays stable when workspace membership changes without flipping the boolean", () => { + const workspaceA = createWorkspace({ id: "workspace-a" }); + const workspaceB = createWorkspace({ id: "workspace-b" }); + initializeWorkspaces([workspaceA]); + + const tracked = trackSelector( + useSessionStore, + (state) => selectHasWorkspaces(state, SERVER_ID), + workspaceEqualityFns.identity, + ); + const before = tracked.current; + + useSessionStore.getState().mergeWorkspaces(SERVER_ID, [workspaceB]); + expect(tracked.current).toBe(before); + + tracked.stop(); + }); +}); + +describe("selectResolveWorkspaceIdByCwd", () => { + it("resolves by cwd and stays stable under unrelated updates", () => { + const workspaceA = createWorkspace({ + id: "workspace-a", + workspaceDirectory: "/repo/a", + }); + const workspaceB = createWorkspace({ + id: "workspace-b", + workspaceDirectory: "/repo/b", + }); + initializeWorkspaces([workspaceA, workspaceB]); + + const tracked = trackSelector( + useSessionStore, + (state) => selectResolveWorkspaceIdByCwd(state, SERVER_ID, "/repo/a"), + workspaceEqualityFns.identity, + ); + const before = tracked.current; + expect(before).toBe("workspace-a"); + + useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceB, status: "running" }]); + expect(tracked.current).toBe(before); + + tracked.stop(); + }); +}); + +describe("selectWorkspaceStatusesForBadges", () => { + it("tracks status changes without changing for no-ops or unrelated descriptor updates", () => { + const workspaceA = createWorkspace({ id: "workspace-a", status: "done" }); + const workspaceB = createWorkspace({ id: "workspace-b", status: "attention" }); + initializeWorkspaces([workspaceA, workspaceB]); + + const tracked = trackSelector( + useSessionStore, + (state) => selectWorkspaceStatusesForBadges(state), + workspaceEqualityFns.deep, + ); + const before = tracked.current; + expect(before).toEqual(["done", "attention"]); + + useSessionStore + .getState() + .mergeWorkspaces(SERVER_ID, [{ ...workspaceA, scripts: [...workspaceA.scripts] }]); + expect(tracked.current).toBe(before); + + useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceB, name: "Renamed" }]); + expect(tracked.current).toBe(before); + + useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceA, status: "failed" }]); + expect(tracked.current).not.toBe(before); + expect(tracked.current).toEqual(["failed", "attention"]); + + tracked.stop(); + }); +}); diff --git a/packages/app/src/stores/session-store-hooks.ts b/packages/app/src/stores/session-store-hooks/selectors.ts similarity index 50% rename from packages/app/src/stores/session-store-hooks.ts rename to packages/app/src/stores/session-store-hooks/selectors.ts index f6dd921aa..338e0aa3e 100644 --- a/packages/app/src/stores/session-store-hooks.ts +++ b/packages/app/src/stores/session-store-hooks/selectors.ts @@ -1,7 +1,4 @@ import equal from "fast-deep-equal"; -import { useMemo } from "react"; -import { useStoreWithEqualityFn } from "zustand/traditional"; -import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; import { projectDisplayNameFromProjectId } from "@/utils/project-display-name"; import type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state"; import { @@ -10,11 +7,7 @@ import { resolveWorkspaceMapKeyByIdentity, type WorkspaceExecutionAuthorityResult, } from "@/utils/workspace-execution"; -import { useSessionStore, type WorkspaceDescriptor } from "./session-store"; - -// These are the ONLY supported ways to read workspaces from the session store. -// Do not write raw `useSessionStore` selectors that return the workspaces Map, a session object, -// or the sessions dict — it breaks re-render isolation. +import type { WorkspaceDescriptor } from "../session-store"; export type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state"; @@ -30,11 +23,23 @@ export interface WorkspaceStructure { projects: WorkspaceStructureProject[]; } -type SessionStoreSnapshot = ReturnType; +export interface SessionsSnapshot { + sessions: Record }>; +} + +export interface SidebarOrderSnapshot { + projectOrderByServerId: Record; + workspaceOrderByServerAndProject: Record; +} const EMPTY_WORKSPACE_KEYS: string[] = []; const EMPTY_WORKSPACE_STRUCTURE: WorkspaceStructure = { projects: [] }; +export const workspaceEqualityFns = { + identity: Object.is as (a: unknown, b: unknown) => boolean, + deep: equal as (a: unknown, b: unknown) => boolean, +}; + function getWorkspaceOrderScopeKey(serverId: string, projectKey: string): string { return `${serverId.trim()}::${projectKey.trim()}`; } @@ -113,8 +118,8 @@ function applyStoredOrdering(input: { return ordered; } -function selectWorkspace( - state: SessionStoreSnapshot, +export function selectWorkspace( + state: SessionsSnapshot, serverId: string | null, workspaceId: string | null, ): WorkspaceDescriptor | null { @@ -129,53 +134,32 @@ function selectWorkspace( return workspaceKey ? (workspaces?.get(workspaceKey) ?? null) : null; } -export function useWorkspace( - serverId: string | null, - workspaceId: string | null, -): WorkspaceDescriptor | null { - return useStoreWithEqualityFn( - useSessionStore, - (state) => selectWorkspace(state, serverId, workspaceId), - Object.is, - ); -} - -export function useWorkspaceFields( +export function selectWorkspaceFields( + state: SessionsSnapshot, serverId: string | null, workspaceId: string | null, project: (w: WorkspaceDescriptor) => T, ): T | null { - return useStoreWithEqualityFn( - useSessionStore, - (state) => { - const workspace = selectWorkspace(state, serverId, workspaceId); - return workspace ? project(workspace) : null; - }, - equal, - ); + const workspace = selectWorkspace(state, serverId, workspaceId); + return workspace ? project(workspace) : null; } -export function useWorkspaceExecutionAuthority( +export function selectWorkspaceExecutionAuthority( + state: SessionsSnapshot, serverId: string | null, workspaceId: string | null, ): WorkspaceExecutionAuthorityResult | null { - return useStoreWithEqualityFn( - useSessionStore, - (state) => { - if (serverId === null || workspaceId === null) { - return null; - } - return getWorkspaceExecutionAuthority({ - workspaces: state.sessions[serverId]?.workspaces, - workspaceId, - }); - }, - equal, - ); + if (serverId === null || workspaceId === null) { + return null; + } + return getWorkspaceExecutionAuthority({ + workspaces: state.sessions[serverId]?.workspaces, + workspaceId, + }); } -function selectWorkspaceStructureProjects( - state: SessionStoreSnapshot, +export function selectWorkspaceStructureProjects( + state: SessionsSnapshot, serverId: string | null, ): WorkspaceStructureProject[] { if (!serverId) { @@ -231,8 +215,14 @@ function selectWorkspaceStructureProjects( return projects; } -function selectWorkspaceOrderByScopeForServer( - workspaceOrderByScope: Record, +export function selectProjectOrder(state: SidebarOrderSnapshot, serverId: string | null): string[] { + return serverId + ? (state.projectOrderByServerId[serverId] ?? EMPTY_WORKSPACE_KEYS) + : EMPTY_WORKSPACE_KEYS; +} + +export function selectWorkspaceOrderByScopeForServer( + state: SidebarOrderSnapshot, serverId: string | null, ): Record { if (!serverId) { @@ -240,7 +230,7 @@ function selectWorkspaceOrderByScopeForServer( } const prefix = `${serverId.trim()}::`; const relevantOrderByScope: Record = {}; - for (const [scopeKey, order] of Object.entries(workspaceOrderByScope)) { + for (const [scopeKey, order] of Object.entries(state.workspaceOrderByServerAndProject)) { if (scopeKey.startsWith(prefix)) { relevantOrderByScope[scopeKey] = order; } @@ -248,136 +238,96 @@ function selectWorkspaceOrderByScopeForServer( return relevantOrderByScope; } -export function useWorkspaceStructure(serverId: string | null): WorkspaceStructure { - const projects = useStoreWithEqualityFn( - useSessionStore, - (state) => selectWorkspaceStructureProjects(state, serverId), - equal, - ); - const projectOrder = useStoreWithEqualityFn( - useSidebarOrderStore, - (state) => - serverId - ? (state.projectOrderByServerId[serverId] ?? EMPTY_WORKSPACE_KEYS) - : EMPTY_WORKSPACE_KEYS, - equal, - ); - const workspaceOrderByScope = useStoreWithEqualityFn( - useSidebarOrderStore, - (state) => - selectWorkspaceOrderByScopeForServer(state.workspaceOrderByServerAndProject, serverId), - equal, - ); +export function composeWorkspaceStructure(input: { + serverId: string | null; + projects: WorkspaceStructureProject[]; + projectOrder: readonly string[]; + workspaceOrderByScope: Record; +}): WorkspaceStructure { + if (!input.serverId || input.projects.length === 0) { + return EMPTY_WORKSPACE_STRUCTURE; + } - return useMemo(() => { - if (!serverId || projects.length === 0) { - return EMPTY_WORKSPACE_STRUCTURE; - } + const orderedProjects = applyStoredOrdering({ + items: input.projects.map((project) => { + const workspaceOrder = + input.workspaceOrderByScope[ + getWorkspaceOrderScopeKey(input.serverId as string, project.projectKey) + ] ?? EMPTY_WORKSPACE_KEYS; + const workspaceItems = project.workspaceKeys.map((workspaceId) => ({ + workspaceId, + workspaceKey: `${input.serverId as string}:${workspaceId}`, + })); + return { + ...project, + workspaceKeys: applyStoredOrdering({ + items: workspaceItems, + storedOrder: workspaceOrder, + getKey: (workspace) => workspace.workspaceKey, + }).map((workspace) => workspace.workspaceId), + }; + }), + storedOrder: input.projectOrder, + getKey: (project) => project.projectKey, + }); - const orderedProjects = applyStoredOrdering({ - items: projects.map((project) => { - const workspaceOrder = - workspaceOrderByScope[getWorkspaceOrderScopeKey(serverId, project.projectKey)] ?? - EMPTY_WORKSPACE_KEYS; - const workspaceItems = project.workspaceKeys.map((workspaceId) => ({ - workspaceId, - workspaceKey: `${serverId}:${workspaceId}`, - })); - return { - ...project, - workspaceKeys: applyStoredOrdering({ - items: workspaceItems, - storedOrder: workspaceOrder, - getKey: (workspace) => workspace.workspaceKey, - }).map((workspace) => workspace.workspaceId), - }; - }), - storedOrder: projectOrder, - getKey: (project) => project.projectKey, - }); - - return { projects: orderedProjects }; - }, [projectOrder, projects, serverId, workspaceOrderByScope]); + return { projects: orderedProjects }; } -export function useWorkspaceKeys(serverId: string | null): string[] { - return useStoreWithEqualityFn( - useSessionStore, - (state) => { - if (!serverId) { - return EMPTY_WORKSPACE_KEYS; - } - const workspaces = state.sessions[serverId]?.workspaces; - return workspaces ? Array.from(workspaces.keys()) : EMPTY_WORKSPACE_KEYS; - }, - equal, - ); +export function selectWorkspaceKeys(state: SessionsSnapshot, serverId: string | null): string[] { + if (!serverId) { + return EMPTY_WORKSPACE_KEYS; + } + const workspaces = state.sessions[serverId]?.workspaces; + return workspaces ? Array.from(workspaces.keys()) : EMPTY_WORKSPACE_KEYS; } -export function useRecommendedProjectPaths(serverId: string | null): string[] { - return useStoreWithEqualityFn( - useSessionStore, - (state) => { - if (!serverId) { - return EMPTY_WORKSPACE_KEYS; - } - const workspaces = state.sessions[serverId]?.workspaces; - if (!workspaces) { - return EMPTY_WORKSPACE_KEYS; - } - return Array.from(workspaces.values()) - .map((workspace) => workspace.projectRootPath) - .filter((path) => path.length > 0); - }, - equal, - ); +export function selectRecommendedProjectPaths( + state: SessionsSnapshot, + serverId: string | null, +): string[] { + if (!serverId) { + return EMPTY_WORKSPACE_KEYS; + } + const workspaces = state.sessions[serverId]?.workspaces; + if (!workspaces) { + return EMPTY_WORKSPACE_KEYS; + } + return Array.from(workspaces.values()) + .map((workspace) => workspace.projectRootPath) + .filter((path) => path.length > 0); } -export function useHasWorkspaces(serverId: string | null): boolean { - return useStoreWithEqualityFn( - useSessionStore, - (state) => { - if (!serverId) { - return false; - } - return (state.sessions[serverId]?.workspaces?.size ?? 0) > 0; - }, - Object.is, - ); +export function selectHasWorkspaces(state: SessionsSnapshot, serverId: string | null): boolean { + if (!serverId) { + return false; + } + return (state.sessions[serverId]?.workspaces?.size ?? 0) > 0; } -export function useResolveWorkspaceIdByCwd( +export function selectResolveWorkspaceIdByCwd( + state: SessionsSnapshot, serverId: string | null, cwd: string | null | undefined, ): string | null { - return useStoreWithEqualityFn( - useSessionStore, - (state) => { - if (!serverId || !cwd) { - return null; - } - const workspaces = state.sessions[serverId]?.workspaces; - return resolveWorkspaceIdByExecutionDirectory({ - workspaces: workspaces?.values(), - workspaceDirectory: cwd, - }); - }, - Object.is, - ); + if (!serverId || !cwd) { + return null; + } + const workspaces = state.sessions[serverId]?.workspaces; + return resolveWorkspaceIdByExecutionDirectory({ + workspaces: workspaces?.values(), + workspaceDirectory: cwd, + }); } -export function useWorkspaceStatusesForBadges(): DesktopBadgeWorkspaceStatus[] { - return useStoreWithEqualityFn( - useSessionStore, - (state) => { - const statuses: DesktopBadgeWorkspaceStatus[] = []; - for (const session of Object.values(state.sessions)) { - for (const workspace of session.workspaces.values()) { - statuses.push(workspace.status); - } - } - return statuses; - }, - equal, - ); +export function selectWorkspaceStatusesForBadges( + state: SessionsSnapshot, +): DesktopBadgeWorkspaceStatus[] { + const statuses: DesktopBadgeWorkspaceStatus[] = []; + for (const session of Object.values(state.sessions)) { + for (const workspace of session.workspaces.values()) { + statuses.push(workspace.status); + } + } + return statuses; } diff --git a/packages/app/src/stores/sidebar-collapsed-sections-store.test.ts b/packages/app/src/stores/sidebar-collapsed-sections-store.test.ts deleted file mode 100644 index 7fca91bde..000000000 --- a/packages/app/src/stores/sidebar-collapsed-sections-store.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("@react-native-async-storage/async-storage", () => ({ - default: { - getItem: vi.fn(async () => null), - setItem: vi.fn(), - removeItem: vi.fn(), - }, -})); - -import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store"; - -describe("sidebar-collapsed-sections-store", () => { - beforeEach(() => { - useSidebarCollapsedSectionsStore.setState({ - collapsedProjectKeys: new Set(), - }); - }); - - it("tracks collapsed project keys as a Set", () => { - const store = useSidebarCollapsedSectionsStore.getState(); - - store.setProjectCollapsed("project-a", true); - store.toggleProjectCollapsed("project-b"); - store.toggleProjectCollapsed("project-a"); - - expect(Array.from(useSidebarCollapsedSectionsStore.getState().collapsedProjectKeys)).toEqual([ - "project-b", - ]); - }); - - it("serializes collapsed project keys for preference storage", () => { - useSidebarCollapsedSectionsStore.setState({ - collapsedProjectKeys: new Set(["project-a", "project-b"]), - }); - - const partialize = useSidebarCollapsedSectionsStore.persist.getOptions().partialize; - - expect(partialize?.(useSidebarCollapsedSectionsStore.getState())).toEqual({ - collapsedProjectKeys: ["project-a", "project-b"], - }); - }); - - it("restores collapsed project keys from persisted preferences", () => { - const merge = useSidebarCollapsedSectionsStore.persist.getOptions().merge; - const restored = merge?.( - { - collapsedProjectKeys: ["project-a", "project-b", 42], - }, - useSidebarCollapsedSectionsStore.getState(), - ); - - expect(Array.from(restored?.collapsedProjectKeys ?? [])).toEqual(["project-a", "project-b"]); - }); - - it("keeps the existing state object when persisted preferences do not change collapsed keys", () => { - const merge = useSidebarCollapsedSectionsStore.persist.getOptions().merge; - const currentState = useSidebarCollapsedSectionsStore.getState(); - - expect(merge?.({}, currentState)).toBe(currentState); - expect(merge?.({ collapsedProjectKeys: [] }, currentState)).toBe(currentState); - }); -}); diff --git a/packages/app/src/stores/sidebar-collapsed-sections-store.ts b/packages/app/src/stores/sidebar-collapsed-sections-store.ts deleted file mode 100644 index 43013074d..000000000 --- a/packages/app/src/stores/sidebar-collapsed-sections-store.ts +++ /dev/null @@ -1,91 +0,0 @@ -import AsyncStorage from "@react-native-async-storage/async-storage"; -import { create } from "zustand"; -import { createJSONStorage, persist } from "zustand/middleware"; - -interface SidebarCollapsedSectionsState { - collapsedProjectKeys: Set; - toggleProjectCollapsed: (projectKey: string) => void; - setProjectCollapsed: (projectKey: string, collapsed: boolean) => void; -} - -interface PersistedSidebarCollapsedSectionsState { - collapsedProjectKeys?: string[]; -} - -function serializeCollapsedProjectKeys(keys: Set): string[] { - return Array.from(keys); -} - -function deserializeCollapsedProjectKeys(value: unknown): Set { - if (!Array.isArray(value)) { - return new Set(); - } - - return new Set(value.filter((key): key is string => typeof key === "string")); -} - -function areSetsEqual(left: Set, right: Set): boolean { - if (left.size !== right.size) { - return false; - } - - for (const key of left) { - if (!right.has(key)) { - return false; - } - } - - return true; -} - -export const useSidebarCollapsedSectionsStore = create()( - persist( - (set) => ({ - collapsedProjectKeys: new Set(), - toggleProjectCollapsed: (projectKey) => - set((state) => { - const next = new Set(state.collapsedProjectKeys); - if (next.has(projectKey)) { - next.delete(projectKey); - } else { - next.add(projectKey); - } - return { collapsedProjectKeys: next }; - }), - setProjectCollapsed: (projectKey, collapsed) => - set((state) => { - const next = new Set(state.collapsedProjectKeys); - if (collapsed) { - next.add(projectKey); - } else { - next.delete(projectKey); - } - return { collapsedProjectKeys: next }; - }), - }), - { - name: "sidebar-collapsed-sections", - storage: createJSONStorage(() => AsyncStorage), - partialize: (state) => ({ - collapsedProjectKeys: serializeCollapsedProjectKeys(state.collapsedProjectKeys), - }), - merge: (persistedState, currentState) => { - const persisted = persistedState as PersistedSidebarCollapsedSectionsState | undefined; - if (!persisted?.collapsedProjectKeys) { - return currentState; - } - const collapsedProjectKeys = deserializeCollapsedProjectKeys( - persisted.collapsedProjectKeys, - ); - if (areSetsEqual(currentState.collapsedProjectKeys, collapsedProjectKeys)) { - return currentState; - } - - return { - ...currentState, - collapsedProjectKeys, - }; - }, - }, - ), -); diff --git a/packages/app/src/stores/sidebar-collapsed-sections-store/index.ts b/packages/app/src/stores/sidebar-collapsed-sections-store/index.ts new file mode 100644 index 000000000..ce56b7d6a --- /dev/null +++ b/packages/app/src/stores/sidebar-collapsed-sections-store/index.ts @@ -0,0 +1,37 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; +import { + type CollapsedProjectsState, + mergePersistedCollapsedProjects, + serializeCollapsedProjects, + setProjectCollapsed, + toggleProjectCollapsed, +} from "./state"; + +interface SidebarCollapsedSectionsState extends CollapsedProjectsState { + toggleProjectCollapsed: (projectKey: string) => void; + setProjectCollapsed: (projectKey: string, collapsed: boolean) => void; +} + +export const useSidebarCollapsedSectionsStore = create()( + persist( + (set) => ({ + collapsedProjectKeys: new Set(), + toggleProjectCollapsed: (projectKey) => + set((state) => toggleProjectCollapsed(state, projectKey)), + setProjectCollapsed: (projectKey, collapsed) => + set((state) => setProjectCollapsed(state, projectKey, collapsed)), + }), + { + name: "sidebar-collapsed-sections", + storage: createJSONStorage(() => AsyncStorage), + partialize: (state) => serializeCollapsedProjects(state), + merge: (persistedState, currentState) => + mergePersistedCollapsedProjects( + persistedState as { collapsedProjectKeys?: unknown } | undefined, + currentState, + ), + }, + ), +); diff --git a/packages/app/src/stores/sidebar-collapsed-sections-store/state.test.ts b/packages/app/src/stores/sidebar-collapsed-sections-store/state.test.ts new file mode 100644 index 000000000..f9ccb17e7 --- /dev/null +++ b/packages/app/src/stores/sidebar-collapsed-sections-store/state.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { + type CollapsedProjectsState, + mergePersistedCollapsedProjects, + serializeCollapsedProjects, + setProjectCollapsed, + toggleProjectCollapsed, +} from "@/stores/sidebar-collapsed-sections-store/state"; + +function emptyState(): CollapsedProjectsState { + return { collapsedProjectKeys: new Set() }; +} + +describe("sidebar collapsed projects transitions", () => { + it("tracks collapsed project keys as a Set", () => { + let state = emptyState(); + + state = setProjectCollapsed(state, "project-a", true); + state = toggleProjectCollapsed(state, "project-b"); + state = toggleProjectCollapsed(state, "project-a"); + + expect(Array.from(state.collapsedProjectKeys)).toEqual(["project-b"]); + }); + + it("serializes collapsed project keys for preference storage", () => { + const state: CollapsedProjectsState = { + collapsedProjectKeys: new Set(["project-a", "project-b"]), + }; + + expect(serializeCollapsedProjects(state)).toEqual({ + collapsedProjectKeys: ["project-a", "project-b"], + }); + }); + + it("restores collapsed project keys from persisted preferences", () => { + const restored = mergePersistedCollapsedProjects( + { collapsedProjectKeys: ["project-a", "project-b", 42] }, + emptyState(), + ); + + expect(Array.from(restored.collapsedProjectKeys)).toEqual(["project-a", "project-b"]); + }); + + it("keeps the existing state object when persisted preferences do not change collapsed keys", () => { + const currentState = emptyState(); + + expect(mergePersistedCollapsedProjects(undefined, currentState)).toBe(currentState); + expect(mergePersistedCollapsedProjects({}, currentState)).toBe(currentState); + expect(mergePersistedCollapsedProjects({ collapsedProjectKeys: [] }, currentState)).toBe( + currentState, + ); + }); +}); diff --git a/packages/app/src/stores/sidebar-collapsed-sections-store/state.ts b/packages/app/src/stores/sidebar-collapsed-sections-store/state.ts new file mode 100644 index 000000000..1807e068b --- /dev/null +++ b/packages/app/src/stores/sidebar-collapsed-sections-store/state.ts @@ -0,0 +1,73 @@ +export interface CollapsedProjectsState { + collapsedProjectKeys: Set; +} + +export interface PersistedCollapsedProjects { + collapsedProjectKeys?: unknown; +} + +export function toggleProjectCollapsed( + state: CollapsedProjectsState, + projectKey: string, +): CollapsedProjectsState { + const next = new Set(state.collapsedProjectKeys); + if (next.has(projectKey)) { + next.delete(projectKey); + } else { + next.add(projectKey); + } + return { collapsedProjectKeys: next }; +} + +export function setProjectCollapsed( + state: CollapsedProjectsState, + projectKey: string, + collapsed: boolean, +): CollapsedProjectsState { + const next = new Set(state.collapsedProjectKeys); + if (collapsed) { + next.add(projectKey); + } else { + next.delete(projectKey); + } + return { collapsedProjectKeys: next }; +} + +export function serializeCollapsedProjects(state: CollapsedProjectsState): { + collapsedProjectKeys: string[]; +} { + return { collapsedProjectKeys: Array.from(state.collapsedProjectKeys) }; +} + +export function mergePersistedCollapsedProjects( + persisted: PersistedCollapsedProjects | undefined, + current: S, +): S { + if (!persisted?.collapsedProjectKeys) { + return current; + } + const restored = deserializeCollapsedProjectKeys(persisted.collapsedProjectKeys); + if (areSetsEqual(current.collapsedProjectKeys, restored)) { + return current; + } + return { ...current, collapsedProjectKeys: restored }; +} + +function deserializeCollapsedProjectKeys(value: unknown): Set { + if (!Array.isArray(value)) { + return new Set(); + } + return new Set(value.filter((key): key is string => typeof key === "string")); +} + +function areSetsEqual(left: Set, right: Set): boolean { + if (left.size !== right.size) { + return false; + } + for (const key of left) { + if (!right.has(key)) { + return false; + } + } + return true; +} diff --git a/packages/app/src/stores/workspace-subagents-integration.test.ts b/packages/app/src/stores/workspace-subagents-integration.test.ts index 132886783..4219b5203 100644 --- a/packages/app/src/stores/workspace-subagents-integration.test.ts +++ b/packages/app/src/stores/workspace-subagents-integration.test.ts @@ -9,62 +9,6 @@ import { selectSubagentsForParent } from "@/subagents"; import { buildWorkspaceTabPersistenceKey, useWorkspaceLayoutStore } from "./workspace-layout-store"; import { useSessionStore, type Agent } from "./session-store"; -vi.mock("lucide-react-native", () => ({ - Archive: () => null, - Check: () => null, - ChevronDown: () => null, - ChevronRight: () => null, -})); - -vi.mock("react-native-unistyles", () => ({ - StyleSheet: { - create: (factory: unknown) => - typeof factory === "function" - ? factory({ - spacing: { 0: 0, 1: 4, 2: 8, 3: 12, 4: 16 }, - borderWidth: { 1: 1 }, - borderRadius: { sm: 4, md: 6, lg: 8, "2xl": 16, full: 999 }, - fontSize: { xs: 11, sm: 13, base: 15 }, - fontWeight: { normal: "400", medium: "500" }, - iconSize: { sm: 14, md: 18 }, - colors: { - foreground: "#fff", - foregroundMuted: "#aaa", - surface0: "#000", - surface1: "#111", - surface2: "#222", - surface3: "#333", - border: "#444", - borderAccent: "#555", - accent: "#0a84ff", - palette: { - amber: { 500: "#ffbf00", 700: "#aa8000" }, - blue: { 500: "#0a84ff" }, - red: { 500: "#ff453a" }, - green: { 500: "#30d158" }, - }, - }, - }) - : factory, - }, - useUnistyles: () => ({ theme: { colors: {} } }), - withUnistyles: (component: T) => component, -})); - -vi.mock("@/components/provider-icons", () => ({ - getProviderIcon: () => null, -})); - -vi.mock("@/components/ui/tooltip", () => ({ - Tooltip: ({ children }: { children: unknown }) => children, - TooltipTrigger: ({ children }: { children: unknown }) => children, - TooltipContent: () => null, -})); - -vi.mock("@/screens/workspace/workspace-tab-presentation", () => ({ - WorkspaceTabIcon: () => null, -})); - vi.mock("@react-native-async-storage/async-storage", () => { const storage = new Map(); return { diff --git a/packages/app/src/stores/workspace-tabs-store.test.ts b/packages/app/src/stores/workspace-tabs-store.test.ts deleted file mode 100644 index b9e0e7c3a..000000000 --- a/packages/app/src/stores/workspace-tabs-store.test.ts +++ /dev/null @@ -1,353 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("@react-native-async-storage/async-storage", () => { - const storage = new Map(); - return { - default: { - getItem: vi.fn(async (key: string) => storage.get(key) ?? null), - setItem: vi.fn(async (key: string, value: string) => { - storage.set(key, value); - }), - removeItem: vi.fn(async (key: string) => { - storage.delete(key); - }), - }, - }; -}); - -import { - buildWorkspaceTabPersistenceKey, - useWorkspaceTabsStore, -} from "@/stores/workspace-tabs-store"; - -const SERVER_ID = "server-1"; -const WORKSPACE_ID = "/repo/worktree"; - -describe("buildWorkspaceTabPersistenceKey", () => { - it("preserves opaque workspace ids instead of normalizing them like paths", () => { - expect( - buildWorkspaceTabPersistenceKey({ - serverId: SERVER_ID, - workspaceId: " setup\\workspace\\ ", - }), - ).toBe("server-1:setup\\workspace\\"); - }); -}); - -describe("workspace-tabs-store retargetTab", () => { - beforeEach(() => { - useWorkspaceTabsStore.setState({ - uiTabsByWorkspace: {}, - tabOrderByWorkspace: {}, - focusedTabIdByWorkspace: {}, - }); - }); - - it("keeps a promoted draft tab in-place by mutating target without changing tab id", () => { - const draftTabId = "draft_123"; - const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID }); - expect(key).toBeTruthy(); - const workspaceKey = key as string; - - useWorkspaceTabsStore.getState().ensureTab({ - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { kind: "agent", agentId: "left" }, - }); - useWorkspaceTabsStore.getState().openDraftTab({ - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - draftId: draftTabId, - }); - useWorkspaceTabsStore.getState().ensureTab({ - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { kind: "agent", agentId: "right" }, - }); - useWorkspaceTabsStore.getState().focusTab({ - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - tabId: draftTabId, - }); - - const before = useWorkspaceTabsStore.getState(); - const beforeOrder = before.tabOrderByWorkspace[workspaceKey] ?? []; - - const retargeted = useWorkspaceTabsStore.getState().retargetTab({ - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - tabId: draftTabId, - target: { kind: "agent", agentId: "created" }, - }); - - const after = useWorkspaceTabsStore.getState(); - const afterOrder = after.tabOrderByWorkspace[workspaceKey] ?? []; - const tabs = after.uiTabsByWorkspace[workspaceKey] ?? []; - const retargetedTab = tabs.find((tab) => tab.tabId === draftTabId) ?? null; - - expect(retargeted).toBe(draftTabId); - expect(afterOrder).toEqual(beforeOrder); - expect(after.focusedTabIdByWorkspace[workspaceKey]).toBe(draftTabId); - expect(retargetedTab?.target).toEqual({ kind: "agent", agentId: "created" }); - }); - - it("ensureTab adds non-focused membership while openOrFocusTab focuses", () => { - const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID }); - expect(key).toBeTruthy(); - const workspaceKey = key as string; - - const terminalTabId = useWorkspaceTabsStore.getState().ensureTab({ - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { kind: "terminal", terminalId: "term-1" }, - }); - expect(terminalTabId).toBe("terminal_term-1"); - expect(useWorkspaceTabsStore.getState().focusedTabIdByWorkspace[workspaceKey]).toBeUndefined(); - - const focusedTabId = useWorkspaceTabsStore.getState().openOrFocusTab({ - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { kind: "terminal", terminalId: "term-1" }, - }); - expect(focusedTabId).toBe("terminal_term-1"); - expect(useWorkspaceTabsStore.getState().focusedTabIdByWorkspace[workspaceKey]).toBe( - "terminal_term-1", - ); - }); - - it("ensureTab deduplicates by target when a retargeted tab already exists", () => { - const draftTabId = "draft_x"; - const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID }); - expect(key).toBeTruthy(); - const workspaceKey = key as string; - - useWorkspaceTabsStore.getState().openDraftTab({ - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - draftId: draftTabId, - }); - useWorkspaceTabsStore.getState().retargetTab({ - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - tabId: draftTabId, - target: { kind: "agent", agentId: "created-agent" }, - }); - - const ensured = useWorkspaceTabsStore.getState().ensureTab({ - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { kind: "agent", agentId: "created-agent" }, - }); - - const state = useWorkspaceTabsStore.getState(); - const tabs = state.uiTabsByWorkspace[workspaceKey] ?? []; - const order = state.tabOrderByWorkspace[workspaceKey] ?? []; - const matchingTabs = tabs.filter( - (tab) => tab.target.kind === "agent" && tab.target.agentId === "created-agent", - ); - - expect(ensured).toBe(draftTabId); - expect(matchingTabs).toHaveLength(1); - expect(order).toEqual([draftTabId]); - }); - - it("openDraftTab creates a draft tab and deduplicates by draftId", () => { - const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID }); - expect(key).toBeTruthy(); - const workspaceKey = key as string; - - const firstTabId = useWorkspaceTabsStore.getState().openDraftTab({ - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - draftId: "draft-1", - }); - const secondTabId = useWorkspaceTabsStore.getState().openDraftTab({ - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - draftId: "draft-2", - }); - - const state = useWorkspaceTabsStore.getState(); - expect(firstTabId).toBe("draft-1"); - expect(secondTabId).toBe("draft-2"); - expect(state.tabOrderByWorkspace[workspaceKey]).toEqual([firstTabId, secondTabId]); - expect(state.uiTabsByWorkspace[workspaceKey]).toEqual([ - { - tabId: "draft-1", - target: { kind: "draft", draftId: "draft-1" }, - createdAt: expect.any(Number), - }, - { - tabId: "draft-2", - target: { kind: "draft", draftId: "draft-2" }, - createdAt: expect.any(Number), - }, - ]); - }); - - it("keeps draft setup on a retargeted tab", () => { - const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID }); - expect(key).toBeTruthy(); - const workspaceKey = key as string; - - const tabId = useWorkspaceTabsStore.getState().ensureTab({ - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { kind: "agent", agentId: "agent-1" }, - }); - if (!tabId) { - throw new Error("Expected tab id"); - } - expect(tabId).toBe("agent_agent-1"); - - useWorkspaceTabsStore.getState().retargetTab({ - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - tabId, - target: { - kind: "draft", - draftId: "draft-replacement", - setup: { - provider: "mock", - cwd: "/repo/worktree", - modeId: "load-test", - model: "ten-second-stream", - thinkingOptionId: null, - featureValues: { effort: "high" }, - }, - }, - }); - - expect(useWorkspaceTabsStore.getState().uiTabsByWorkspace[workspaceKey]?.[0]?.target).toEqual({ - kind: "draft", - draftId: "draft-replacement", - setup: { - provider: "mock", - cwd: "/repo/worktree", - modeId: "load-test", - model: "ten-second-stream", - thinkingOptionId: null, - featureValues: { effort: "high" }, - }, - }); - }); - - it("updates an existing draft tab when the setup changes", () => { - const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID }); - expect(key).toBeTruthy(); - const workspaceKey = key as string; - - const first = useWorkspaceTabsStore.getState().ensureTab({ - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { kind: "draft", draftId: "draft-1" }, - }); - const second = useWorkspaceTabsStore.getState().ensureTab({ - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { - kind: "draft", - draftId: "draft-1", - setup: { - provider: "mock", - cwd: "/repo/worktree", - modeId: "load-test", - model: "ten-second-stream", - thinkingOptionId: null, - featureValues: {}, - }, - }, - }); - - expect(second).toBe(first); - expect(useWorkspaceTabsStore.getState().uiTabsByWorkspace[workspaceKey]).toHaveLength(1); - expect(useWorkspaceTabsStore.getState().uiTabsByWorkspace[workspaceKey]?.[0]?.target).toEqual({ - kind: "draft", - draftId: "draft-1", - setup: { - provider: "mock", - cwd: "/repo/worktree", - modeId: "load-test", - model: "ten-second-stream", - thinkingOptionId: null, - featureValues: {}, - }, - }); - }); - - it("retargeting a background draft keeps the currently focused tab focused", () => { - const draftTabId = "draft_background"; - const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID }); - expect(key).toBeTruthy(); - const workspaceKey = key as string; - - useWorkspaceTabsStore.getState().openDraftTab({ - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - draftId: draftTabId, - }); - const focusedFileTabId = useWorkspaceTabsStore.getState().openOrFocusTab({ - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { kind: "file", path: "/repo/worktree/src/index.ts" }, - }); - - useWorkspaceTabsStore.getState().retargetTab({ - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - tabId: draftTabId, - target: { kind: "agent", agentId: "created-agent" }, - }); - - expect(useWorkspaceTabsStore.getState().focusedTabIdByWorkspace[workspaceKey]).toBe( - focusedFileTabId, - ); - }); - - it("openOrFocusTab re-focuses an existing file tab after the workspace focus changed", () => { - const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID }); - expect(key).toBeTruthy(); - const workspaceKey = key as string; - - const fileTabId = useWorkspaceTabsStore.getState().openOrFocusTab({ - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { kind: "file", path: "/repo/worktree/src/index.ts" }, - }); - const terminalTabId = useWorkspaceTabsStore.getState().openOrFocusTab({ - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { kind: "terminal", terminalId: "term-1" }, - }); - - expect(fileTabId).toBe("file_/repo/worktree/src/index.ts"); - expect(terminalTabId).toBe("terminal_term-1"); - expect(useWorkspaceTabsStore.getState().focusedTabIdByWorkspace[workspaceKey]).toBe( - terminalTabId, - ); - - const reopenedFileTabId = useWorkspaceTabsStore.getState().openOrFocusTab({ - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { kind: "file", path: "/repo/worktree/src/index.ts" }, - }); - - expect(reopenedFileTabId).toBe(fileTabId); - expect(useWorkspaceTabsStore.getState().focusedTabIdByWorkspace[workspaceKey]).toBe(fileTabId); - }); - - it("builds a deterministic setup tab keyed by workspace id", () => { - const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID }); - expect(key).toBeTruthy(); - const workspaceKey = key as string; - - const tabId = useWorkspaceTabsStore.getState().openOrFocusTab({ - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { kind: "setup", workspaceId: WORKSPACE_ID }, - }); - - expect(tabId).toBe(`setup_${WORKSPACE_ID}`); - expect(useWorkspaceTabsStore.getState().focusedTabIdByWorkspace[workspaceKey]).toBe(tabId); - }); -}); diff --git a/packages/app/src/stores/workspace-tabs-store.ts b/packages/app/src/stores/workspace-tabs-store.ts deleted file mode 100644 index ca0b12ca9..000000000 --- a/packages/app/src/stores/workspace-tabs-store.ts +++ /dev/null @@ -1,696 +0,0 @@ -import AsyncStorage from "@react-native-async-storage/async-storage"; -import type { AgentProvider } from "@getpaseo/protocol/agent-types"; -import { create } from "zustand"; -import { createJSONStorage, persist } from "zustand/middleware"; -import { - buildDeterministicWorkspaceTabId, - normalizeWorkspaceDraftTabSetup, - normalizeWorkspaceTabTarget, - workspaceTabTargetsEqual, -} from "@/workspace-tabs/identity"; -import type { WorkspaceFileTabTarget } from "@/workspace/file-open"; - -export interface WorkspaceDraftTabSetup { - provider: AgentProvider; - cwd: string; - modeId: string | null; - model: string | null; - thinkingOptionId: string | null; - featureValues: Record; -} - -export type WorkspaceTabTarget = - | { kind: "draft"; draftId: string; setup?: WorkspaceDraftTabSetup } - | { kind: "agent"; agentId: string } - | { kind: "terminal"; terminalId: string } - | { kind: "browser"; browserId: string } - | WorkspaceFileTabTarget - | { kind: "setup"; workspaceId: string }; - -export interface WorkspaceTab { - tabId: string; - target: WorkspaceTabTarget; - createdAt: number; -} - -function trimNonEmpty(value: string | null | undefined): string | null { - if (typeof value !== "string") { - return null; - } - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -export function buildWorkspaceTabPersistenceKey(input: { - serverId: string; - workspaceId: string; -}): string | null { - const serverId = trimNonEmpty(input.serverId); - const workspaceId = trimNonEmpty(input.workspaceId); - if (!serverId || !workspaceId) { - return null; - } - return `${serverId}:${workspaceId}`; -} - -function isPlainRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - -function toObjectRecord(value: unknown): Record | undefined { - return isPlainRecord(value) ? value : undefined; -} - -function normalizeTabOrder(list: unknown): string[] { - if (!Array.isArray(list)) { - return []; - } - const next: string[] = []; - const used = new Set(); - for (const value of list) { - const tabId = trimNonEmpty(typeof value === "string" ? value : null); - if (!tabId || used.has(tabId)) { - continue; - } - used.add(tabId); - next.push(tabId); - } - return next; -} - -function ensureInOrder(input: { current: string[]; tabId: string }): string[] { - if (input.current.includes(input.tabId)) { - return input.current; - } - return [...input.current, input.tabId]; -} - -function retargetTabAtIndex( - tab: WorkspaceTab, - index: number, - targetIndex: number, - normalizedTarget: WorkspaceTabTarget, -): WorkspaceTab { - return index === targetIndex ? { ...tab, target: normalizedTarget } : tab; -} - -function buildNextTabsForEnsure(args: { - currentTabs: WorkspaceTab[]; - existingIndex: number; - effectiveTabId: string; - normalizedTarget: WorkspaceTabTarget; - createdAt: number; -}): WorkspaceTab[] { - const { currentTabs, existingIndex, effectiveTabId, normalizedTarget, createdAt } = args; - if (existingIndex < 0) { - return [...currentTabs, { tabId: effectiveTabId, target: normalizedTarget, createdAt }]; - } - const existing = currentTabs[existingIndex]; - if (existing && workspaceTabTargetsEqual(existing.target, normalizedTarget)) { - return currentTabs; - } - return currentTabs.map((tab, index) => - retargetTabAtIndex(tab, index, existingIndex, normalizedTarget), - ); -} - -interface MigrationRawSources { - rawUiTabsByWorkspace: Record; - rawFocused: Record; - rawOrder: Record; - legacyOrder: Record; -} - -function extractMigrationRawSources(persistedState: unknown): MigrationRawSources { - const top = toObjectRecord(persistedState) ?? {}; - const rawState = toObjectRecord(top.state) ?? top; - - return { - rawUiTabsByWorkspace: - toObjectRecord( - rawState.uiTabsByWorkspace ?? - rawState.openTabsByWorkspace ?? - top.uiTabsByWorkspace ?? - top.openTabsByWorkspace, - ) ?? {}, - rawFocused: - toObjectRecord( - rawState.focusedTabIdByWorkspace ?? - rawState.lastFocusedTabByWorkspace ?? - top.focusedTabIdByWorkspace, - ) ?? {}, - rawOrder: toObjectRecord(rawState.tabOrderByWorkspace ?? top.tabOrderByWorkspace) ?? {}, - legacyOrder: - toObjectRecord( - rawState.tabOrderByWorkspace ?? - rawState.tabOrderLegacyByWorkspace ?? - top.tabOrderLegacyByWorkspace, - ) ?? {}, - }; -} - -function coerceWorkspaceTabTarget(raw: Record): WorkspaceTabTarget | null { - const kind = typeof raw.kind === "string" ? raw.kind : null; - if (kind === "draft" && typeof raw.draftId === "string") { - const setup = normalizeWorkspaceDraftTabSetup(raw.setup); - return normalizeWorkspaceTabTarget({ - kind: "draft", - draftId: raw.draftId, - ...(setup ? { setup } : {}), - }); - } - if (kind === "agent" && typeof raw.agentId === "string") { - return normalizeWorkspaceTabTarget({ kind: "agent", agentId: raw.agentId }); - } - if (kind === "terminal" && typeof raw.terminalId === "string") { - return normalizeWorkspaceTabTarget({ kind: "terminal", terminalId: raw.terminalId }); - } - if (kind === "browser" && typeof raw.browserId === "string") { - return normalizeWorkspaceTabTarget({ kind: "browser", browserId: raw.browserId }); - } - if (kind === "file" && typeof raw.path === "string") { - return normalizeWorkspaceTabTarget({ - kind: "file", - path: raw.path, - lineStart: typeof raw.lineStart === "number" ? raw.lineStart : undefined, - lineEnd: typeof raw.lineEnd === "number" ? raw.lineEnd : undefined, - }); - } - if (kind === "setup" && typeof raw.workspaceId === "string") { - return normalizeWorkspaceTabTarget({ kind: "setup", workspaceId: raw.workspaceId }); - } - return null; -} - -function migrateSingleTab(rawTab: unknown): WorkspaceTab | null { - const record = toObjectRecord(rawTab); - if (!record) { - return null; - } - const rawTarget = toObjectRecord(record.target); - const normalizedTarget = rawTarget ? coerceWorkspaceTabTarget(rawTarget) : null; - if (!normalizedTarget) { - return null; - } - const rawTabId = trimNonEmpty(typeof record.tabId === "string" ? record.tabId : null); - const tabId = rawTabId ?? buildDeterministicWorkspaceTabId(normalizedTarget); - const rawCreatedAt = record.createdAt; - return { - tabId, - target: normalizedTarget, - createdAt: typeof rawCreatedAt === "number" ? rawCreatedAt : Date.now(), - }; -} - -interface MigratedTabsForKey { - nextUiTabs: WorkspaceTab[]; - orderFromTabs: string[]; -} - -function migrateUiTabsForKey(rawEntries: unknown): MigratedTabsForKey { - const entries = Array.isArray(rawEntries) ? rawEntries : []; - const nextUiTabs: WorkspaceTab[] = []; - const orderFromTabs: string[] = []; - const usedOrder = new Set(); - - for (const rawTab of entries) { - const migrated = migrateSingleTab(rawTab); - if (!migrated) { - continue; - } - if (!usedOrder.has(migrated.tabId)) { - usedOrder.add(migrated.tabId); - orderFromTabs.push(migrated.tabId); - } - nextUiTabs.push(migrated); - } - - return { nextUiTabs, orderFromTabs }; -} - -function mergeExplicitTabOrder( - tabOrderByWorkspace: Record, - rawOrder: Record, -): void { - for (const key in rawOrder) { - const normalizedOrder = normalizeTabOrder(rawOrder[key]); - if (normalizedOrder.length === 0) { - continue; - } - const existing = tabOrderByWorkspace[key] ?? []; - tabOrderByWorkspace[key] = normalizeTabOrder([...existing, ...normalizedOrder]); - } -} - -function convertLegacyOrderEntry(entry: unknown): string | null { - const raw = typeof entry === "string" ? entry.trim() : ""; - if (!raw) { - return null; - } - if (raw.startsWith("agent:")) { - const agentId = raw.slice("agent:".length).trim(); - return agentId ? `agent_${agentId}` : null; - } - if (raw.startsWith("terminal:")) { - const terminalId = raw.slice("terminal:".length).trim(); - return terminalId ? `terminal_${terminalId}` : null; - } - return null; -} - -function normalizeLegacyOrderList(list: unknown[]): string[] { - const result: string[] = []; - for (const entry of list) { - const converted = convertLegacyOrderEntry(entry); - if (converted) { - result.push(converted); - } - } - return result; -} - -function mergeLegacyTabOrder( - tabOrderByWorkspace: Record, - legacyOrder: Record, -): void { - for (const key in legacyOrder) { - const list = legacyOrder[key]; - if (!Array.isArray(list) || list.length === 0) { - continue; - } - const normalizedLegacyOrder = normalizeLegacyOrderList(list); - if (normalizedLegacyOrder.length === 0) { - continue; - } - const existing = tabOrderByWorkspace[key] ?? []; - tabOrderByWorkspace[key] = normalizeTabOrder([...existing, ...normalizedLegacyOrder]); - } -} - -function resolveFocusedTabId(rawValue: unknown): string | null { - if (typeof rawValue === "string") { - return trimNonEmpty(rawValue); - } - if (!rawValue || typeof rawValue !== "object") { - return null; - } - const value = rawValue as { - kind?: string; - agentId?: string; - terminalId?: string; - draftId?: string; - }; - if (value.kind === "agent" && typeof value.agentId === "string" && value.agentId.trim()) { - return `agent_${value.agentId.trim()}`; - } - if ( - value.kind === "terminal" && - typeof value.terminalId === "string" && - value.terminalId.trim() - ) { - return `terminal_${value.terminalId.trim()}`; - } - if (value.kind === "draft" && typeof value.draftId === "string" && value.draftId.trim()) { - return value.draftId.trim(); - } - return null; -} - -function migrateFocusedTabIds( - focusedTabIdByWorkspace: Record, - rawFocused: Record, -): void { - for (const key in rawFocused) { - const resolved = resolveFocusedTabId(rawFocused[key]); - if (resolved) { - focusedTabIdByWorkspace[key] = resolved; - } - } -} - -function migrateWorkspaceTabsState(persistedState: unknown): { - uiTabsByWorkspace: Record; - tabOrderByWorkspace: Record; - focusedTabIdByWorkspace: Record; -} { - const { rawUiTabsByWorkspace, rawFocused, rawOrder, legacyOrder } = - extractMigrationRawSources(persistedState); - - const uiTabsByWorkspace: Record = {}; - const tabOrderByWorkspace: Record = {}; - const focusedTabIdByWorkspace: Record = {}; - - for (const key in rawUiTabsByWorkspace) { - const { nextUiTabs, orderFromTabs } = migrateUiTabsForKey(rawUiTabsByWorkspace[key]); - if (nextUiTabs.length > 0) { - uiTabsByWorkspace[key] = nextUiTabs; - } - if (orderFromTabs.length > 0) { - tabOrderByWorkspace[key] = orderFromTabs; - } - } - - mergeExplicitTabOrder(tabOrderByWorkspace, rawOrder); - mergeLegacyTabOrder(tabOrderByWorkspace, legacyOrder); - migrateFocusedTabIds(focusedTabIdByWorkspace, rawFocused); - - return { - uiTabsByWorkspace, - tabOrderByWorkspace, - focusedTabIdByWorkspace, - }; -} - -interface WorkspaceTabsState { - uiTabsByWorkspace: Record; - tabOrderByWorkspace: Record; - focusedTabIdByWorkspace: Record; - openDraftTab: (input: { - serverId: string; - workspaceId: string; - draftId: string; - }) => string | null; - ensureTab: (input: { - serverId: string; - workspaceId: string; - target: WorkspaceTabTarget; - }) => string | null; - openOrFocusTab: (input: { - serverId: string; - workspaceId: string; - target: WorkspaceTabTarget; - }) => string | null; - focusTab: (input: { serverId: string; workspaceId: string; tabId: string }) => void; - closeTab: (input: { serverId: string; workspaceId: string; tabId: string }) => void; - retargetTab: (input: { - serverId: string; - workspaceId: string; - tabId: string; - target: WorkspaceTabTarget; - }) => string | null; - reorderTabs: (input: { serverId: string; workspaceId: string; tabIds: string[] }) => void; - getWorkspaceTabs: (input: { serverId: string; workspaceId: string }) => WorkspaceTab[]; - purgeWorkspace: (input: { serverId: string; workspaceId: string }) => void; -} - -export const useWorkspaceTabsStore = create()( - persist( - (set, get) => ({ - uiTabsByWorkspace: {}, - tabOrderByWorkspace: {}, - focusedTabIdByWorkspace: {}, - openDraftTab: ({ serverId, workspaceId, draftId }) => { - const normalizedDraftId = trimNonEmpty(draftId); - if (!normalizedDraftId) { - return null; - } - return get().openOrFocusTab({ - serverId, - workspaceId, - target: { kind: "draft", draftId: normalizedDraftId }, - }); - }, - ensureTab: ({ serverId, workspaceId, target }) => { - const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); - const normalizedTarget = normalizeWorkspaceTabTarget(target); - if (!key || !normalizedTarget) { - return null; - } - - const deterministicTabId = buildDeterministicWorkspaceTabId(normalizedTarget); - let resolvedTabId = deterministicTabId; - const now = Date.now(); - - set((state) => { - const currentTabs = state.uiTabsByWorkspace[key] ?? []; - const tabWithSameTarget = - currentTabs.find((tab) => workspaceTabTargetsEqual(tab.target, normalizedTarget)) ?? - null; - const effectiveTabId = tabWithSameTarget?.tabId ?? deterministicTabId; - resolvedTabId = effectiveTabId; - - const currentOrder = state.tabOrderByWorkspace[key] ?? []; - const nextOrder = ensureInOrder({ current: currentOrder, tabId: effectiveTabId }); - const existingIndex = currentTabs.findIndex((tab) => tab.tabId === effectiveTabId); - const nextTabs = buildNextTabsForEnsure({ - currentTabs, - existingIndex, - effectiveTabId, - normalizedTarget, - createdAt: now, - }); - - return { - uiTabsByWorkspace: - nextTabs === currentTabs - ? state.uiTabsByWorkspace - : { ...state.uiTabsByWorkspace, [key]: nextTabs }, - tabOrderByWorkspace: - nextOrder === currentOrder - ? state.tabOrderByWorkspace - : { ...state.tabOrderByWorkspace, [key]: nextOrder }, - }; - }); - - return resolvedTabId; - }, - openOrFocusTab: ({ serverId, workspaceId, target }) => { - const tabId = get().ensureTab({ serverId, workspaceId, target }); - if (!tabId) { - return null; - } - get().focusTab({ serverId, workspaceId, tabId }); - return tabId; - }, - focusTab: ({ serverId, workspaceId, tabId }) => { - const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); - const normalizedTabId = trimNonEmpty(tabId); - if (!key || !normalizedTabId) { - return; - } - set((state) => { - if (state.focusedTabIdByWorkspace[key] === normalizedTabId) { - return state; - } - return { - ...state, - focusedTabIdByWorkspace: { - ...state.focusedTabIdByWorkspace, - [key]: normalizedTabId, - }, - }; - }); - }, - closeTab: ({ serverId, workspaceId, tabId }) => { - const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); - const normalizedTabId = trimNonEmpty(tabId); - if (!key || !normalizedTabId) { - return; - } - - set((state) => { - const currentTabs = state.uiTabsByWorkspace[key] ?? []; - const nextTabs = currentTabs.filter((tab) => tab.tabId !== normalizedTabId); - const currentOrder = state.tabOrderByWorkspace[key] ?? []; - const nextOrder = currentOrder.filter((value) => value !== normalizedTabId); - - let nextUiTabsByWorkspace: typeof state.uiTabsByWorkspace; - if (nextTabs.length === 0) { - const { [key]: _removed, ...rest } = state.uiTabsByWorkspace; - nextUiTabsByWorkspace = rest; - } else if (nextTabs.length === currentTabs.length) { - nextUiTabsByWorkspace = state.uiTabsByWorkspace; - } else { - nextUiTabsByWorkspace = { ...state.uiTabsByWorkspace, [key]: nextTabs }; - } - - let nextTabOrderByWorkspace: typeof state.tabOrderByWorkspace; - if (nextOrder.length === 0) { - const { [key]: _removed, ...rest } = state.tabOrderByWorkspace; - nextTabOrderByWorkspace = rest; - } else if (nextOrder.length === currentOrder.length) { - nextTabOrderByWorkspace = state.tabOrderByWorkspace; - } else { - nextTabOrderByWorkspace = { ...state.tabOrderByWorkspace, [key]: nextOrder }; - } - - const currentFocused = state.focusedTabIdByWorkspace[key] ?? null; - const nextFocused = - currentFocused !== normalizedTabId - ? currentFocused - : (nextOrder[nextOrder.length - 1] ?? null); - const nextFocusedByWorkspace = (() => { - if (!nextFocused) { - const { [key]: _removed, ...rest } = state.focusedTabIdByWorkspace; - return rest; - } - return { ...state.focusedTabIdByWorkspace, [key]: nextFocused }; - })(); - - const tabsChanged = nextTabs.length !== currentTabs.length; - const orderChanged = nextOrder.length !== currentOrder.length; - const focusChanged = - (state.focusedTabIdByWorkspace[key] ?? null) !== (nextFocusedByWorkspace[key] ?? null); - - if (!tabsChanged && !orderChanged && !focusChanged) { - return state; - } - - return { - uiTabsByWorkspace: nextUiTabsByWorkspace, - tabOrderByWorkspace: nextTabOrderByWorkspace, - focusedTabIdByWorkspace: nextFocusedByWorkspace, - }; - }); - }, - retargetTab: ({ serverId, workspaceId, tabId, target }) => { - const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); - const normalizedTabId = trimNonEmpty(tabId); - const normalizedTarget = normalizeWorkspaceTabTarget(target); - if (!key || !normalizedTabId || !normalizedTarget) { - return null; - } - - let retargetedTabId: string | null = null; - - set((state) => { - const currentTabs = state.uiTabsByWorkspace[key] ?? []; - const index = currentTabs.findIndex((tab) => tab.tabId === normalizedTabId); - if (index < 0) { - return state; - } - - const currentTarget = currentTabs[index]?.target; - if (currentTarget && workspaceTabTargetsEqual(currentTarget, normalizedTarget)) { - return state; - } - - const nextTabs = currentTabs.map((tab, tabIndex) => - tabIndex === index ? Object.assign({}, tab, { target: normalizedTarget }) : tab, - ); - retargetedTabId = normalizedTabId; - return { - uiTabsByWorkspace: { ...state.uiTabsByWorkspace, [key]: nextTabs }, - tabOrderByWorkspace: state.tabOrderByWorkspace, - focusedTabIdByWorkspace: state.focusedTabIdByWorkspace, - }; - }); - - return retargetedTabId; - }, - reorderTabs: ({ serverId, workspaceId, tabIds }) => { - const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); - if (!key) { - return; - } - - const normalized = normalizeTabOrder(tabIds); - set((state) => { - const current = state.tabOrderByWorkspace[key] ?? []; - if (current.length === normalized.length) { - let same = true; - for (let i = 0; i < current.length; i += 1) { - if (current[i] !== normalized[i]) { - same = false; - break; - } - } - if (same) { - return state; - } - } - - return { - ...state, - tabOrderByWorkspace: { - ...state.tabOrderByWorkspace, - [key]: normalized, - }, - }; - }); - }, - getWorkspaceTabs: ({ serverId, workspaceId }) => { - const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); - if (!key) { - return []; - } - return get().uiTabsByWorkspace[key] ?? []; - }, - purgeWorkspace: ({ serverId, workspaceId }) => { - const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); - if (!key) { - return; - } - set((state) => { - if ( - !(key in state.uiTabsByWorkspace) && - !(key in state.tabOrderByWorkspace) && - !(key in state.focusedTabIdByWorkspace) - ) { - return state; - } - const { [key]: _tabs, ...remainingUiTabsByWorkspace } = state.uiTabsByWorkspace; - const { [key]: _order, ...remainingTabOrderByWorkspace } = state.tabOrderByWorkspace; - const { [key]: _focused, ...remainingFocusedTabIdByWorkspace } = - state.focusedTabIdByWorkspace; - return { - ...state, - uiTabsByWorkspace: remainingUiTabsByWorkspace, - tabOrderByWorkspace: remainingTabOrderByWorkspace, - focusedTabIdByWorkspace: remainingFocusedTabIdByWorkspace, - }; - }); - }, - }), - { - name: "workspace-tabs-state", - version: 5, - storage: createJSONStorage(() => AsyncStorage), - partialize: (state) => { - const nextUiTabsByWorkspace: Record = {}; - for (const key in state.uiTabsByWorkspace) { - const tabs = (state.uiTabsByWorkspace[key] ?? []) - .map((tab) => { - const normalizedTarget = normalizeWorkspaceTabTarget(tab.target); - const normalizedTabId = trimNonEmpty(tab.tabId); - if (!normalizedTarget || !normalizedTabId) { - return null; - } - return { - tabId: normalizedTabId, - target: normalizedTarget, - createdAt: typeof tab.createdAt === "number" ? tab.createdAt : Date.now(), - } satisfies WorkspaceTab; - }) - .filter((tab): tab is WorkspaceTab => tab !== null); - if (tabs.length > 0) { - nextUiTabsByWorkspace[key] = tabs; - } - } - - const nextTabOrderByWorkspace: Record = {}; - for (const key in state.tabOrderByWorkspace) { - const order = normalizeTabOrder(state.tabOrderByWorkspace[key]); - if (order.length > 0) { - nextTabOrderByWorkspace[key] = order; - } - } - - const nextFocusedTabIdByWorkspace: Record = {}; - for (const key in state.focusedTabIdByWorkspace) { - const focusedTabId = trimNonEmpty(state.focusedTabIdByWorkspace[key]); - if (focusedTabId) { - nextFocusedTabIdByWorkspace[key] = focusedTabId; - } - } - - return { - uiTabsByWorkspace: nextUiTabsByWorkspace, - tabOrderByWorkspace: nextTabOrderByWorkspace, - focusedTabIdByWorkspace: nextFocusedTabIdByWorkspace, - }; - }, - migrate: (persistedState) => migrateWorkspaceTabsState(persistedState), - }, - ), -); diff --git a/packages/app/src/stores/workspace-tabs-store/index.ts b/packages/app/src/stores/workspace-tabs-store/index.ts new file mode 100644 index 000000000..013f3fca4 --- /dev/null +++ b/packages/app/src/stores/workspace-tabs-store/index.ts @@ -0,0 +1,124 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; +import { + applyCloseTab, + applyEnsureTab, + applyFocusTab, + applyOpenDraftTab, + applyOpenOrFocusTab, + applyPurgeWorkspace, + applyReorderTabs, + applyRetargetTab, + initialWorkspaceTabsCoreState, + migrateWorkspaceTabsState, + partializeWorkspaceTabsState, + selectWorkspaceTabs, + type WorkspaceTab, + type WorkspaceTabsCoreState, + type WorkspaceTabTarget, +} from "./state"; + +export { buildWorkspaceTabPersistenceKey } from "./state"; +export type { WorkspaceDraftTabSetup, WorkspaceTab, WorkspaceTabTarget } from "./state"; + +interface WorkspaceTabsState extends WorkspaceTabsCoreState { + openDraftTab: (input: { + serverId: string; + workspaceId: string; + draftId: string; + }) => string | null; + ensureTab: (input: { + serverId: string; + workspaceId: string; + target: WorkspaceTabTarget; + }) => string | null; + openOrFocusTab: (input: { + serverId: string; + workspaceId: string; + target: WorkspaceTabTarget; + }) => string | null; + focusTab: (input: { serverId: string; workspaceId: string; tabId: string }) => void; + closeTab: (input: { serverId: string; workspaceId: string; tabId: string }) => void; + retargetTab: (input: { + serverId: string; + workspaceId: string; + tabId: string; + target: WorkspaceTabTarget; + }) => string | null; + reorderTabs: (input: { serverId: string; workspaceId: string; tabIds: string[] }) => void; + getWorkspaceTabs: (input: { serverId: string; workspaceId: string }) => WorkspaceTab[]; + purgeWorkspace: (input: { serverId: string; workspaceId: string }) => void; +} + +export const useWorkspaceTabsStore = create()( + persist( + (set, get) => ({ + ...initialWorkspaceTabsCoreState, + openDraftTab: ({ serverId, workspaceId, draftId }) => { + let resolved: string | null = null; + set((state) => { + const result = applyOpenDraftTab(state, { + serverId, + workspaceId, + draftId, + now: Date.now(), + }); + resolved = result.tabId; + return result.state; + }); + return resolved; + }, + ensureTab: ({ serverId, workspaceId, target }) => { + let resolved: string | null = null; + set((state) => { + const result = applyEnsureTab(state, { + serverId, + workspaceId, + target, + now: Date.now(), + }); + resolved = result.tabId; + return result.state; + }); + return resolved; + }, + openOrFocusTab: ({ serverId, workspaceId, target }) => { + let resolved: string | null = null; + set((state) => { + const result = applyOpenOrFocusTab(state, { + serverId, + workspaceId, + target, + now: Date.now(), + }); + resolved = result.tabId; + return result.state; + }); + return resolved; + }, + focusTab: (input) => set((state) => applyFocusTab(state, input)), + closeTab: (input) => set((state) => applyCloseTab(state, input)), + retargetTab: ({ serverId, workspaceId, tabId, target }) => { + let resolved: string | null = null; + set((state) => { + const result = applyRetargetTab(state, { serverId, workspaceId, tabId, target }); + resolved = result.tabId; + return result.state; + }); + return resolved; + }, + reorderTabs: (input) => set((state) => applyReorderTabs(state, input)), + getWorkspaceTabs: (input) => selectWorkspaceTabs(get(), input), + purgeWorkspace: (input) => set((state) => applyPurgeWorkspace(state, input)), + }), + { + name: "workspace-tabs-state", + version: 5, + storage: createJSONStorage(() => AsyncStorage), + partialize: (state) => partializeWorkspaceTabsState(state, { now: Date.now() }), + migrate: (persistedState) => + migrateWorkspaceTabsState(persistedState, { now: Date.now() }) as WorkspaceTabsState, + }, + ), +); diff --git a/packages/app/src/stores/workspace-tabs-store/state.test.ts b/packages/app/src/stores/workspace-tabs-store/state.test.ts new file mode 100644 index 000000000..68b2ef6f5 --- /dev/null +++ b/packages/app/src/stores/workspace-tabs-store/state.test.ts @@ -0,0 +1,364 @@ +import { describe, expect, it } from "vitest"; +import { + applyCloseTab, + applyEnsureTab, + applyFocusTab, + applyOpenDraftTab, + applyOpenOrFocusTab, + applyRetargetTab, + buildWorkspaceTabPersistenceKey, + initialWorkspaceTabsCoreState, + type WorkspaceTabsCoreState, +} from "./state"; + +const SERVER_ID = "server-1"; +const WORKSPACE_ID = "/repo/worktree"; +const WORKSPACE_KEY = `${SERVER_ID}:${WORKSPACE_ID}`; + +const NOW = 1_700_000_000_000; + +function emptyState(): WorkspaceTabsCoreState { + return { + uiTabsByWorkspace: {}, + tabOrderByWorkspace: {}, + focusedTabIdByWorkspace: {}, + }; +} + +describe("buildWorkspaceTabPersistenceKey", () => { + it("preserves opaque workspace ids instead of normalizing them like paths", () => { + expect( + buildWorkspaceTabPersistenceKey({ + serverId: SERVER_ID, + workspaceId: " setup\\workspace\\ ", + }), + ).toBe("server-1:setup\\workspace\\"); + }); +}); + +describe("workspace-tabs-store reducers", () => { + it("keeps a promoted draft tab in-place by mutating target without changing tab id", () => { + const draftTabId = "draft_123"; + + let state = emptyState(); + state = applyEnsureTab(state, { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + target: { kind: "agent", agentId: "left" }, + now: NOW, + }).state; + state = applyOpenDraftTab(state, { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + draftId: draftTabId, + now: NOW, + }).state; + state = applyEnsureTab(state, { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + target: { kind: "agent", agentId: "right" }, + now: NOW, + }).state; + state = applyFocusTab(state, { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + tabId: draftTabId, + }); + + const beforeOrder = state.tabOrderByWorkspace[WORKSPACE_KEY] ?? []; + + const retargeted = applyRetargetTab(state, { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + tabId: draftTabId, + target: { kind: "agent", agentId: "created" }, + }); + + const afterOrder = retargeted.state.tabOrderByWorkspace[WORKSPACE_KEY] ?? []; + const tabs = retargeted.state.uiTabsByWorkspace[WORKSPACE_KEY] ?? []; + const retargetedTab = tabs.find((tab) => tab.tabId === draftTabId) ?? null; + + expect(retargeted.tabId).toBe(draftTabId); + expect(afterOrder).toEqual(beforeOrder); + expect(retargeted.state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe(draftTabId); + expect(retargetedTab?.target).toEqual({ kind: "agent", agentId: "created" }); + }); + + it("ensureTab adds non-focused membership while openOrFocusTab focuses", () => { + let state = emptyState(); + const ensured = applyEnsureTab(state, { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + target: { kind: "terminal", terminalId: "term-1" }, + now: NOW, + }); + state = ensured.state; + expect(ensured.tabId).toBe("terminal_term-1"); + expect(state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBeUndefined(); + + const focused = applyOpenOrFocusTab(state, { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + target: { kind: "terminal", terminalId: "term-1" }, + now: NOW, + }); + expect(focused.tabId).toBe("terminal_term-1"); + expect(focused.state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe("terminal_term-1"); + }); + + it("ensureTab deduplicates by target when a retargeted tab already exists", () => { + const draftTabId = "draft_x"; + + let state = emptyState(); + state = applyOpenDraftTab(state, { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + draftId: draftTabId, + now: NOW, + }).state; + state = applyRetargetTab(state, { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + tabId: draftTabId, + target: { kind: "agent", agentId: "created-agent" }, + }).state; + + const ensured = applyEnsureTab(state, { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + target: { kind: "agent", agentId: "created-agent" }, + now: NOW, + }); + + const tabs = ensured.state.uiTabsByWorkspace[WORKSPACE_KEY] ?? []; + const order = ensured.state.tabOrderByWorkspace[WORKSPACE_KEY] ?? []; + const matchingTabs = tabs.filter( + (tab) => tab.target.kind === "agent" && tab.target.agentId === "created-agent", + ); + + expect(ensured.tabId).toBe(draftTabId); + expect(matchingTabs).toHaveLength(1); + expect(order).toEqual([draftTabId]); + }); + + it("openDraftTab creates a draft tab and deduplicates by draftId", () => { + let state = emptyState(); + const first = applyOpenDraftTab(state, { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + draftId: "draft-1", + now: NOW, + }); + state = first.state; + const second = applyOpenDraftTab(state, { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + draftId: "draft-2", + now: NOW, + }); + state = second.state; + + expect(first.tabId).toBe("draft-1"); + expect(second.tabId).toBe("draft-2"); + expect(state.tabOrderByWorkspace[WORKSPACE_KEY]).toEqual([first.tabId, second.tabId]); + expect(state.uiTabsByWorkspace[WORKSPACE_KEY]).toEqual([ + { + tabId: "draft-1", + target: { kind: "draft", draftId: "draft-1" }, + createdAt: NOW, + }, + { + tabId: "draft-2", + target: { kind: "draft", draftId: "draft-2" }, + createdAt: NOW, + }, + ]); + }); + + it("keeps draft setup on a retargeted tab", () => { + let state = emptyState(); + const ensured = applyEnsureTab(state, { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + target: { kind: "agent", agentId: "agent-1" }, + now: NOW, + }); + expect(ensured.tabId).toBe("agent_agent-1"); + state = ensured.state; + + const retargeted = applyRetargetTab(state, { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + tabId: ensured.tabId!, + target: { + kind: "draft", + draftId: "draft-replacement", + setup: { + provider: "mock", + cwd: "/repo/worktree", + modeId: "load-test", + model: "ten-second-stream", + thinkingOptionId: null, + featureValues: { effort: "high" }, + }, + }, + }); + + expect(retargeted.state.uiTabsByWorkspace[WORKSPACE_KEY]?.[0]?.target).toEqual({ + kind: "draft", + draftId: "draft-replacement", + setup: { + provider: "mock", + cwd: "/repo/worktree", + modeId: "load-test", + model: "ten-second-stream", + thinkingOptionId: null, + featureValues: { effort: "high" }, + }, + }); + }); + + it("updates an existing draft tab when the setup changes", () => { + let state = emptyState(); + const first = applyEnsureTab(state, { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + target: { kind: "draft", draftId: "draft-1" }, + now: NOW, + }); + state = first.state; + const second = applyEnsureTab(state, { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + target: { + kind: "draft", + draftId: "draft-1", + setup: { + provider: "mock", + cwd: "/repo/worktree", + modeId: "load-test", + model: "ten-second-stream", + thinkingOptionId: null, + featureValues: {}, + }, + }, + now: NOW, + }); + state = second.state; + + expect(second.tabId).toBe(first.tabId); + expect(state.uiTabsByWorkspace[WORKSPACE_KEY]).toHaveLength(1); + expect(state.uiTabsByWorkspace[WORKSPACE_KEY]?.[0]?.target).toEqual({ + kind: "draft", + draftId: "draft-1", + setup: { + provider: "mock", + cwd: "/repo/worktree", + modeId: "load-test", + model: "ten-second-stream", + thinkingOptionId: null, + featureValues: {}, + }, + }); + }); + + it("retargeting a background draft keeps the currently focused tab focused", () => { + const draftTabId = "draft_background"; + + let state = emptyState(); + state = applyOpenDraftTab(state, { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + draftId: draftTabId, + now: NOW, + }).state; + const file = applyOpenOrFocusTab(state, { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + target: { kind: "file", path: "/repo/worktree/src/index.ts" }, + now: NOW, + }); + state = file.state; + + state = applyRetargetTab(state, { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + tabId: draftTabId, + target: { kind: "agent", agentId: "created-agent" }, + }).state; + + expect(state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe(file.tabId); + }); + + it("openOrFocusTab re-focuses an existing file tab after the workspace focus changed", () => { + let state = emptyState(); + const fileResult = applyOpenOrFocusTab(state, { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + target: { kind: "file", path: "/repo/worktree/src/index.ts" }, + now: NOW, + }); + state = fileResult.state; + const terminalResult = applyOpenOrFocusTab(state, { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + target: { kind: "terminal", terminalId: "term-1" }, + now: NOW, + }); + state = terminalResult.state; + + expect(fileResult.tabId).toBe("file_/repo/worktree/src/index.ts"); + expect(terminalResult.tabId).toBe("terminal_term-1"); + expect(state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe(terminalResult.tabId); + + const reopened = applyOpenOrFocusTab(state, { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + target: { kind: "file", path: "/repo/worktree/src/index.ts" }, + now: NOW, + }); + + expect(reopened.tabId).toBe(fileResult.tabId); + expect(reopened.state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe(fileResult.tabId); + }); + + it("builds a deterministic setup tab keyed by workspace id", () => { + const result = applyOpenOrFocusTab(initialWorkspaceTabsCoreState, { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + target: { kind: "setup", workspaceId: WORKSPACE_ID }, + now: NOW, + }); + + expect(result.tabId).toBe(`setup_${WORKSPACE_ID}`); + expect(result.state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe(result.tabId); + }); + + it("closeTab focuses the most-recent remaining tab when the focused tab is removed", () => { + let state = emptyState(); + const first = applyOpenOrFocusTab(state, { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + target: { kind: "agent", agentId: "left" }, + now: NOW, + }); + state = first.state; + const second = applyOpenOrFocusTab(state, { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + target: { kind: "agent", agentId: "right" }, + now: NOW, + }); + state = second.state; + expect(state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe(second.tabId); + + state = applyCloseTab(state, { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + tabId: second.tabId!, + }); + + expect(state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe(first.tabId); + expect(state.uiTabsByWorkspace[WORKSPACE_KEY]).toHaveLength(1); + }); +}); diff --git a/packages/app/src/stores/workspace-tabs-store/state.ts b/packages/app/src/stores/workspace-tabs-store/state.ts new file mode 100644 index 000000000..409e17150 --- /dev/null +++ b/packages/app/src/stores/workspace-tabs-store/state.ts @@ -0,0 +1,757 @@ +import type { AgentProvider } from "@getpaseo/protocol/agent-types"; +import { + buildDeterministicWorkspaceTabId, + normalizeWorkspaceDraftTabSetup, + normalizeWorkspaceTabTarget, + workspaceTabTargetsEqual, +} from "@/workspace-tabs/identity"; +import type { WorkspaceFileTabTarget } from "@/workspace/file-open"; + +export interface WorkspaceDraftTabSetup { + provider: AgentProvider; + cwd: string; + modeId: string | null; + model: string | null; + thinkingOptionId: string | null; + featureValues: Record; +} + +export type WorkspaceTabTarget = + | { kind: "draft"; draftId: string; setup?: WorkspaceDraftTabSetup } + | { kind: "agent"; agentId: string } + | { kind: "terminal"; terminalId: string } + | { kind: "browser"; browserId: string } + | WorkspaceFileTabTarget + | { kind: "setup"; workspaceId: string }; + +export interface WorkspaceTab { + tabId: string; + target: WorkspaceTabTarget; + createdAt: number; +} + +export interface WorkspaceTabsCoreState { + uiTabsByWorkspace: Record; + tabOrderByWorkspace: Record; + focusedTabIdByWorkspace: Record; +} + +export const initialWorkspaceTabsCoreState: WorkspaceTabsCoreState = { + uiTabsByWorkspace: {}, + tabOrderByWorkspace: {}, + focusedTabIdByWorkspace: {}, +}; + +function trimNonEmpty(value: string | null | undefined): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +export function buildWorkspaceTabPersistenceKey(input: { + serverId: string; + workspaceId: string; +}): string | null { + const serverId = trimNonEmpty(input.serverId); + const workspaceId = trimNonEmpty(input.workspaceId); + if (!serverId || !workspaceId) { + return null; + } + return `${serverId}:${workspaceId}`; +} + +function isPlainRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function toObjectRecord(value: unknown): Record | undefined { + return isPlainRecord(value) ? value : undefined; +} + +function normalizeTabOrder(list: unknown): string[] { + if (!Array.isArray(list)) { + return []; + } + const next: string[] = []; + const used = new Set(); + for (const value of list) { + const tabId = trimNonEmpty(typeof value === "string" ? value : null); + if (!tabId || used.has(tabId)) { + continue; + } + used.add(tabId); + next.push(tabId); + } + return next; +} + +function ensureInOrder(input: { current: string[]; tabId: string }): string[] { + if (input.current.includes(input.tabId)) { + return input.current; + } + return [...input.current, input.tabId]; +} + +function retargetTabAtIndex( + tab: WorkspaceTab, + index: number, + targetIndex: number, + normalizedTarget: WorkspaceTabTarget, +): WorkspaceTab { + return index === targetIndex ? { ...tab, target: normalizedTarget } : tab; +} + +function buildNextTabsForEnsure(args: { + currentTabs: WorkspaceTab[]; + existingIndex: number; + effectiveTabId: string; + normalizedTarget: WorkspaceTabTarget; + createdAt: number; +}): WorkspaceTab[] { + const { currentTabs, existingIndex, effectiveTabId, normalizedTarget, createdAt } = args; + if (existingIndex < 0) { + return [...currentTabs, { tabId: effectiveTabId, target: normalizedTarget, createdAt }]; + } + const existing = currentTabs[existingIndex]; + if (existing && workspaceTabTargetsEqual(existing.target, normalizedTarget)) { + return currentTabs; + } + return currentTabs.map((tab, index) => + retargetTabAtIndex(tab, index, existingIndex, normalizedTarget), + ); +} + +export interface EnsureTabInput { + serverId: string; + workspaceId: string; + target: WorkspaceTabTarget; + now: number; +} + +export interface EnsureTabResult { + state: WorkspaceTabsCoreState; + tabId: string | null; +} + +export function applyEnsureTab( + state: WorkspaceTabsCoreState, + input: EnsureTabInput, +): EnsureTabResult { + const key = buildWorkspaceTabPersistenceKey({ + serverId: input.serverId, + workspaceId: input.workspaceId, + }); + const normalizedTarget = normalizeWorkspaceTabTarget(input.target); + if (!key || !normalizedTarget) { + return { state, tabId: null }; + } + + const deterministicTabId = buildDeterministicWorkspaceTabId(normalizedTarget); + const currentTabs = state.uiTabsByWorkspace[key] ?? []; + const tabWithSameTarget = + currentTabs.find((tab) => workspaceTabTargetsEqual(tab.target, normalizedTarget)) ?? null; + const effectiveTabId = tabWithSameTarget?.tabId ?? deterministicTabId; + + const currentOrder = state.tabOrderByWorkspace[key] ?? []; + const nextOrder = ensureInOrder({ current: currentOrder, tabId: effectiveTabId }); + const existingIndex = currentTabs.findIndex((tab) => tab.tabId === effectiveTabId); + const nextTabs = buildNextTabsForEnsure({ + currentTabs, + existingIndex, + effectiveTabId, + normalizedTarget, + createdAt: input.now, + }); + + const uiTabsByWorkspace = + nextTabs === currentTabs + ? state.uiTabsByWorkspace + : { ...state.uiTabsByWorkspace, [key]: nextTabs }; + const tabOrderByWorkspace = + nextOrder === currentOrder + ? state.tabOrderByWorkspace + : { ...state.tabOrderByWorkspace, [key]: nextOrder }; + + if ( + uiTabsByWorkspace === state.uiTabsByWorkspace && + tabOrderByWorkspace === state.tabOrderByWorkspace + ) { + return { state, tabId: effectiveTabId }; + } + + return { + state: { ...state, uiTabsByWorkspace, tabOrderByWorkspace }, + tabId: effectiveTabId, + }; +} + +export interface FocusTabInput { + serverId: string; + workspaceId: string; + tabId: string; +} + +export function applyFocusTab( + state: WorkspaceTabsCoreState, + input: FocusTabInput, +): WorkspaceTabsCoreState { + const key = buildWorkspaceTabPersistenceKey({ + serverId: input.serverId, + workspaceId: input.workspaceId, + }); + const normalizedTabId = trimNonEmpty(input.tabId); + if (!key || !normalizedTabId) { + return state; + } + if (state.focusedTabIdByWorkspace[key] === normalizedTabId) { + return state; + } + return { + ...state, + focusedTabIdByWorkspace: { + ...state.focusedTabIdByWorkspace, + [key]: normalizedTabId, + }, + }; +} + +export function applyOpenOrFocusTab( + state: WorkspaceTabsCoreState, + input: EnsureTabInput, +): EnsureTabResult { + const ensured = applyEnsureTab(state, input); + if (!ensured.tabId) { + return ensured; + } + const focused = applyFocusTab(ensured.state, { + serverId: input.serverId, + workspaceId: input.workspaceId, + tabId: ensured.tabId, + }); + return { state: focused, tabId: ensured.tabId }; +} + +export interface OpenDraftTabInput { + serverId: string; + workspaceId: string; + draftId: string; + now: number; +} + +export function applyOpenDraftTab( + state: WorkspaceTabsCoreState, + input: OpenDraftTabInput, +): EnsureTabResult { + const normalizedDraftId = trimNonEmpty(input.draftId); + if (!normalizedDraftId) { + return { state, tabId: null }; + } + return applyOpenOrFocusTab(state, { + serverId: input.serverId, + workspaceId: input.workspaceId, + target: { kind: "draft", draftId: normalizedDraftId }, + now: input.now, + }); +} + +export interface CloseTabInput { + serverId: string; + workspaceId: string; + tabId: string; +} + +export function applyCloseTab( + state: WorkspaceTabsCoreState, + input: CloseTabInput, +): WorkspaceTabsCoreState { + const key = buildWorkspaceTabPersistenceKey({ + serverId: input.serverId, + workspaceId: input.workspaceId, + }); + const normalizedTabId = trimNonEmpty(input.tabId); + if (!key || !normalizedTabId) { + return state; + } + + const currentTabs = state.uiTabsByWorkspace[key] ?? []; + const nextTabs = currentTabs.filter((tab) => tab.tabId !== normalizedTabId); + const currentOrder = state.tabOrderByWorkspace[key] ?? []; + const nextOrder = currentOrder.filter((value) => value !== normalizedTabId); + + let nextUiTabsByWorkspace: Record; + if (nextTabs.length === 0) { + const { [key]: _removed, ...rest } = state.uiTabsByWorkspace; + nextUiTabsByWorkspace = rest; + } else if (nextTabs.length === currentTabs.length) { + nextUiTabsByWorkspace = state.uiTabsByWorkspace; + } else { + nextUiTabsByWorkspace = { ...state.uiTabsByWorkspace, [key]: nextTabs }; + } + + let nextTabOrderByWorkspace: Record; + if (nextOrder.length === 0) { + const { [key]: _removed, ...rest } = state.tabOrderByWorkspace; + nextTabOrderByWorkspace = rest; + } else if (nextOrder.length === currentOrder.length) { + nextTabOrderByWorkspace = state.tabOrderByWorkspace; + } else { + nextTabOrderByWorkspace = { ...state.tabOrderByWorkspace, [key]: nextOrder }; + } + + const currentFocused = state.focusedTabIdByWorkspace[key] ?? null; + const nextFocused = + currentFocused !== normalizedTabId ? currentFocused : (nextOrder[nextOrder.length - 1] ?? null); + const nextFocusedByWorkspace = (() => { + if (!nextFocused) { + const { [key]: _removed, ...rest } = state.focusedTabIdByWorkspace; + return rest; + } + return { ...state.focusedTabIdByWorkspace, [key]: nextFocused }; + })(); + + const tabsChanged = nextTabs.length !== currentTabs.length; + const orderChanged = nextOrder.length !== currentOrder.length; + const focusChanged = + (state.focusedTabIdByWorkspace[key] ?? null) !== (nextFocusedByWorkspace[key] ?? null); + + if (!tabsChanged && !orderChanged && !focusChanged) { + return state; + } + + return { + uiTabsByWorkspace: nextUiTabsByWorkspace, + tabOrderByWorkspace: nextTabOrderByWorkspace, + focusedTabIdByWorkspace: nextFocusedByWorkspace, + }; +} + +export interface RetargetTabInput { + serverId: string; + workspaceId: string; + tabId: string; + target: WorkspaceTabTarget; +} + +export function applyRetargetTab( + state: WorkspaceTabsCoreState, + input: RetargetTabInput, +): EnsureTabResult { + const key = buildWorkspaceTabPersistenceKey({ + serverId: input.serverId, + workspaceId: input.workspaceId, + }); + const normalizedTabId = trimNonEmpty(input.tabId); + const normalizedTarget = normalizeWorkspaceTabTarget(input.target); + if (!key || !normalizedTabId || !normalizedTarget) { + return { state, tabId: null }; + } + + const currentTabs = state.uiTabsByWorkspace[key] ?? []; + const index = currentTabs.findIndex((tab) => tab.tabId === normalizedTabId); + if (index < 0) { + return { state, tabId: null }; + } + + const currentTarget = currentTabs[index]?.target; + if (currentTarget && workspaceTabTargetsEqual(currentTarget, normalizedTarget)) { + return { state, tabId: null }; + } + + const nextTabs = currentTabs.map((tab, tabIndex) => + tabIndex === index ? Object.assign({}, tab, { target: normalizedTarget }) : tab, + ); + + return { + state: { + ...state, + uiTabsByWorkspace: { ...state.uiTabsByWorkspace, [key]: nextTabs }, + }, + tabId: normalizedTabId, + }; +} + +export interface ReorderTabsInput { + serverId: string; + workspaceId: string; + tabIds: string[]; +} + +export function applyReorderTabs( + state: WorkspaceTabsCoreState, + input: ReorderTabsInput, +): WorkspaceTabsCoreState { + const key = buildWorkspaceTabPersistenceKey({ + serverId: input.serverId, + workspaceId: input.workspaceId, + }); + if (!key) { + return state; + } + + const normalized = normalizeTabOrder(input.tabIds); + const current = state.tabOrderByWorkspace[key] ?? []; + if (current.length === normalized.length) { + let same = true; + for (let i = 0; i < current.length; i += 1) { + if (current[i] !== normalized[i]) { + same = false; + break; + } + } + if (same) { + return state; + } + } + + return { + ...state, + tabOrderByWorkspace: { + ...state.tabOrderByWorkspace, + [key]: normalized, + }, + }; +} + +export interface PurgeWorkspaceInput { + serverId: string; + workspaceId: string; +} + +export function applyPurgeWorkspace( + state: WorkspaceTabsCoreState, + input: PurgeWorkspaceInput, +): WorkspaceTabsCoreState { + const key = buildWorkspaceTabPersistenceKey({ + serverId: input.serverId, + workspaceId: input.workspaceId, + }); + if (!key) { + return state; + } + if ( + !(key in state.uiTabsByWorkspace) && + !(key in state.tabOrderByWorkspace) && + !(key in state.focusedTabIdByWorkspace) + ) { + return state; + } + const { [key]: _tabs, ...remainingUiTabsByWorkspace } = state.uiTabsByWorkspace; + const { [key]: _order, ...remainingTabOrderByWorkspace } = state.tabOrderByWorkspace; + const { [key]: _focused, ...remainingFocusedTabIdByWorkspace } = state.focusedTabIdByWorkspace; + return { + ...state, + uiTabsByWorkspace: remainingUiTabsByWorkspace, + tabOrderByWorkspace: remainingTabOrderByWorkspace, + focusedTabIdByWorkspace: remainingFocusedTabIdByWorkspace, + }; +} + +export function selectWorkspaceTabs( + state: WorkspaceTabsCoreState, + input: { serverId: string; workspaceId: string }, +): WorkspaceTab[] { + const key = buildWorkspaceTabPersistenceKey(input); + if (!key) { + return []; + } + return state.uiTabsByWorkspace[key] ?? []; +} + +interface MigrationRawSources { + rawUiTabsByWorkspace: Record; + rawFocused: Record; + rawOrder: Record; + legacyOrder: Record; +} + +function extractMigrationRawSources(persistedState: unknown): MigrationRawSources { + const top = toObjectRecord(persistedState) ?? {}; + const rawState = toObjectRecord(top.state) ?? top; + + return { + rawUiTabsByWorkspace: + toObjectRecord( + rawState.uiTabsByWorkspace ?? + rawState.openTabsByWorkspace ?? + top.uiTabsByWorkspace ?? + top.openTabsByWorkspace, + ) ?? {}, + rawFocused: + toObjectRecord( + rawState.focusedTabIdByWorkspace ?? + rawState.lastFocusedTabByWorkspace ?? + top.focusedTabIdByWorkspace, + ) ?? {}, + rawOrder: toObjectRecord(rawState.tabOrderByWorkspace ?? top.tabOrderByWorkspace) ?? {}, + legacyOrder: + toObjectRecord( + rawState.tabOrderByWorkspace ?? + rawState.tabOrderLegacyByWorkspace ?? + top.tabOrderLegacyByWorkspace, + ) ?? {}, + }; +} + +function coerceWorkspaceTabTarget(raw: Record): WorkspaceTabTarget | null { + const kind = typeof raw.kind === "string" ? raw.kind : null; + if (kind === "draft" && typeof raw.draftId === "string") { + const setup = normalizeWorkspaceDraftTabSetup(raw.setup); + return normalizeWorkspaceTabTarget({ + kind: "draft", + draftId: raw.draftId, + ...(setup ? { setup } : {}), + }); + } + if (kind === "agent" && typeof raw.agentId === "string") { + return normalizeWorkspaceTabTarget({ kind: "agent", agentId: raw.agentId }); + } + if (kind === "terminal" && typeof raw.terminalId === "string") { + return normalizeWorkspaceTabTarget({ kind: "terminal", terminalId: raw.terminalId }); + } + if (kind === "browser" && typeof raw.browserId === "string") { + return normalizeWorkspaceTabTarget({ kind: "browser", browserId: raw.browserId }); + } + if (kind === "file" && typeof raw.path === "string") { + return normalizeWorkspaceTabTarget({ + kind: "file", + path: raw.path, + lineStart: typeof raw.lineStart === "number" ? raw.lineStart : undefined, + lineEnd: typeof raw.lineEnd === "number" ? raw.lineEnd : undefined, + }); + } + if (kind === "setup" && typeof raw.workspaceId === "string") { + return normalizeWorkspaceTabTarget({ kind: "setup", workspaceId: raw.workspaceId }); + } + return null; +} + +function migrateSingleTab(rawTab: unknown, now: number): WorkspaceTab | null { + const record = toObjectRecord(rawTab); + if (!record) { + return null; + } + const rawTarget = toObjectRecord(record.target); + const normalizedTarget = rawTarget ? coerceWorkspaceTabTarget(rawTarget) : null; + if (!normalizedTarget) { + return null; + } + const rawTabId = trimNonEmpty(typeof record.tabId === "string" ? record.tabId : null); + const tabId = rawTabId ?? buildDeterministicWorkspaceTabId(normalizedTarget); + const rawCreatedAt = record.createdAt; + return { + tabId, + target: normalizedTarget, + createdAt: typeof rawCreatedAt === "number" ? rawCreatedAt : now, + }; +} + +interface MigratedTabsForKey { + nextUiTabs: WorkspaceTab[]; + orderFromTabs: string[]; +} + +function migrateUiTabsForKey(rawEntries: unknown, now: number): MigratedTabsForKey { + const entries = Array.isArray(rawEntries) ? rawEntries : []; + const nextUiTabs: WorkspaceTab[] = []; + const orderFromTabs: string[] = []; + const usedOrder = new Set(); + + for (const rawTab of entries) { + const migrated = migrateSingleTab(rawTab, now); + if (!migrated) { + continue; + } + if (!usedOrder.has(migrated.tabId)) { + usedOrder.add(migrated.tabId); + orderFromTabs.push(migrated.tabId); + } + nextUiTabs.push(migrated); + } + + return { nextUiTabs, orderFromTabs }; +} + +function mergeExplicitTabOrder( + tabOrderByWorkspace: Record, + rawOrder: Record, +): void { + for (const key in rawOrder) { + const normalizedOrder = normalizeTabOrder(rawOrder[key]); + if (normalizedOrder.length === 0) { + continue; + } + const existing = tabOrderByWorkspace[key] ?? []; + tabOrderByWorkspace[key] = normalizeTabOrder([...existing, ...normalizedOrder]); + } +} + +function convertLegacyOrderEntry(entry: unknown): string | null { + const raw = typeof entry === "string" ? entry.trim() : ""; + if (!raw) { + return null; + } + if (raw.startsWith("agent:")) { + const agentId = raw.slice("agent:".length).trim(); + return agentId ? `agent_${agentId}` : null; + } + if (raw.startsWith("terminal:")) { + const terminalId = raw.slice("terminal:".length).trim(); + return terminalId ? `terminal_${terminalId}` : null; + } + return null; +} + +function normalizeLegacyOrderList(list: unknown[]): string[] { + const result: string[] = []; + for (const entry of list) { + const converted = convertLegacyOrderEntry(entry); + if (converted) { + result.push(converted); + } + } + return result; +} + +function mergeLegacyTabOrder( + tabOrderByWorkspace: Record, + legacyOrder: Record, +): void { + for (const key in legacyOrder) { + const list = legacyOrder[key]; + if (!Array.isArray(list) || list.length === 0) { + continue; + } + const normalizedLegacyOrder = normalizeLegacyOrderList(list); + if (normalizedLegacyOrder.length === 0) { + continue; + } + const existing = tabOrderByWorkspace[key] ?? []; + tabOrderByWorkspace[key] = normalizeTabOrder([...existing, ...normalizedLegacyOrder]); + } +} + +function resolveFocusedTabId(rawValue: unknown): string | null { + if (typeof rawValue === "string") { + return trimNonEmpty(rawValue); + } + if (!rawValue || typeof rawValue !== "object") { + return null; + } + const value = rawValue as { + kind?: string; + agentId?: string; + terminalId?: string; + draftId?: string; + }; + if (value.kind === "agent" && typeof value.agentId === "string" && value.agentId.trim()) { + return `agent_${value.agentId.trim()}`; + } + if ( + value.kind === "terminal" && + typeof value.terminalId === "string" && + value.terminalId.trim() + ) { + return `terminal_${value.terminalId.trim()}`; + } + if (value.kind === "draft" && typeof value.draftId === "string" && value.draftId.trim()) { + return value.draftId.trim(); + } + return null; +} + +function migrateFocusedTabIds( + focusedTabIdByWorkspace: Record, + rawFocused: Record, +): void { + for (const key in rawFocused) { + const resolved = resolveFocusedTabId(rawFocused[key]); + if (resolved) { + focusedTabIdByWorkspace[key] = resolved; + } + } +} + +export function migrateWorkspaceTabsState( + persistedState: unknown, + options: { now: number }, +): WorkspaceTabsCoreState { + const { rawUiTabsByWorkspace, rawFocused, rawOrder, legacyOrder } = + extractMigrationRawSources(persistedState); + + const uiTabsByWorkspace: Record = {}; + const tabOrderByWorkspace: Record = {}; + const focusedTabIdByWorkspace: Record = {}; + + for (const key in rawUiTabsByWorkspace) { + const { nextUiTabs, orderFromTabs } = migrateUiTabsForKey( + rawUiTabsByWorkspace[key], + options.now, + ); + if (nextUiTabs.length > 0) { + uiTabsByWorkspace[key] = nextUiTabs; + } + if (orderFromTabs.length > 0) { + tabOrderByWorkspace[key] = orderFromTabs; + } + } + + mergeExplicitTabOrder(tabOrderByWorkspace, rawOrder); + mergeLegacyTabOrder(tabOrderByWorkspace, legacyOrder); + migrateFocusedTabIds(focusedTabIdByWorkspace, rawFocused); + + return { + uiTabsByWorkspace, + tabOrderByWorkspace, + focusedTabIdByWorkspace, + }; +} + +export function partializeWorkspaceTabsState( + state: WorkspaceTabsCoreState, + options: { now: number }, +): WorkspaceTabsCoreState { + const nextUiTabsByWorkspace: Record = {}; + for (const key in state.uiTabsByWorkspace) { + const tabs = (state.uiTabsByWorkspace[key] ?? []) + .map((tab) => { + const normalizedTarget = normalizeWorkspaceTabTarget(tab.target); + const normalizedTabId = trimNonEmpty(tab.tabId); + if (!normalizedTarget || !normalizedTabId) { + return null; + } + return { + tabId: normalizedTabId, + target: normalizedTarget, + createdAt: typeof tab.createdAt === "number" ? tab.createdAt : options.now, + } satisfies WorkspaceTab; + }) + .filter((tab): tab is WorkspaceTab => tab !== null); + if (tabs.length > 0) { + nextUiTabsByWorkspace[key] = tabs; + } + } + + const nextTabOrderByWorkspace: Record = {}; + for (const key in state.tabOrderByWorkspace) { + const order = normalizeTabOrder(state.tabOrderByWorkspace[key]); + if (order.length > 0) { + nextTabOrderByWorkspace[key] = order; + } + } + + const nextFocusedTabIdByWorkspace: Record = {}; + for (const key in state.focusedTabIdByWorkspace) { + const focusedTabId = trimNonEmpty(state.focusedTabIdByWorkspace[key]); + if (focusedTabId) { + nextFocusedTabIdByWorkspace[key] = focusedTabId; + } + } + + return { + uiTabsByWorkspace: nextUiTabsByWorkspace, + tabOrderByWorkspace: nextTabOrderByWorkspace, + focusedTabIdByWorkspace: nextFocusedTabIdByWorkspace, + }; +} diff --git a/packages/app/src/subagents/archive-subagent.test.ts b/packages/app/src/subagents/archive-subagent.test.ts new file mode 100644 index 000000000..68280d4ec --- /dev/null +++ b/packages/app/src/subagents/archive-subagent.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it } from "vitest"; +import type { ConfirmDialogInput } from "@/utils/confirm-dialog"; +import { + requestArchiveSubagent, + resolveArchiveSubagentDialog, + type ArchiveSubagentDeps, + type ResolveArchiveSubagentDialogInput, +} from "./archive-subagent"; + +interface RecordedArchive { + serverId: string; + agentId: string; +} + +interface FakeArchiveSubagentEnv { + deps: ArchiveSubagentDeps; + recordedArchives: RecordedArchive[]; + recordedConfirmInputs: ConfirmDialogInput[]; + setSubagent(id: string, snapshot: ResolveArchiveSubagentDialogInput | undefined): void; +} + +function createFakeEnv( + options: { + confirmResult?: boolean; + initialSubagents?: Array<{ id: string; snapshot: ResolveArchiveSubagentDialogInput }>; + } = {}, +): FakeArchiveSubagentEnv { + const subagents = new Map(); + for (const entry of options.initialSubagents ?? []) { + subagents.set(entry.id, entry.snapshot); + } + const recordedArchives: RecordedArchive[] = []; + const recordedConfirmInputs: ConfirmDialogInput[] = []; + + return { + recordedArchives, + recordedConfirmInputs, + setSubagent(id, snapshot) { + subagents.set(id, snapshot); + }, + deps: { + getSubagent: (id) => subagents.get(id), + confirm: async (dialog) => { + recordedConfirmInputs.push(dialog); + return options.confirmResult ?? false; + }, + archiveAgent: async (input) => { + recordedArchives.push(input); + }, + }, + }; +} + +describe("resolveArchiveSubagentDialog", () => { + it("uses running copy for running subagents", () => { + expect( + resolveArchiveSubagentDialog({ + title: "Review branch", + status: "running", + }), + ).toEqual({ + title: "Archive running subagent?", + message: + "Review branch is still running. Archiving it will stop the subagent and remove it from the track.", + confirmLabel: "Archive", + cancelLabel: "Cancel", + destructive: true, + }); + }); + + it("uses running copy for initializing subagents", () => { + expect( + resolveArchiveSubagentDialog({ + title: "Starting child", + status: "initializing", + }), + ).toEqual({ + title: "Archive running subagent?", + message: + "Starting child is still running. Archiving it will stop the subagent and remove it from the track.", + confirmLabel: "Archive", + cancelLabel: "Cancel", + destructive: true, + }); + }); + + it("uses idle copy for non-running subagents", () => { + expect( + resolveArchiveSubagentDialog({ + title: "Review branch", + status: "idle", + }), + ).toEqual({ + title: "Archive subagent?", + message: "Remove Review branch from the track. The subagent will be archived.", + confirmLabel: "Archive", + cancelLabel: "Cancel", + destructive: true, + }); + }); + + it("falls back to this subagent when the title is not displayable", () => { + expect( + resolveArchiveSubagentDialog({ + title: "New Agent", + status: null, + }), + ).toEqual({ + title: "Archive subagent?", + message: "Remove this subagent from the track. The subagent will be archived.", + confirmLabel: "Archive", + cancelLabel: "Cancel", + destructive: true, + }); + }); +}); + +describe("requestArchiveSubagent", () => { + it("archives the subagent with the server id when the user confirms", async () => { + const env = createFakeEnv({ + confirmResult: true, + initialSubagents: [ + { + id: "child-agent", + snapshot: { title: "Review branch", status: "running" }, + }, + ], + }); + + await requestArchiveSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps); + + expect(env.recordedArchives).toEqual([{ serverId: "server-1", agentId: "child-agent" }]); + }); + + it("does not archive the subagent when the user cancels", async () => { + const env = createFakeEnv({ + confirmResult: false, + initialSubagents: [ + { + id: "child-agent", + snapshot: { title: "Review branch", status: "idle" }, + }, + ], + }); + + await requestArchiveSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps); + + expect(env.recordedArchives).toEqual([]); + }); + + it("asks for confirmation using the resolved dialog for the subagent", async () => { + const env = createFakeEnv({ + confirmResult: false, + initialSubagents: [ + { + id: "child-agent", + snapshot: { title: "Review branch", status: "running" }, + }, + ], + }); + + await requestArchiveSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps); + + expect(env.recordedConfirmInputs).toEqual([ + resolveArchiveSubagentDialog({ title: "Review branch", status: "running" }), + ]); + }); + + it("asks with the missing-subagent dialog when the snapshot is unknown", async () => { + const env = createFakeEnv({ confirmResult: false }); + + await requestArchiveSubagent({ serverId: "server-1", subagentId: "missing" }, env.deps); + + expect(env.recordedConfirmInputs).toEqual([ + resolveArchiveSubagentDialog({ title: undefined, status: undefined }), + ]); + expect(env.recordedArchives).toEqual([]); + }); + + it("swallows archive errors so the caller never sees them", async () => { + const env = createFakeEnv({ + confirmResult: true, + initialSubagents: [ + { + id: "child-agent", + snapshot: { title: "Review branch", status: "running" }, + }, + ], + }); + env.deps.archiveAgent = async () => { + throw new Error("daemon offline"); + }; + + await expect( + requestArchiveSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps), + ).resolves.toBeUndefined(); + }); +}); diff --git a/packages/app/src/subagents/archive-subagent.ts b/packages/app/src/subagents/archive-subagent.ts new file mode 100644 index 000000000..26a431199 --- /dev/null +++ b/packages/app/src/subagents/archive-subagent.ts @@ -0,0 +1,66 @@ +import type { Agent } from "@/stores/session-store"; +import type { ConfirmDialogInput } from "@/utils/confirm-dialog"; + +export interface ResolveArchiveSubagentDialogInput { + title: Agent["title"] | null | undefined; + status: Agent["status"] | null | undefined; +} + +function resolveSubagentLabel(title: Agent["title"] | null | undefined): string | null { + if (typeof title !== "string") { + return null; + } + const normalized = title.trim(); + if (!normalized) { + return null; + } + if (normalized.toLowerCase() === "new agent") { + return null; + } + return normalized; +} + +export function resolveArchiveSubagentDialog( + input: ResolveArchiveSubagentDialogInput, +): ConfirmDialogInput { + const subagentLabel = resolveSubagentLabel(input.title) ?? "this subagent"; + const isRunning = input.status === "running" || input.status === "initializing"; + + return { + title: isRunning ? "Archive running subagent?" : "Archive subagent?", + message: isRunning + ? `${subagentLabel} is still running. Archiving it will stop the subagent and remove it from the track.` + : `Remove ${subagentLabel} from the track. The subagent will be archived.`, + confirmLabel: "Archive", + cancelLabel: "Cancel", + destructive: true, + }; +} + +export interface ArchiveSubagentDeps { + getSubagent: (subagentId: string) => ResolveArchiveSubagentDialogInput | undefined; + confirm: (input: ConfirmDialogInput) => Promise; + archiveAgent: (input: { serverId: string; agentId: string }) => Promise; +} + +export interface RequestArchiveSubagentInput { + serverId: string; + subagentId: string; +} + +export async function requestArchiveSubagent( + input: RequestArchiveSubagentInput, + deps: ArchiveSubagentDeps, +): Promise { + const subagent = deps.getSubagent(input.subagentId); + const confirmed = await deps.confirm( + resolveArchiveSubagentDialog({ + title: subagent?.title, + status: subagent?.status, + }), + ); + if (!confirmed) { + return; + } + void deps.archiveAgent({ serverId: input.serverId, agentId: input.subagentId }).catch(() => {}); +} diff --git a/packages/app/src/subagents/index.ts b/packages/app/src/subagents/index.ts index c368f0b0d..0a0be0a9e 100644 --- a/packages/app/src/subagents/index.ts +++ b/packages/app/src/subagents/index.ts @@ -1,5 +1,3 @@ -export { SubagentsTrack } from "./track"; -export type { SubagentsTrackProps } from "./track"; export type { SubagentRow } from "./select"; export { selectSubagentsForParent, useSubagentsForParent } from "./select"; export { useArchiveSubagent, type UseArchiveSubagentInput } from "./use-archive-subagent"; diff --git a/packages/app/src/subagents/track-presentation.test.ts b/packages/app/src/subagents/track-presentation.test.ts new file mode 100644 index 000000000..82170690e --- /dev/null +++ b/packages/app/src/subagents/track-presentation.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from "vitest"; +import type { SubagentRow } from "./select"; +import { + buildSubagentRowPresentationData, + formatHeaderLabel, + resolveRowLabel, +} from "./track-presentation"; + +function row(overrides: Partial & Pick): SubagentRow { + return { + id: overrides.id, + provider: overrides.provider ?? "codex", + title: overrides.title ?? `Agent ${overrides.id}`, + status: overrides.status ?? "idle", + requiresAttention: overrides.requiresAttention ?? false, + createdAt: overrides.createdAt ?? new Date("2026-04-20T00:00:00.000Z"), + }; +} + +describe("formatHeaderLabel", () => { + it("uses singular 'subagent' for a single row", () => { + expect(formatHeaderLabel([row({ id: "a" })])).toBe("1 subagent"); + }); + + it("uses plural 'subagents' for two rows with no running rows", () => { + expect(formatHeaderLabel([row({ id: "a" }), row({ id: "b" })])).toBe("2 subagents"); + }); + + it("appends the running count when at least one row is running", () => { + expect( + formatHeaderLabel([row({ id: "a", status: "running" }), row({ id: "b" }), row({ id: "c" })]), + ).toBe("3 subagents · 1 running"); + }); + + it("counts every running row in the suffix", () => { + expect( + formatHeaderLabel([ + row({ id: "a", status: "running" }), + row({ id: "b", status: "running" }), + row({ id: "c", requiresAttention: true }), + row({ id: "d" }), + row({ id: "e" }), + ]), + ).toBe("5 subagents · 2 running"); + }); + + it("ignores requiresAttention on non-running rows in the header copy", () => { + expect( + formatHeaderLabel([ + row({ id: "a", status: "error", requiresAttention: false }), + row({ id: "b", status: "idle", requiresAttention: false }), + row({ id: "c", status: "idle", requiresAttention: true }), + ]), + ).toBe("3 subagents"); + }); + + it("still counts running rows even when they require attention", () => { + expect( + formatHeaderLabel([ + row({ id: "a", status: "error", requiresAttention: true }), + row({ id: "b", status: "running", requiresAttention: true }), + row({ id: "c", status: "idle", requiresAttention: true }), + ]), + ).toBe("3 subagents · 1 running"); + }); + + it("uses singular 'subagent' for a single row that requires attention upstream", () => { + expect(formatHeaderLabel([row({ id: "a", requiresAttention: true })])).toBe("1 subagent"); + }); +}); + +describe("resolveRowLabel", () => { + it("returns null when title is not a string", () => { + expect(resolveRowLabel(null as unknown as SubagentRow["title"])).toBe(null); + }); + + it("returns null for whitespace-only titles", () => { + expect(resolveRowLabel(" ")).toBe(null); + }); + + it("returns null for the placeholder 'new agent' regardless of case", () => { + expect(resolveRowLabel("new agent")).toBe(null); + expect(resolveRowLabel("New Agent")).toBe(null); + expect(resolveRowLabel(" NEW AGENT ")).toBe(null); + }); + + it("returns the trimmed title for real names", () => { + expect(resolveRowLabel(" Build the thing ")).toBe("Build the thing"); + }); +}); + +describe("buildSubagentRowPresentationData", () => { + it("namespaces the key with a subagent prefix", () => { + expect(buildSubagentRowPresentationData(row({ id: "child-a" })).key).toBe("subagent_child-a"); + }); + + it("marks the row ready when the title resolves to a real label", () => { + const presentation = buildSubagentRowPresentationData(row({ id: "a", title: "Build it" })); + expect(presentation.titleState).toBe("ready"); + expect(presentation.label).toBe("Build it"); + }); + + it("marks the row loading and blanks the label for the placeholder title", () => { + const presentation = buildSubagentRowPresentationData(row({ id: "a", title: "new agent" })); + expect(presentation.titleState).toBe("loading"); + expect(presentation.label).toBe(""); + }); + + it("maps a running row to the running status bucket so callers render the synced loader", () => { + expect(buildSubagentRowPresentationData(row({ id: "a", status: "running" })).statusBucket).toBe( + "running", + ); + }); + + it("maps an idle row to the done status bucket so callers render the static provider icon", () => { + expect(buildSubagentRowPresentationData(row({ id: "a", status: "idle" })).statusBucket).toBe( + "done", + ); + }); + + it("ignores requiresAttention on the source row when computing the bucket", () => { + expect( + buildSubagentRowPresentationData(row({ id: "a", status: "idle", requiresAttention: true })) + .statusBucket, + ).toBe("done"); + }); +}); diff --git a/packages/app/src/subagents/track-presentation.ts b/packages/app/src/subagents/track-presentation.ts new file mode 100644 index 000000000..d9c626313 --- /dev/null +++ b/packages/app/src/subagents/track-presentation.ts @@ -0,0 +1,56 @@ +import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; +import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state"; +import type { SubagentRow } from "./select"; + +export interface SubagentRowPresentationData { + key: string; + kind: "agent"; + label: string; + subtitle: string; + titleState: "ready" | "loading"; + statusBucket: SidebarStateBucket | null; +} + +export function buildSubagentRowPresentationData(row: SubagentRow): SubagentRowPresentationData { + const label = resolveRowLabel(row.title); + return { + key: `subagent_${row.id}`, + kind: "agent", + label: label ?? "", + subtitle: "", + titleState: label ? "ready" : "loading", + statusBucket: deriveSidebarStateBucket({ + status: row.status, + requiresAttention: false, + }), + }; +} + +export function formatHeaderLabel(rows: readonly SubagentRow[]): string { + let runningCount = 0; + for (const row of rows) { + if (row.status === "running") { + runningCount += 1; + } + } + + const parts = [`${rows.length} ${rows.length === 1 ? "subagent" : "subagents"}`]; + if (runningCount > 0) { + parts.push(`${runningCount} running`); + } + return parts.join(" · "); +} + +export function resolveRowLabel(title: SubagentRow["title"]): string | null { + if (typeof title !== "string") { + return null; + } + const normalized = title.trim(); + if (!normalized) { + return null; + } + if (normalized.toLowerCase() === "new agent") { + return null; + } + return normalized; +} diff --git a/packages/app/src/subagents/track.test.tsx b/packages/app/src/subagents/track.test.tsx deleted file mode 100644 index a9ed48957..000000000 --- a/packages/app/src/subagents/track.test.tsx +++ /dev/null @@ -1,285 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import React from "react"; -import { act } from "@testing-library/react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { SubagentsTrack } from "./track"; -import type { SubagentRow } from "./select"; - -const { theme } = vi.hoisted(() => ({ - theme: { - colorScheme: "dark", - spacing: { 0: 0, 1: 4, 2: 8, 3: 12, 4: 16 }, - borderWidth: { 1: 1 }, - borderRadius: { sm: 4, md: 6, lg: 8, "2xl": 16, full: 999 }, - fontSize: { xs: 11, sm: 13, base: 15 }, - fontWeight: { normal: "400", medium: "500" }, - iconSize: { sm: 14, md: 18 }, - colors: { - foreground: "#fff", - foregroundMuted: "#aaa", - surface0: "#000", - surface1: "#111", - surface2: "#222", - surface3: "#333", - border: "#444", - borderAccent: "#555", - accent: "#0a84ff", - palette: { - amber: { 500: "#ffbf00", 700: "#aa8000" }, - blue: { 500: "#0a84ff" }, - red: { 500: "#ff453a" }, - green: { 500: "#30d158" }, - }, - }, - }, -})); - -vi.hoisted(() => { - (globalThis as unknown as { __DEV__: boolean }).__DEV__ = false; -}); - -vi.mock("react-native-unistyles", () => ({ - StyleSheet: { - create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory), - }, - useUnistyles: () => ({ theme, rt: { breakpoint: "lg" } }), - withUnistyles: (component: T) => component, -})); - -vi.mock("@/panels/register-panels", () => ({ - ensurePanelsRegistered: () => {}, -})); - -vi.mock("@/components/ui/tooltip", () => ({ - Tooltip: ({ children }: { children: React.ReactNode }) => children, - TooltipTrigger: ({ children }: { children: React.ReactNode }) => children, - TooltipContent: () => null, -})); - -vi.mock("react-native-reanimated", () => ({ - default: { - View: "div", - }, - runOnJS: (fn: (...args: unknown[]) => unknown) => fn, - useSharedValue: (value: unknown) => ({ value }), - useAnimatedStyle: (factory: () => unknown) => factory(), - withTiming: (value: unknown) => value, - withRepeat: (value: unknown) => value, - cancelAnimation: () => {}, - Easing: { - inOut: () => () => 0, - linear: () => 0, - ease: () => 0, - bezier: () => () => 0, - }, - ReduceMotion: { System: "system", Never: "never", Always: "always" }, -})); - -vi.mock("@/components/provider-icons", () => ({ - getProviderIcon: (provider: unknown) => - function ProviderIconStub(props: { size: number; color: string }) { - return React.createElement("span", { - "data-testid": "subagents-provider-icon", - "data-provider": String(provider ?? "none"), - "data-size": String(props.size), - }); - }, -})); - -vi.mock("@/components/synced-loader", () => ({ - SyncedLoader: (props: { size: number; color: string }) => - React.createElement("span", { - "data-testid": "subagents-synced-loader", - "data-size": String(props.size), - }), -})); - -vi.mock("lucide-react-native", () => { - const createIcon = (name: string) => - function IconStub(props: Record) { - return React.createElement("span", { ...props, "data-icon": name }); - }; - return { - Archive: createIcon("Archive"), - Check: createIcon("Check"), - ChevronDown: createIcon("ChevronDown"), - ChevronRight: createIcon("ChevronRight"), - }; -}); - -function row(overrides: Partial & Pick): SubagentRow { - return { - id: overrides.id, - provider: overrides.provider ?? "codex", - title: overrides.title ?? `Agent ${overrides.id}`, - status: overrides.status ?? "idle", - requiresAttention: overrides.requiresAttention ?? false, - createdAt: overrides.createdAt ?? new Date("2026-04-20T00:00:00.000Z"), - }; -} - -function click(element: Element): void { - act(() => { - element.dispatchEvent(new window.MouseEvent("click", { bubbles: true })); - }); -} - -function queryByTestId(testID: string): HTMLElement | null { - return document.querySelector(`[data-testid="${testID}"]`); -} - -function queryRowIds(): string[] { - return Array.from( - document.querySelectorAll('[data-testid^="subagents-track-row-"]'), - ).map((node) => node.getAttribute("data-testid")?.replace("subagents-track-row-", "") ?? ""); -} - -type SubagentAction = (id: string) => void; - -describe("SubagentsTrack", () => { - let container: HTMLElement | null = null; - let root: Root | null = null; - - beforeEach(() => { - vi.stubGlobal("React", React); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - vi.stubGlobal( - "ResizeObserver", - class ResizeObserver { - observe() {} - unobserve() {} - disconnect() {} - }, - ); - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - }); - - afterEach(() => { - if (root) { - act(() => { - root?.unmount(); - }); - } - root = null; - container?.remove(); - container = null; - vi.unstubAllGlobals(); - }); - - function render( - rows: SubagentRow[], - onOpenSubagent: SubagentAction = vi.fn(), - onArchiveSubagent: SubagentAction = vi.fn(), - ): SubagentAction { - act(() => { - root?.render( - , - ); - }); - return onOpenSubagent; - } - - it("renders nothing when rows is empty", () => { - render([]); - expect(queryByTestId("subagents-track")).toBeNull(); - expect(queryByTestId("subagents-track-header")).toBeNull(); - expect(queryRowIds()).toEqual([]); - }); - - it("shows only the collapsed header and no rows initially", () => { - render([row({ id: "child-a" }), row({ id: "child-b" })]); - expect(queryByTestId("subagents-track-header")).not.toBeNull(); - expect(queryRowIds()).toEqual([]); - }); - - it("expands rows in the given order when the header is pressed", () => { - render([row({ id: "child-b" }), row({ id: "child-a" }), row({ id: "child-c" })]); - click(queryByTestId("subagents-track-header")!); - expect(queryRowIds()).toEqual(["child-b", "child-a", "child-c"]); - }); - - it("calls onOpenSubagent with the row id when a row is pressed", () => { - const onOpenSubagent = render([row({ id: "child-a" }), row({ id: "child-b" })]); - click(queryByTestId("subagents-track-header")!); - click(queryByTestId("subagents-track-row-child-b")!); - expect(onOpenSubagent).toHaveBeenCalledTimes(1); - expect(onOpenSubagent).toHaveBeenCalledWith("child-b"); - }); - - describe("header copy", () => { - it("renders '2 subagents' when two rows are not running", () => { - render([row({ id: "child-a" }), row({ id: "child-b" })]); - expect(queryByTestId("subagents-track-header")?.textContent).toBe("2 subagents"); - }); - - it("renders '3 subagents · 1 running' with a single running row", () => { - render([ - row({ id: "child-a", status: "running" }), - row({ id: "child-b" }), - row({ id: "child-c" }), - ]); - expect(queryByTestId("subagents-track-header")?.textContent).toBe("3 subagents · 1 running"); - }); - - it("renders '1 subagent' for a finished row that still requires attention upstream", () => { - render([row({ id: "child-a", requiresAttention: true })]); - expect(queryByTestId("subagents-track-header")?.textContent).toBe("1 subagent"); - }); - - it("renders '5 subagents · 2 running' when finished rows require attention upstream", () => { - render([ - row({ id: "a", status: "running" }), - row({ id: "b", status: "running" }), - row({ id: "c", requiresAttention: true }), - row({ id: "d" }), - row({ id: "e" }), - ]); - expect(queryByTestId("subagents-track-header")?.textContent).toBe("5 subagents · 2 running"); - }); - }); - - it("ignores requiresAttention for non-running header copy", () => { - render([ - row({ id: "a", status: "error", requiresAttention: false }), - row({ id: "b", status: "idle", requiresAttention: false }), - row({ id: "c", status: "idle", requiresAttention: true }), - ]); - expect(queryByTestId("subagents-track-header")?.textContent).toBe("3 subagents"); - }); - - it("still counts running rows even when they require attention", () => { - render([ - row({ id: "a", status: "error", requiresAttention: true }), - row({ id: "b", status: "running", requiresAttention: true }), - row({ id: "c", status: "idle", requiresAttention: true }), - ]); - expect(queryByTestId("subagents-track-header")?.textContent).toBe("3 subagents · 1 running"); - }); - - it("renders each row through the shared workspace tab icon primitives", () => { - render([ - row({ id: "idle-child", status: "idle", provider: "codex" }), - row({ id: "running-child", status: "running", provider: "claude-code" }), - ]); - click(queryByTestId("subagents-track-header")!); - - const idleRow = queryByTestId("subagents-track-row-idle-child"); - expect(idleRow).not.toBeNull(); - expect(idleRow!.querySelectorAll('[data-testid="subagents-provider-icon"]').length).toBe(1); - expect(idleRow!.querySelectorAll('[data-testid="subagents-synced-loader"]').length).toBe(0); - - const runningRow = queryByTestId("subagents-track-row-running-child"); - expect(runningRow).not.toBeNull(); - expect(runningRow!.querySelectorAll('[data-testid="subagents-synced-loader"]').length).toBe(1); - expect(runningRow!.querySelectorAll('[data-testid="subagents-provider-icon"]').length).toBe(0); - }); -}); diff --git a/packages/app/src/subagents/track.tsx b/packages/app/src/subagents/track.tsx index 48ca2804b..47178949d 100644 --- a/packages/app/src/subagents/track.tsx +++ b/packages/app/src/subagents/track.tsx @@ -11,8 +11,8 @@ import { type WorkspaceTabPresentation, } from "@/screens/workspace/workspace-tab-presentation"; import type { Theme } from "@/styles/theme"; -import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state"; import type { SubagentRow } from "./select"; +import { buildSubagentRowPresentationData, formatHeaderLabel } from "./track-presentation"; const ThemedArchive = withUnistyles(Archive); const ThemedChevronDown = withUnistyles(ChevronDown); @@ -31,48 +31,10 @@ export interface SubagentsTrackProps { const SUBAGENTS_LIST_MAX_HEIGHT = 200; -function formatHeaderLabel(rows: SubagentRow[]): string { - let runningCount = 0; - for (const row of rows) { - if (row.status === "running") { - runningCount += 1; - } - } - - const parts = [`${rows.length} ${rows.length === 1 ? "subagent" : "subagents"}`]; - if (runningCount > 0) { - parts.push(`${runningCount} running`); - } - return parts.join(" · "); -} - -function resolveRowLabel(title: SubagentRow["title"]): string | null { - if (typeof title !== "string") { - return null; - } - const normalized = title.trim(); - if (!normalized) { - return null; - } - if (normalized.toLowerCase() === "new agent") { - return null; - } - return normalized; -} - function buildRowPresentation(row: SubagentRow): WorkspaceTabPresentation { - const label = resolveRowLabel(row.title); return { - key: `subagent_${row.id}`, - kind: "agent", - label: label ?? "", - subtitle: "", - titleState: label ? "ready" : "loading", + ...buildSubagentRowPresentationData(row), icon: getProviderIcon(row.provider), - statusBucket: deriveSidebarStateBucket({ - status: row.status, - requiresAttention: false, - }), }; } diff --git a/packages/app/src/subagents/use-archive-subagent.test.ts b/packages/app/src/subagents/use-archive-subagent.test.ts deleted file mode 100644 index 96dfb7896..000000000 --- a/packages/app/src/subagents/use-archive-subagent.test.ts +++ /dev/null @@ -1,217 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import { act, renderHook } from "@testing-library/react"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { useArchiveSubagent } from "@/subagents"; -import { useSessionStore, type Agent } from "@/stores/session-store"; -import { resolveArchiveSubagentDialog } from "./use-archive-subagent"; - -const { archiveAgentMock, confirmDialogMock } = vi.hoisted(() => ({ - archiveAgentMock: vi.fn(), - confirmDialogMock: vi.fn(), -})); - -vi.mock("@/hooks/use-archive-agent", () => ({ - useArchiveAgent: () => ({ - archiveAgent: archiveAgentMock, - }), -})); - -vi.mock("@/utils/confirm-dialog", () => ({ - confirmDialog: confirmDialogMock, -})); - -vi.mock("./track", () => ({ - SubagentsTrack: () => null, -})); - -const SERVER_ID = "server-1"; - -function makeAgent(input: { id: string; title?: Agent["title"]; status?: Agent["status"] }): Agent { - const createdAt = new Date("2026-03-04T00:00:00.000Z"); - return { - serverId: SERVER_ID, - id: input.id, - provider: "codex", - status: input.status ?? "idle", - createdAt, - updatedAt: createdAt, - lastUserMessageAt: null, - lastActivityAt: createdAt, - capabilities: { - supportsStreaming: true, - supportsSessionPersistence: true, - supportsDynamicModes: true, - supportsMcpServers: true, - supportsReasoningStream: true, - supportsToolInvocations: true, - }, - currentModeId: null, - availableModes: [], - pendingPermissions: [], - persistence: null, - runtimeInfo: { - provider: "codex", - sessionId: null, - }, - title: input.title ?? null, - cwd: "/repo/worktree", - model: null, - thinkingOptionId: null, - parentAgentId: "parent-agent", - labels: {}, - requiresAttention: false, - attentionReason: null, - attentionTimestamp: null, - archivedAt: null, - }; -} - -function seedSubagent(subagent: Agent): void { - const client = { archiveAgent: vi.fn() } as unknown as DaemonClient; - useSessionStore.getState().initializeSession(SERVER_ID, client); - useSessionStore.getState().setAgents(SERVER_ID, new Map([[subagent.id, subagent]])); -} - -describe("resolveArchiveSubagentDialog", () => { - it("uses running copy for running subagents", () => { - expect( - resolveArchiveSubagentDialog({ - title: "Review branch", - status: "running", - }), - ).toEqual({ - title: "Archive running subagent?", - message: - "Review branch is still running. Archiving it will stop the subagent and remove it from the track.", - confirmLabel: "Archive", - cancelLabel: "Cancel", - destructive: true, - }); - }); - - it("uses running copy for initializing subagents", () => { - expect( - resolveArchiveSubagentDialog({ - title: "Starting child", - status: "initializing", - }), - ).toEqual({ - title: "Archive running subagent?", - message: - "Starting child is still running. Archiving it will stop the subagent and remove it from the track.", - confirmLabel: "Archive", - cancelLabel: "Cancel", - destructive: true, - }); - }); - - it("uses idle copy for non-running subagents", () => { - expect( - resolveArchiveSubagentDialog({ - title: "Review branch", - status: "idle", - }), - ).toEqual({ - title: "Archive subagent?", - message: "Remove Review branch from the track. The subagent will be archived.", - confirmLabel: "Archive", - cancelLabel: "Cancel", - destructive: true, - }); - }); - - it("falls back to this subagent when the title is not displayable", () => { - expect( - resolveArchiveSubagentDialog({ - title: "New Agent", - status: null, - }), - ).toEqual({ - title: "Archive subagent?", - message: "Remove this subagent from the track. The subagent will be archived.", - confirmLabel: "Archive", - cancelLabel: "Cancel", - destructive: true, - }); - }); -}); - -describe("useArchiveSubagent", () => { - beforeEach(() => { - archiveAgentMock.mockReset(); - archiveAgentMock.mockResolvedValue(undefined); - confirmDialogMock.mockReset(); - useSessionStore.getState().clearSession(SERVER_ID); - }); - - afterEach(() => { - useSessionStore.getState().clearSession(SERVER_ID); - }); - - it("archives the subagent with the server id when the user confirms", async () => { - const subagent = makeAgent({ - id: "child-agent", - title: "Review branch", - status: "running", - }); - seedSubagent(subagent); - confirmDialogMock.mockResolvedValue(true); - - const { result } = renderHook(() => useArchiveSubagent({ serverId: SERVER_ID })); - - await act(async () => { - await (result.current as (subagentId: string) => Promise)(subagent.id); - }); - - expect(archiveAgentMock).toHaveBeenCalledTimes(1); - expect(archiveAgentMock).toHaveBeenCalledWith({ - serverId: SERVER_ID, - agentId: subagent.id, - }); - }); - - it("does not archive the subagent when the user cancels", async () => { - const subagent = makeAgent({ - id: "child-agent", - title: "Review branch", - status: "idle", - }); - seedSubagent(subagent); - confirmDialogMock.mockResolvedValue(false); - - const { result } = renderHook(() => useArchiveSubagent({ serverId: SERVER_ID })); - - await act(async () => { - await (result.current as (subagentId: string) => Promise)(subagent.id); - }); - - expect(archiveAgentMock).not.toHaveBeenCalled(); - }); - - it("passes the resolved dialog input for the subagent to confirmDialog", async () => { - const subagent = makeAgent({ - id: "child-agent", - title: "Review branch", - status: "running", - }); - seedSubagent(subagent); - confirmDialogMock.mockResolvedValue(false); - - const { result } = renderHook(() => useArchiveSubagent({ serverId: SERVER_ID })); - - await act(async () => { - await (result.current as (subagentId: string) => Promise)(subagent.id); - }); - - expect(confirmDialogMock).toHaveBeenCalledTimes(1); - expect(confirmDialogMock).toHaveBeenCalledWith( - resolveArchiveSubagentDialog({ - title: subagent.title, - status: subagent.status, - }), - ); - }); -}); diff --git a/packages/app/src/subagents/use-archive-subagent.ts b/packages/app/src/subagents/use-archive-subagent.ts index da6188f2f..6f344d3f8 100644 --- a/packages/app/src/subagents/use-archive-subagent.ts +++ b/packages/app/src/subagents/use-archive-subagent.ts @@ -1,65 +1,35 @@ import { useCallback } from "react"; import { useArchiveAgent } from "@/hooks/use-archive-agent"; -import { useSessionStore, type Agent } from "@/stores/session-store"; -import { confirmDialog, type ConfirmDialogInput } from "@/utils/confirm-dialog"; +import { useSessionStore } from "@/stores/session-store"; +import { confirmDialog } from "@/utils/confirm-dialog"; +import { requestArchiveSubagent, type ResolveArchiveSubagentDialogInput } from "./archive-subagent"; + +export { resolveArchiveSubagentDialog, requestArchiveSubagent } from "./archive-subagent"; +export type { + ArchiveSubagentDeps, + RequestArchiveSubagentInput, + ResolveArchiveSubagentDialogInput, +} from "./archive-subagent"; export interface UseArchiveSubagentInput { serverId: string; } -interface ResolveArchiveSubagentDialogInput { - title: Agent["title"] | null | undefined; - status: Agent["status"] | null | undefined; -} - -function resolveSubagentLabel(title: Agent["title"] | null | undefined): string | null { - if (typeof title !== "string") { - return null; - } - const normalized = title.trim(); - if (!normalized) { - return null; - } - if (normalized.toLowerCase() === "new agent") { - return null; - } - return normalized; -} - -export function resolveArchiveSubagentDialog( - input: ResolveArchiveSubagentDialogInput, -): ConfirmDialogInput { - const subagentLabel = resolveSubagentLabel(input.title) ?? "this subagent"; - const isRunning = input.status === "running" || input.status === "initializing"; - - return { - title: isRunning ? "Archive running subagent?" : "Archive subagent?", - message: isRunning - ? `${subagentLabel} is still running. Archiving it will stop the subagent and remove it from the track.` - : `Remove ${subagentLabel} from the track. The subagent will be archived.`, - confirmLabel: "Archive", - cancelLabel: "Cancel", - destructive: true, - }; -} - export function useArchiveSubagent(input: UseArchiveSubagentInput): (subagentId: string) => void { const { archiveAgent } = useArchiveAgent(); const { serverId } = input; return useCallback( - async (subagentId: string) => { - const subagent = useSessionStore.getState().sessions[serverId]?.agents?.get(subagentId); - const confirmed = await confirmDialog( - resolveArchiveSubagentDialog({ - title: subagent?.title, - status: subagent?.status, - }), + (subagentId: string) => { + void requestArchiveSubagent( + { serverId, subagentId }, + { + getSubagent: (id): ResolveArchiveSubagentDialogInput | undefined => + useSessionStore.getState().sessions[serverId]?.agents?.get(id), + confirm: confirmDialog, + archiveAgent, + }, ); - if (!confirmed) { - return; - } - void archiveAgent({ serverId, agentId: subagentId }).catch(() => {}); }, [archiveAgent, serverId], ); diff --git a/packages/app/src/terminal/drop/terminal-file-drop.test.ts b/packages/app/src/terminal/drop/terminal-file-drop.test.ts index d5a51327b..01147eb7b 100644 --- a/packages/app/src/terminal/drop/terminal-file-drop.test.ts +++ b/packages/app/src/terminal/drop/terminal-file-drop.test.ts @@ -1,7 +1,5 @@ -/** - * @vitest-environment jsdom - */ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import type { DesktopHostBridge } from "@/desktop/host"; import { extractTerminalDropPaths, isTerminalDragLeaveOutside, @@ -10,13 +8,6 @@ import { prepareDroppedPathsForTerminal, } from "./terminal-file-drop"; -function setDesktopBridge(bridge: unknown): void { - Object.defineProperty(window, "paseoDesktop", { - configurable: true, - value: bridge, - }); -} - function dataTransfer(input: { types?: string[]; files?: File[] }): DataTransfer { return { types: input.types ?? [], @@ -24,10 +15,25 @@ function dataTransfer(input: { types?: string[]; files?: File[] }): DataTransfer } as unknown as DataTransfer; } -afterEach(() => { - vi.restoreAllMocks(); - delete (window as unknown as { paseoDesktop?: unknown }).paseoDesktop; -}); +function fakeFile(input: { name: string; legacyPath?: string }): File { + const file = { name: input.name } as unknown as File; + if (input.legacyPath !== undefined) { + Object.defineProperty(file, "path", { + configurable: true, + value: input.legacyPath, + }); + } + return file; +} + +function makeNode(children: readonly EventTarget[] = []): EventTarget { + return { + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + contains: (other: EventTarget | null) => other !== null && children.includes(other), + } as unknown as EventTarget; +} describe("terminal file drop", () => { it("detects file drags", () => { @@ -37,77 +43,75 @@ describe("terminal file drop", () => { }); it("keeps drag highlight active when moving between terminal children", () => { - const root = document.createElement("div"); - const child = document.createElement("div"); - const outside = document.createElement("div"); - root.appendChild(child); + const child = makeNode(); + const outside = makeNode(); + const root = makeNode([child]); expect(isTerminalDragLeaveOutside({ currentTarget: root, relatedTarget: child })).toBe(false); expect(isTerminalDragLeaveOutside({ currentTarget: root, relatedTarget: outside })).toBe(true); expect(isTerminalDragLeaveOutside({ currentTarget: root, relatedTarget: null })).toBe(true); + expect(isTerminalDragLeaveOutside({ currentTarget: null, relatedTarget: child })).toBe(true); }); it("extracts paths through Electron webUtils", () => { - const file = new File(["image"], "photo.png", { type: "image/png" }); + const file = fakeFile({ name: "photo.png" }); const getPathForFile = vi.fn(() => "/Users/me/Desktop/photo.png"); - setDesktopBridge({ - webUtils: { getPathForFile }, - }); + const bridge: DesktopHostBridge = { webUtils: { getPathForFile } }; - expect(extractTerminalDropPaths(dataTransfer({ types: ["Files"], files: [file] }))).toEqual([ - "/Users/me/Desktop/photo.png", - ]); + expect( + extractTerminalDropPaths(dataTransfer({ types: ["Files"], files: [file] }), bridge), + ).toEqual(["/Users/me/Desktop/photo.png"]); expect(getPathForFile).toHaveBeenCalledWith(file); }); it("falls back to legacy Electron file paths", () => { - const file = new File(["image"], "photo.png", { type: "image/png" }); - Object.defineProperty(file, "path", { - configurable: true, - value: "/tmp/legacy-photo.png", - }); - setDesktopBridge({ + const file = fakeFile({ name: "photo.png", legacyPath: "/tmp/legacy-photo.png" }); + const bridge: DesktopHostBridge = { webUtils: { getPathForFile: vi.fn(() => { throw new Error("not available"); }), }, - }); + }; - expect(extractTerminalDropPaths(dataTransfer({ types: ["Files"], files: [file] }))).toEqual([ - "/tmp/legacy-photo.png", - ]); + expect( + extractTerminalDropPaths(dataTransfer({ types: ["Files"], files: [file] }), bridge), + ).toEqual(["/tmp/legacy-photo.png"]); }); it("drops browser files that have no filesystem path", () => { - const file = new File(["image"], "photo.png", { type: "image/png" }); + const file = fakeFile({ name: "photo.png" }); - expect(extractTerminalDropPaths(dataTransfer({ types: ["Files"], files: [file] }))).toEqual([]); + expect( + extractTerminalDropPaths(dataTransfer({ types: ["Files"], files: [file] }), null), + ).toEqual([]); }); it("prepares POSIX paths with conservative escaping", () => { - setDesktopBridge({ platform: "darwin" }); + const bridge: DesktopHostBridge = { platform: "darwin" }; - expect(prepareDroppedPathForTerminal("/tmp/my image.png")).toBe("'/tmp/my image.png'"); - expect(prepareDroppedPathForTerminal("/tmp/a$(touch bad).png")).toBe("'/tmp/a(touch bad).png'"); - expect(prepareDroppedPathForTerminal("/tmp/it's.png")).toBe("'/tmp/it\\'s.png'"); + expect(prepareDroppedPathForTerminal("/tmp/my image.png", bridge)).toBe("'/tmp/my image.png'"); + expect(prepareDroppedPathForTerminal("/tmp/a$(touch bad).png", bridge)).toBe( + "'/tmp/a(touch bad).png'", + ); + expect(prepareDroppedPathForTerminal("/tmp/it's.png", bridge)).toBe("'/tmp/it\\'s.png'"); }); it("prepares Windows paths with space quoting", () => { - setDesktopBridge({ platform: "win32" }); + const bridge: DesktopHostBridge = { platform: "win32" }; - expect(prepareDroppedPathForTerminal("C:\\Users\\me\\photo.png")).toBe( + expect(prepareDroppedPathForTerminal("C:\\Users\\me\\photo.png", bridge)).toBe( "C:\\Users\\me\\photo.png", ); - expect(prepareDroppedPathForTerminal("C:\\Users\\me\\photo one.png")).toBe( + expect(prepareDroppedPathForTerminal("C:\\Users\\me\\photo one.png", bridge)).toBe( '"C:\\Users\\me\\photo one.png"', ); }); it("joins multiple dropped paths for one terminal input", () => { - setDesktopBridge({ platform: "darwin" }); + const bridge: DesktopHostBridge = { platform: "darwin" }; - expect(prepareDroppedPathsForTerminal(["/tmp/a.png", "/tmp/b c.png"])).toBe( + expect(prepareDroppedPathsForTerminal(["/tmp/a.png", "/tmp/b c.png"], bridge)).toBe( "'/tmp/a.png' '/tmp/b c.png'", ); }); diff --git a/packages/app/src/terminal/drop/terminal-file-drop.ts b/packages/app/src/terminal/drop/terminal-file-drop.ts index df0a97700..44dab5b14 100644 --- a/packages/app/src/terminal/drop/terminal-file-drop.ts +++ b/packages/app/src/terminal/drop/terminal-file-drop.ts @@ -1,27 +1,13 @@ -interface DesktopFilePathBridge { - platform?: string; - webUtils?: { - getPathForFile?: (file: File) => string; - }; -} +import type { DesktopHostBridge } from "@/desktop/host"; const DANGEROUS_NON_WINDOWS_PATH_CHARS = /[`$|&>~#!^*;<]/g; -function getDesktopBridge(): DesktopFilePathBridge | null { - if (typeof window === "undefined") { - return null; - } - const bridge = (window as unknown as { paseoDesktop?: DesktopFilePathBridge }).paseoDesktop; - return bridge && typeof bridge === "object" ? bridge : null; -} - function getLegacyFilePath(file: File): string | null { const path = Reflect.get(file, "path"); return typeof path === "string" && path.length > 0 ? path : null; } -function getFilePath(file: File): string | null { - const bridge = getDesktopBridge(); +function getFilePath(file: File, bridge: DesktopHostBridge | null): string | null { const getPathForFile = bridge?.webUtils?.getPathForFile; if (typeof getPathForFile === "function") { try { @@ -40,24 +26,39 @@ export function isTerminalFileDrag(dataTransfer: DataTransfer | null): boolean { return Boolean(dataTransfer && Array.from(dataTransfer.types).includes("Files")); } +interface ContainsTarget { + contains: (other: EventTarget | null) => boolean; +} + +function asContainsTarget(value: EventTarget | null): ContainsTarget | null { + if (!value || typeof (value as Partial).contains !== "function") { + return null; + } + return value as unknown as ContainsTarget; +} + export function isTerminalDragLeaveOutside(input: { currentTarget: EventTarget | null; relatedTarget: EventTarget | null; }): boolean { - if (!(input.currentTarget instanceof Node) || !(input.relatedTarget instanceof Node)) { + const currentTarget = asContainsTarget(input.currentTarget); + if (!currentTarget || !input.relatedTarget) { return true; } - return !input.currentTarget.contains(input.relatedTarget); + return !currentTarget.contains(input.relatedTarget); } -export function extractTerminalDropPaths(dataTransfer: DataTransfer | null): string[] { +export function extractTerminalDropPaths( + dataTransfer: DataTransfer | null, + bridge: DesktopHostBridge | null, +): string[] { if (!dataTransfer) { return []; } const paths: string[] = []; for (const file of Array.from(dataTransfer.files)) { - const path = getFilePath(file); + const path = getFilePath(file, bridge); if (path) { paths.push(path); } @@ -89,14 +90,19 @@ function escapeWindowsPath(path: string): string { return `"${path}"`; } -export function prepareDroppedPathForTerminal(path: string): string { - const platform = getDesktopBridge()?.platform; - if (platform === "win32") { +export function prepareDroppedPathForTerminal( + path: string, + bridge: DesktopHostBridge | null, +): string { + if (bridge?.platform === "win32") { return escapeWindowsPath(path); } return escapeNonWindowsPath(path); } -export function prepareDroppedPathsForTerminal(paths: readonly string[]): string { - return paths.map(prepareDroppedPathForTerminal).join(" "); +export function prepareDroppedPathsForTerminal( + paths: readonly string[], + bridge: DesktopHostBridge | null, +): string { + return paths.map((path) => prepareDroppedPathForTerminal(path, bridge)).join(" "); } diff --git a/packages/app/src/utils/client-id.test.ts b/packages/app/src/utils/client-id.test.ts index 3b7be3e22..647ea48b0 100644 --- a/packages/app/src/utils/client-id.test.ts +++ b/packages/app/src/utils/client-id.test.ts @@ -1,45 +1,79 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; +import { createClientIdResolver, type ClientIdStorage } from "./client-id"; -const asyncStorageMock = vi.hoisted(() => ({ - getItem: vi.fn<(_: string) => Promise>(), - setItem: vi.fn<(_: string, __: string) => Promise>(), -})); +interface InMemoryStorage extends ClientIdStorage { + items: Map; + setCallCount: number; +} -vi.mock("@react-native-async-storage/async-storage", () => ({ - default: asyncStorageMock, -})); +function inMemoryStorage(initial: Record = {}): InMemoryStorage { + const items = new Map(Object.entries(initial)); + const storage: InMemoryStorage = { + items, + setCallCount: 0, + async getItem(key) { + return items.get(key) ?? null; + }, + async setItem(key, value) { + storage.setCallCount += 1; + items.set(key, value); + }, + }; + return storage; +} -describe("client-id", () => { - beforeEach(() => { - vi.resetModules(); - asyncStorageMock.getItem.mockReset(); - asyncStorageMock.setItem.mockReset(); +describe("clientIdResolver", () => { + it("returns the stored client id when present and does not regenerate", async () => { + const storage = inMemoryStorage({ "@paseo:client-id-v1": "cid_existing" }); + const resolver = createClientIdResolver({ + storage, + generateUuid: () => { + throw new Error("generateUuid should not run when an id is stored"); + }, + }); + + expect(await resolver.getOrCreate()).toBe("cid_existing"); + expect(storage.setCallCount).toBe(0); }); - it("returns stored client id when present", async () => { - asyncStorageMock.getItem.mockResolvedValue("cid_existing"); - const mod = await import("./client-id"); + it("creates and persists a new client id when storage is empty", async () => { + const storage = inMemoryStorage(); + const resolver = createClientIdResolver({ + storage, + generateUuid: () => "123456781234123412341234567890ab", + }); - const key = await mod.getOrCreateClientId(); - expect(key).toBe("cid_existing"); - expect(asyncStorageMock.getItem).toHaveBeenCalledTimes(1); - expect(asyncStorageMock.setItem).not.toHaveBeenCalled(); + expect(await resolver.getOrCreate()).toBe("cid_123456781234123412341234567890ab"); + expect(storage.items.get("@paseo:client-id-v1")).toBe("cid_123456781234123412341234567890ab"); }); - it("creates and persists a client id when missing", async () => { - asyncStorageMock.getItem.mockResolvedValue(null); - asyncStorageMock.setItem.mockResolvedValue(); - vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue( - "12345678-1234-1234-1234-1234567890ab", - ); + it("dedupes concurrent callers behind a single storage write", async () => { + const storage = inMemoryStorage(); + let uuidCalls = 0; + const resolver = createClientIdResolver({ + storage, + generateUuid: () => { + uuidCalls += 1; + return "abcdef0123456789abcdef0123456789"; + }, + }); - const mod = await import("./client-id"); - const key = await mod.getOrCreateClientId(); + const [first, second] = await Promise.all([resolver.getOrCreate(), resolver.getOrCreate()]); - expect(key).toBe("cid_123456781234123412341234567890ab"); - expect(asyncStorageMock.setItem).toHaveBeenCalledWith( - "@paseo:client-id-v1", - "cid_123456781234123412341234567890ab", - ); + expect(first).toBe("cid_abcdef0123456789abcdef0123456789"); + expect(second).toBe(first); + expect(uuidCalls).toBe(1); + expect(storage.setCallCount).toBe(1); + }); + + it("ignores stored blank strings and treats them as missing", async () => { + const storage = inMemoryStorage({ "@paseo:client-id-v1": " " }); + const resolver = createClientIdResolver({ + storage, + generateUuid: () => "newuuid", + }); + + expect(await resolver.getOrCreate()).toBe("cid_newuuid"); + expect(storage.items.get("@paseo:client-id-v1")).toBe("cid_newuuid"); }); }); diff --git a/packages/app/src/utils/client-id.ts b/packages/app/src/utils/client-id.ts index 19f307a91..52397e91b 100644 --- a/packages/app/src/utils/client-id.ts +++ b/packages/app/src/utils/client-id.ts @@ -2,18 +2,13 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; const CLIENT_ID_STORAGE_KEY = "@paseo:client-id-v1"; -let cachedClientId: string | null = null; -let inFlightClientId: Promise | null = null; +export interface ClientIdStorage { + getItem(key: string): Promise; + setItem(key: string, value: string): Promise; +} -function generateClientId(): string { - const randomUuid = (() => { - const cryptoObj = globalThis.crypto as { randomUUID?: () => string } | undefined; - if (cryptoObj && typeof cryptoObj.randomUUID === "function") { - return cryptoObj.randomUUID().replace(/-/g, ""); - } - return `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`; - })(); - return `cid_${randomUuid}`; +export interface ClientIdResolver { + getOrCreate(): Promise; } function normalizeStoredClientId(value: unknown): string | null { @@ -24,31 +19,60 @@ function normalizeStoredClientId(value: unknown): string | null { return trimmed.length > 0 ? trimmed : null; } -export async function getOrCreateClientId(): Promise { - if (cachedClientId) { - return cachedClientId; - } - if (inFlightClientId) { - return inFlightClientId; - } +export function createClientIdResolver(deps: { + storage: ClientIdStorage; + generateUuid: () => string; + storageKey?: string; +}): ClientIdResolver { + const storageKey = deps.storageKey ?? CLIENT_ID_STORAGE_KEY; + let cached: string | null = null; + let inFlight: Promise | null = null; - inFlightClientId = (async () => { - const storedValue = await AsyncStorage.getItem(CLIENT_ID_STORAGE_KEY); - const existing = normalizeStoredClientId(storedValue); - if (existing) { - cachedClientId = existing; - return existing; - } + return { + async getOrCreate(): Promise { + if (cached) { + return cached; + } + if (inFlight) { + return inFlight; + } - const nextValue = generateClientId(); - await AsyncStorage.setItem(CLIENT_ID_STORAGE_KEY, nextValue); - cachedClientId = nextValue; - return nextValue; - })(); + inFlight = (async () => { + const stored = await deps.storage.getItem(storageKey); + const existing = normalizeStoredClientId(stored); + if (existing) { + cached = existing; + return existing; + } - try { - return await inFlightClientId; - } finally { - inFlightClientId = null; + const next = `cid_${deps.generateUuid()}`; + await deps.storage.setItem(storageKey, next); + cached = next; + return next; + })(); + + try { + return await inFlight; + } finally { + inFlight = null; + } + }, + }; +} + +function generateUuidFromGlobalCrypto(): string { + const cryptoObj = globalThis.crypto as { randomUUID?: () => string } | undefined; + if (cryptoObj && typeof cryptoObj.randomUUID === "function") { + return cryptoObj.randomUUID().replace(/-/g, ""); } + return `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`; +} + +const defaultResolver = createClientIdResolver({ + storage: AsyncStorage, + generateUuid: generateUuidFromGlobalCrypto, +}); + +export async function getOrCreateClientId(): Promise { + return defaultResolver.getOrCreate(); } diff --git a/packages/app/src/utils/navigate-to-agent.test.ts b/packages/app/src/utils/navigate-to-agent.test.ts deleted file mode 100644 index 627186b6c..000000000 --- a/packages/app/src/utils/navigate-to-agent.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const { routerMock } = vi.hoisted(() => ({ - routerMock: { - dismissTo: vi.fn(), - navigate: vi.fn(), - replace: vi.fn(), - }, -})); - -vi.mock("expo-router", () => ({ - router: routerMock, - useLocalSearchParams: () => ({}), - usePathname: () => "/", -})); - -vi.mock("@react-native-async-storage/async-storage", () => { - const storage = new Map(); - return { - default: { - getItem: vi.fn(async (key: string) => storage.get(key) ?? null), - setItem: vi.fn(async (key: string, value: string) => { - storage.set(key, value); - }), - removeItem: vi.fn(async (key: string) => { - storage.delete(key); - }), - }, - }; -}); - -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { useSessionStore, type Agent, type WorkspaceDescriptor } from "@/stores/session-store"; -import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; -import { navigateToAgent } from "@/utils/navigate-to-agent"; - -const SERVER_ID = "server-1"; -const WORKSPACE_ID = "workspace-1"; -const AGENT_ID = "agent-1"; - -function createWorkspace(): WorkspaceDescriptor { - return { - id: WORKSPACE_ID, - projectId: "project-1", - projectDisplayName: "Project", - projectRootPath: "/repo", - workspaceDirectory: "/repo/worktree", - projectKind: "git", - workspaceKind: "local_checkout", - name: "worktree", - status: "done", - archivingAt: null, - diffStat: null, - scripts: [], - }; -} - -function createAgent(input: Partial = {}): Agent { - return { - serverId: SERVER_ID, - id: AGENT_ID, - provider: "codex", - status: "closed", - createdAt: new Date("2026-05-09T00:00:00.000Z"), - updatedAt: new Date("2026-05-09T00:00:00.000Z"), - lastUserMessageAt: null, - lastActivityAt: new Date("2026-05-09T00:00:00.000Z"), - capabilities: { - supportsStreaming: false, - supportsSessionPersistence: false, - supportsDynamicModes: false, - supportsMcpServers: false, - supportsReasoningStream: false, - supportsToolInvocations: false, - }, - currentModeId: null, - availableModes: [], - pendingPermissions: [], - persistence: null, - title: "Archived agent", - cwd: "/repo/worktree", - model: null, - thinkingOptionId: null, - archivedAt: new Date("2026-05-09T00:00:00.000Z"), - parentAgentId: null, - labels: {}, - ...input, - }; -} - -describe("navigateToAgent", () => { - beforeEach(() => { - routerMock.dismissTo.mockReset(); - routerMock.navigate.mockReset(); - routerMock.replace.mockReset(); - useSessionStore.getState().clearSession(SERVER_ID); - useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient); - useSessionStore - .getState() - .setWorkspaces(SERVER_ID, new Map([[WORKSPACE_ID, createWorkspace()]])); - useWorkspaceLayoutStore.setState({ - layoutByWorkspace: {}, - splitSizesByWorkspace: {}, - pinnedAgentIdsByWorkspace: {}, - hiddenAgentIdsByWorkspace: {}, - }); - }); - - it("opens archived agent details through the resolved workspace", () => { - useSessionStore.getState().setAgentDetails(SERVER_ID, new Map([[AGENT_ID, createAgent()]])); - - const route = navigateToAgent({ serverId: SERVER_ID, agentId: AGENT_ID, pin: true }); - - expect(route).toBe("/h/server-1/workspace/workspace-1"); - expect(routerMock.navigate).not.toHaveBeenCalled(); - expect(routerMock.dismissTo).toHaveBeenCalledWith("/h/server-1/workspace/workspace-1"); - const key = `${SERVER_ID}:${WORKSPACE_ID}`; - expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(key)).toEqual([ - expect.objectContaining({ target: { kind: "agent", agentId: AGENT_ID } }), - ]); - expect(useWorkspaceLayoutStore.getState().pinnedAgentIdsByWorkspace[key]).toEqual( - new Set([AGENT_ID]), - ); - }); - - it("falls back to the host agent route when the workspace is unknown", () => { - const route = navigateToAgent({ serverId: SERVER_ID, agentId: "missing-agent" }); - - expect(route).toBe("/h/server-1/agent/missing-agent"); - expect(routerMock.navigate).toHaveBeenCalledWith("/h/server-1/agent/missing-agent"); - expect(routerMock.dismissTo).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/app/src/utils/navigate-to-agent.ts b/packages/app/src/utils/navigate-to-agent.ts deleted file mode 100644 index 9dc73767a..000000000 --- a/packages/app/src/utils/navigate-to-agent.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { router, type Href } from "expo-router"; -import { useSessionStore } from "@/stores/session-store"; -import { buildHostAgentDetailRoute } from "@/utils/host-routes"; -import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution"; -import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation"; - -interface NavigateToAgentInput { - serverId: string; - agentId: string; - currentPathname?: string | null; - pin?: boolean; -} - -export function navigateToAgent(input: NavigateToAgentInput): string { - const session = useSessionStore.getState().sessions[input.serverId]; - const agent = session?.agents.get(input.agentId) ?? session?.agentDetails.get(input.agentId); - const workspaceId = resolveWorkspaceIdByExecutionDirectory({ - workspaces: session?.workspaces.values(), - workspaceDirectory: agent?.cwd, - }); - - if (!workspaceId) { - const route = buildHostAgentDetailRoute(input.serverId, input.agentId); - router.navigate(route as Href); - return route; - } - - return navigateToPreparedWorkspaceTab({ - serverId: input.serverId, - workspaceId, - target: { kind: "agent", agentId: input.agentId }, - currentPathname: input.currentPathname, - pin: input.pin, - }); -} diff --git a/packages/app/src/utils/navigate-to-agent/index.ts b/packages/app/src/utils/navigate-to-agent/index.ts new file mode 100644 index 000000000..44d412ecb --- /dev/null +++ b/packages/app/src/utils/navigate-to-agent/index.ts @@ -0,0 +1,23 @@ +import { router, type Href } from "expo-router"; +import { useSessionStore } from "@/stores/session-store"; +import { resolveNavigateToAgent, type NavigateToAgentInput } from "./resolve"; +import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation"; + +export type { NavigateToAgentInput } from "./resolve"; + +export function navigateToAgent(input: NavigateToAgentInput): string { + return resolveNavigateToAgent(input, { + readAgentNavTarget: ({ serverId, agentId }) => { + const session = useSessionStore.getState().sessions[serverId]; + const agent = session?.agents.get(agentId) ?? session?.agentDetails.get(agentId); + return { + workspaces: session?.workspaces.values(), + agentCwd: agent?.cwd, + }; + }, + navigateToHostAgent: (route) => { + router.navigate(route as Href); + }, + navigateToPreparedWorkspaceTab, + }); +} diff --git a/packages/app/src/utils/navigate-to-agent/resolve.test.ts b/packages/app/src/utils/navigate-to-agent/resolve.test.ts new file mode 100644 index 000000000..de61b33be --- /dev/null +++ b/packages/app/src/utils/navigate-to-agent/resolve.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; +import type { WorkspaceDescriptor } from "@/stores/session-store"; +import { + resolveNavigateToAgent, + type AgentNavTarget, + type NavigateToAgentDeps, +} from "@/utils/navigate-to-agent/resolve"; +import type { NavigateToPreparedWorkspaceTabInput } from "@/utils/prepare-workspace-tab"; + +const SERVER_ID = "server-1"; +const WORKSPACE_ID = "workspace-1"; +const AGENT_ID = "agent-1"; + +function createWorkspace(): WorkspaceDescriptor { + return { + id: WORKSPACE_ID, + projectId: "project-1", + projectDisplayName: "Project", + projectRootPath: "/repo", + workspaceDirectory: "/repo/worktree", + projectKind: "git", + workspaceKind: "local_checkout", + name: "worktree", + status: "done", + archivingAt: null, + diffStat: null, + scripts: [], + }; +} + +interface RecordedHostNav { + route: string; +} + +interface RecordedTabNav extends NavigateToPreparedWorkspaceTabInput {} + +function createFakeNavigators(target: AgentNavTarget): { + deps: NavigateToAgentDeps; + hostNavigations: RecordedHostNav[]; + tabNavigations: RecordedTabNav[]; +} { + const hostNavigations: RecordedHostNav[] = []; + const tabNavigations: RecordedTabNav[] = []; + return { + hostNavigations, + tabNavigations, + deps: { + readAgentNavTarget: () => target, + navigateToHostAgent: (route) => { + hostNavigations.push({ route }); + }, + navigateToPreparedWorkspaceTab: (input) => { + tabNavigations.push(input); + return `/h/${input.serverId}/workspace/${input.workspaceId}`; + }, + }, + }; +} + +describe("resolveNavigateToAgent", () => { + it("opens the resolved workspace tab when the agent's cwd matches a workspace", () => { + const { deps, hostNavigations, tabNavigations } = createFakeNavigators({ + workspaces: [createWorkspace()], + agentCwd: "/repo/worktree", + }); + + const route = resolveNavigateToAgent( + { serverId: SERVER_ID, agentId: AGENT_ID, pin: true }, + deps, + ); + + expect(route).toBe("/h/server-1/workspace/workspace-1"); + expect(hostNavigations).toEqual([]); + expect(tabNavigations).toEqual([ + { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + target: { kind: "agent", agentId: AGENT_ID }, + currentPathname: undefined, + pin: true, + }, + ]); + }); + + it("falls back to the host agent route when the workspace is unknown", () => { + const { deps, hostNavigations, tabNavigations } = createFakeNavigators({ + workspaces: [], + agentCwd: null, + }); + + const route = resolveNavigateToAgent({ serverId: SERVER_ID, agentId: "missing-agent" }, deps); + + expect(route).toBe("/h/server-1/agent/missing-agent"); + expect(hostNavigations).toEqual([{ route: "/h/server-1/agent/missing-agent" }]); + expect(tabNavigations).toEqual([]); + }); +}); diff --git a/packages/app/src/utils/navigate-to-agent/resolve.ts b/packages/app/src/utils/navigate-to-agent/resolve.ts new file mode 100644 index 000000000..a9c286924 --- /dev/null +++ b/packages/app/src/utils/navigate-to-agent/resolve.ts @@ -0,0 +1,50 @@ +import type { WorkspaceDescriptor } from "@/stores/session-store"; +import { buildHostAgentDetailRoute } from "@/utils/host-routes"; +import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution"; +import type { NavigateToPreparedWorkspaceTabInput } from "@/utils/prepare-workspace-tab"; + +export interface NavigateToAgentInput { + serverId: string; + agentId: string; + currentPathname?: string | null; + pin?: boolean; +} + +export interface AgentNavTarget { + workspaces: Iterable | null | undefined; + agentCwd: string | null | undefined; +} + +export interface NavigateToAgentDeps { + readAgentNavTarget: (input: { serverId: string; agentId: string }) => AgentNavTarget; + navigateToHostAgent: (route: string) => void; + navigateToPreparedWorkspaceTab: (input: NavigateToPreparedWorkspaceTabInput) => string; +} + +export function resolveNavigateToAgent( + input: NavigateToAgentInput, + deps: NavigateToAgentDeps, +): string { + const { workspaces, agentCwd } = deps.readAgentNavTarget({ + serverId: input.serverId, + agentId: input.agentId, + }); + const workspaceId = resolveWorkspaceIdByExecutionDirectory({ + workspaces, + workspaceDirectory: agentCwd, + }); + + if (!workspaceId) { + const route = buildHostAgentDetailRoute(input.serverId, input.agentId); + deps.navigateToHostAgent(route); + return route; + } + + return deps.navigateToPreparedWorkspaceTab({ + serverId: input.serverId, + workspaceId, + target: { kind: "agent", agentId: input.agentId }, + currentPathname: input.currentPathname, + pin: input.pin, + }); +} diff --git a/packages/app/src/utils/prepare-workspace-tab.ts b/packages/app/src/utils/prepare-workspace-tab.ts new file mode 100644 index 000000000..ee116ff3b --- /dev/null +++ b/packages/app/src/utils/prepare-workspace-tab.ts @@ -0,0 +1,68 @@ +import { generateDraftId } from "@/stores/draft-keys"; +import { + buildWorkspaceTabPersistenceKey, + type WorkspaceTabTarget, +} from "@/stores/workspace-tabs-store"; +import { buildHostWorkspaceRoute } from "@/utils/host-routes"; + +export interface PrepareWorkspaceTabInput { + serverId: string; + workspaceId: string; + target: WorkspaceTabTarget; + pin?: boolean; +} + +export interface NavigateToPreparedWorkspaceTabInput extends PrepareWorkspaceTabInput { + currentPathname?: string | null; +} + +export interface PrepareWorkspaceTabDeps { + openTabFocused: (workspaceKey: string, target: WorkspaceTabTarget) => string | null; + pinAgent: (workspaceKey: string, agentId: string) => void; +} + +export interface NavigateToPreparedWorkspaceTabDeps extends PrepareWorkspaceTabDeps { + navigateToWorkspace: ( + serverId: string, + workspaceId: string, + options: { currentPathname?: string | null }, + ) => void; +} + +function getPreparedTarget(target: WorkspaceTabTarget): WorkspaceTabTarget { + if (target.kind !== "draft" || target.draftId.trim() !== "new") { + return target; + } + return { kind: "draft", draftId: generateDraftId() }; +} + +export function prepareWorkspaceTab( + input: PrepareWorkspaceTabInput, + deps: PrepareWorkspaceTabDeps, +): string { + const target = getPreparedTarget(input.target); + const key = + buildWorkspaceTabPersistenceKey({ + serverId: input.serverId, + workspaceId: input.workspaceId, + }) ?? ""; + + deps.openTabFocused(key, target); + + if (input.pin && target.kind === "agent") { + deps.pinAgent(key, target.agentId); + } + + return buildHostWorkspaceRoute(input.serverId, input.workspaceId); +} + +export function navigateToPreparedWorkspaceTab( + input: NavigateToPreparedWorkspaceTabInput, + deps: NavigateToPreparedWorkspaceTabDeps, +): string { + const route = prepareWorkspaceTab(input, deps); + deps.navigateToWorkspace(input.serverId, input.workspaceId, { + currentPathname: input.currentPathname, + }); + return route; +} diff --git a/packages/app/src/utils/rich-clipboard-default-environment.ts b/packages/app/src/utils/rich-clipboard-default-environment.ts new file mode 100644 index 000000000..2f32c314d --- /dev/null +++ b/packages/app/src/utils/rich-clipboard-default-environment.ts @@ -0,0 +1,30 @@ +import * as Clipboard from "expo-clipboard"; +import { isWeb } from "@/constants/platform"; +import type { MarkdownClipboardEnvironment, RichClipboardWriter } from "@/utils/rich-clipboard"; + +export function getDefaultMarkdownClipboardEnvironment(): MarkdownClipboardEnvironment { + return { + richWriter: getWebRichClipboardWriter(), + writePlainText: (text) => Clipboard.setStringAsync(text), + }; +} + +function getWebRichClipboardWriter(): RichClipboardWriter | null { + if (!isWeb) { + return null; + } + if (typeof navigator === "undefined" || typeof navigator.clipboard?.write !== "function") { + return null; + } + if (typeof ClipboardItem === "undefined") { + return null; + } + + return { + supportsHtml: () => + typeof ClipboardItem.supports !== "function" || ClipboardItem.supports("text/html"), + write: async (data) => { + await navigator.clipboard.write([new ClipboardItem(data)]); + }, + }; +} diff --git a/packages/app/src/utils/rich-clipboard.test.ts b/packages/app/src/utils/rich-clipboard.test.ts index b204f3786..129d87c23 100644 --- a/packages/app/src/utils/rich-clipboard.test.ts +++ b/packages/app/src/utils/rich-clipboard.test.ts @@ -1,21 +1,43 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { createMarkdownClipboardContent, + type MarkdownClipboardEnvironment, type RichClipboardWriter, writeMarkdownToRichClipboard, } from "./rich-clipboard"; -const { setStringAsyncMock } = vi.hoisted(() => ({ - setStringAsyncMock: vi.fn(async () => true), -})); +interface RecordingClipboard { + environment: MarkdownClipboardEnvironment; + richWrites: Array>; + plainTexts: string[]; +} -vi.mock("expo-clipboard", () => ({ - setStringAsync: setStringAsyncMock, -})); +function createRecordingClipboard( + options: { supportsHtml?: boolean; richWriteFails?: boolean } = {}, +): RecordingClipboard { + const { supportsHtml = true, richWriteFails = false } = options; + const richWrites: Array> = []; + const plainTexts: string[] = []; -beforeEach(() => { - setStringAsyncMock.mockClear(); -}); + const richWriter: RichClipboardWriter = { + supportsHtml: () => supportsHtml, + write: async (data) => { + if (richWriteFails) { + throw new Error("clipboard denied"); + } + richWrites.push(data); + }, + }; + + const environment: MarkdownClipboardEnvironment = { + richWriter, + writePlainText: async (text) => { + plainTexts.push(text); + }, + }; + + return { environment, richWrites, plainTexts }; +} describe("createMarkdownClipboardContent", () => { it("renders markdown structures to clipboard html", () => { @@ -56,42 +78,25 @@ describe("createMarkdownClipboardContent", () => { describe("writeMarkdownToRichClipboard", () => { it("writes plain text and html when a rich clipboard writer is available", async () => { - const markdown = "- item"; - const writes: Array[0]> = []; - const richWriter: RichClipboardWriter = { - supportsHtml: () => true, - write: async (data) => { - writes.push(data); - }, - }; + const clipboard = createRecordingClipboard(); - await writeMarkdownToRichClipboard(markdown, { - richWriter, - writePlainText: setStringAsyncMock, - }); + await writeMarkdownToRichClipboard("- item", clipboard.environment); - const written = writes[0]; + const written = clipboard.richWrites[0]; if (!written) { throw new Error("Expected rich clipboard data to be written"); } - await expect(written["text/plain"].text()).resolves.toBe(markdown); + await expect(written["text/plain"].text()).resolves.toBe("- item"); await expect(written["text/html"].text()).resolves.toContain("
  • item
  • "); - expect(setStringAsyncMock).not.toHaveBeenCalled(); + expect(clipboard.plainTexts).toEqual([]); }); it("falls back to plain text when rich clipboard writing fails", async () => { - const richWriter: RichClipboardWriter = { - supportsHtml: () => true, - write: async () => { - throw new Error("clipboard denied"); - }, - }; + const clipboard = createRecordingClipboard({ richWriteFails: true }); - await writeMarkdownToRichClipboard("**bold**", { - richWriter, - writePlainText: setStringAsyncMock, - }); + await writeMarkdownToRichClipboard("**bold**", clipboard.environment); - expect(setStringAsyncMock).toHaveBeenCalledWith("**bold**"); + expect(clipboard.plainTexts).toEqual(["**bold**"]); + expect(clipboard.richWrites).toEqual([]); }); }); diff --git a/packages/app/src/utils/rich-clipboard.ts b/packages/app/src/utils/rich-clipboard.ts index bd0a826e7..ad4030333 100644 --- a/packages/app/src/utils/rich-clipboard.ts +++ b/packages/app/src/utils/rich-clipboard.ts @@ -1,6 +1,4 @@ -import * as Clipboard from "expo-clipboard"; import MarkdownIt from "markdown-it"; -import { isWeb } from "@/constants/platform"; const markdownRenderer = new MarkdownIt({ html: false, @@ -34,7 +32,7 @@ export function createMarkdownClipboardContent(markdown: string): MarkdownClipbo export async function writeMarkdownToRichClipboard( markdown: string, - environment: MarkdownClipboardEnvironment = getDefaultMarkdownClipboardEnvironment(), + environment: MarkdownClipboardEnvironment, ): Promise { if (environment.richWriter?.supportsHtml()) { const content = createMarkdownClipboardContent(markdown); @@ -52,30 +50,3 @@ export async function writeMarkdownToRichClipboard( await environment.writePlainText(markdown); } - -function getDefaultMarkdownClipboardEnvironment(): MarkdownClipboardEnvironment { - return { - richWriter: getWebRichClipboardWriter(), - writePlainText: (text) => Clipboard.setStringAsync(text), - }; -} - -function getWebRichClipboardWriter(): RichClipboardWriter | null { - if (!isWeb) { - return null; - } - if (typeof navigator === "undefined" || typeof navigator.clipboard?.write !== "function") { - return null; - } - if (typeof ClipboardItem === "undefined") { - return null; - } - - return { - supportsHtml: () => - typeof ClipboardItem.supports !== "function" || ClipboardItem.supports("text/html"), - write: async (data) => { - await navigator.clipboard.write([new ClipboardItem(data)]); - }, - }; -} diff --git a/packages/app/src/utils/sidebar-workspace-archive-redirect.ts b/packages/app/src/utils/sidebar-workspace-archive-redirect.ts index 1b0d8ed97..7c80d6821 100644 --- a/packages/app/src/utils/sidebar-workspace-archive-redirect.ts +++ b/packages/app/src/utils/sidebar-workspace-archive-redirect.ts @@ -1,26 +1,16 @@ import { router } from "expo-router"; -import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import { useSessionStore } from "@/stores/session-store"; -import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation"; +import { + redirectIfArchivingActiveWorkspace as redirectIfArchivingActiveWorkspacePure, + type RedirectIfArchivingActiveWorkspaceInput, +} from "@/utils/workspace-archive-redirect"; -export function redirectIfArchivingActiveWorkspace(input: { - serverId: string; - workspaceId: string; - activeWorkspaceSelection: ActiveWorkspaceSelection | null; -}): boolean { - if ( - input.activeWorkspaceSelection?.serverId !== input.serverId || - input.activeWorkspaceSelection.workspaceId !== input.workspaceId - ) { - return false; - } - - router.replace( - buildWorkspaceArchiveRedirectRoute({ - serverId: input.serverId, - archivedWorkspaceId: input.workspaceId, - workspaces: useSessionStore.getState().sessions[input.serverId]?.workspaces.values() ?? [], - }), - ); - return true; +export function redirectIfArchivingActiveWorkspace( + input: RedirectIfArchivingActiveWorkspaceInput, +): boolean { + return redirectIfArchivingActiveWorkspacePure(input, { + navigateToRoute: (route) => router.replace(route), + readWorkspaces: (serverId) => + useSessionStore.getState().sessions[serverId]?.workspaces.values() ?? [], + }); } diff --git a/packages/app/src/utils/tool-call-icon-name.test.ts b/packages/app/src/utils/tool-call-icon-name.test.ts new file mode 100644 index 000000000..14da899c3 --- /dev/null +++ b/packages/app/src/utils/tool-call-icon-name.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { resolveToolCallIconName } from "./tool-call-icon-name"; + +describe("resolveToolCallIconName", () => { + it("uses the bot icon for task sub-agent details", () => { + const icon = resolveToolCallIconName("Task", { + type: "sub_agent", + subAgentType: "Explore", + description: "Inspect repository", + log: "[Read] README.md", + }); + + expect(icon).toBe("bot"); + }); + + it("uses the bot icon for task calls without canonical detail", () => { + const icon = resolveToolCallIconName("Task", { + type: "unknown", + input: null, + output: null, + }); + + expect(icon).toBe("bot"); + expect(resolveToolCallIconName("Task")).toBe("bot"); + }); + + it("keeps the brain icon override for thinking calls with unknown detail", () => { + const icon = resolveToolCallIconName("thinking", { + type: "unknown", + input: null, + output: null, + }); + + expect(icon).toBe("brain"); + }); + + it("uses the custom icon from plain_text detail", () => { + const icon = resolveToolCallIconName("custom_tool", { + type: "plain_text", + label: "Background task completed", + icon: "sparkles", + text: "notification payload", + }); + + expect(icon).toBe("sparkles"); + }); + + it("does not special-case skill tool names", () => { + const icon = resolveToolCallIconName("skill", { + type: "plain_text", + label: "Skill output", + }); + + expect(icon).toBe("wrench"); + }); +}); diff --git a/packages/app/src/utils/tool-call-icon-name.ts b/packages/app/src/utils/tool-call-icon-name.ts new file mode 100644 index 000000000..28bfedb70 --- /dev/null +++ b/packages/app/src/utils/tool-call-icon-name.ts @@ -0,0 +1,45 @@ +import type { ToolCallDetail, ToolCallIconName } from "@getpaseo/protocol/agent-types"; +import { isPaseoToolName } from "@getpaseo/protocol/tool-name-normalization"; + +export type ToolCallIcon = ToolCallIconName | "paseo"; + +const TOOL_DETAIL_ICON_NAMES: Record = { + shell: "square_terminal", + read: "eye", + edit: "pencil", + write: "pencil", + search: "search", + fetch: "search", + worktree_setup: "square_terminal", + sub_agent: "bot", + plain_text: "wrench", + plan: "brain", + unknown: "wrench", +}; + +export function resolveToolCallIconName(toolName: string, detail?: ToolCallDetail): ToolCallIcon { + const lowerName = toolName.trim().toLowerCase(); + + if (detail?.type === "plain_text" && detail.icon) { + return detail.icon; + } + + // Thoughts are rendered through ToolCall with unknown detail payloads. + if (lowerName === "thinking" && (!detail || detail.type === "unknown")) { + return "brain"; + } + if (lowerName === "speak") { + return "mic_vocal"; + } + if (isPaseoToolName(lowerName)) { + return "paseo"; + } + if (lowerName === "task") { + return "bot"; + } + + if (detail) { + return TOOL_DETAIL_ICON_NAMES[detail.type]; + } + return "wrench"; +} diff --git a/packages/app/src/utils/tool-call-icon.test.ts b/packages/app/src/utils/tool-call-icon.test.ts deleted file mode 100644 index cc6588f84..000000000 --- a/packages/app/src/utils/tool-call-icon.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -const iconMocks = vi.hoisted(() => ({ - Bot: () => null, - Brain: () => null, - Eye: () => null, - MicVocal: () => null, - Pencil: () => null, - Search: () => null, - Sparkles: () => null, - SquareTerminal: () => null, - Wrench: () => null, -})); - -vi.mock("lucide-react-native", () => iconMocks); - -import { resolveToolCallIcon } from "./tool-call-icon"; - -describe("tool-call-icon", () => { - it("uses robot icon for task sub-agent details", () => { - const icon = resolveToolCallIcon("Task", { - type: "sub_agent", - subAgentType: "Explore", - description: "Inspect repository", - log: "[Read] README.md", - }); - - expect(icon).toBe(iconMocks.Bot); - }); - - it("uses robot icon for task calls without canonical detail", () => { - const icon = resolveToolCallIcon("Task", { - type: "unknown", - input: null, - output: null, - }); - - expect(icon).toBe(iconMocks.Bot); - expect(resolveToolCallIcon("Task")).toBe(iconMocks.Bot); - }); - - it("keeps thinking icon override for unknown detail", () => { - const icon = resolveToolCallIcon("thinking", { - type: "unknown", - input: null, - output: null, - }); - - expect(icon).toBe(iconMocks.Brain); - }); - - it("uses custom icon from plain_text detail", () => { - const icon = resolveToolCallIcon("custom_tool", { - type: "plain_text", - label: "Background task completed", - icon: "sparkles", - text: "notification payload", - }); - - expect(icon).toBe(iconMocks.Sparkles); - }); - - it("does not special-case skill tool names", () => { - const icon = resolveToolCallIcon("skill", { - type: "plain_text", - label: "Skill output", - }); - - expect(icon).toBe(iconMocks.Wrench); - }); -}); diff --git a/packages/app/src/utils/tool-call-icon.ts b/packages/app/src/utils/tool-call-icon.ts index 4643419c3..804b68c4c 100644 --- a/packages/app/src/utils/tool-call-icon.ts +++ b/packages/app/src/utils/tool-call-icon.ts @@ -10,27 +10,13 @@ import { SquareTerminal, Wrench, } from "lucide-react-native"; -import type { ToolCallDetail, ToolCallIconName } from "@getpaseo/protocol/agent-types"; -import { isPaseoToolName } from "@getpaseo/protocol/tool-name-normalization"; +import type { ToolCallDetail } from "@getpaseo/protocol/agent-types"; import { PaseoLogo } from "@/components/icons/paseo-logo"; +import { resolveToolCallIconName, type ToolCallIcon } from "./tool-call-icon-name"; export type ToolCallIconComponent = ComponentType<{ size?: number; color?: string }>; -const TOOL_DETAIL_ICONS: Record = { - shell: SquareTerminal, - read: Eye, - edit: Pencil, - write: Pencil, - search: Search, - fetch: Search, - worktree_setup: SquareTerminal, - sub_agent: Bot, - plain_text: Wrench, - plan: Brain, - unknown: Wrench, -}; - -const TOOL_ICON_BY_NAME: Record = { +const ICON_COMPONENTS: Record = { wrench: Wrench, square_terminal: SquareTerminal, eye: Eye, @@ -40,34 +26,16 @@ const TOOL_ICON_BY_NAME: Record = { sparkles: Sparkles, brain: Brain, mic_vocal: MicVocal, + paseo: PaseoLogo, }; +export function componentForToolCallIcon(name: ToolCallIcon): ToolCallIconComponent { + return ICON_COMPONENTS[name]; +} + export function resolveToolCallIcon( toolName: string, detail?: ToolCallDetail, ): ToolCallIconComponent { - const lowerName = toolName.trim().toLowerCase(); - - if (detail?.type === "plain_text" && detail.icon) { - return TOOL_ICON_BY_NAME[detail.icon]; - } - - // Thoughts are rendered through ToolCall with unknown detail payloads. - if (lowerName === "thinking" && (!detail || detail.type === "unknown")) { - return Brain; - } - if (lowerName === "speak") { - return MicVocal; - } - if (isPaseoToolName(lowerName)) { - return PaseoLogo; - } - if (lowerName === "task") { - return Bot; - } - - if (detail) { - return TOOL_DETAIL_ICONS[detail.type]; - } - return Wrench; + return componentForToolCallIcon(resolveToolCallIconName(toolName, detail)); } diff --git a/packages/app/src/utils/workspace-archive-navigation.test.ts b/packages/app/src/utils/workspace-archive-navigation.test.ts index a0d505f80..7787174f4 100644 --- a/packages/app/src/utils/workspace-archive-navigation.test.ts +++ b/packages/app/src/utils/workspace-archive-navigation.test.ts @@ -1,19 +1,10 @@ -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation"; import type { WorkspaceDescriptor } from "@/stores/session-store"; -import { useSessionStore } from "@/stores/session-store"; -import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect"; - -const { replaceMock } = vi.hoisted(() => ({ - replaceMock: vi.fn(), -})); - -vi.mock("expo-router", () => ({ - router: { - replace: replaceMock, - }, -})); +import { + redirectIfArchivingActiveWorkspace, + type RedirectIfArchivingActiveWorkspaceDeps, +} from "@/utils/workspace-archive-redirect"; function workspace( input: Partial & Pick, @@ -89,51 +80,60 @@ describe("buildWorkspaceArchiveRedirectRoute", () => { }); }); -describe("redirectIfArchivingActiveWorkspace", () => { - afterEach(() => { - replaceMock.mockClear(); - useSessionStore.getState().clearSession("server-1"); - }); +function createFakeRouter(workspaces: WorkspaceDescriptor[]): { + deps: RedirectIfArchivingActiveWorkspaceDeps; + routes: string[]; +} { + const routes: string[] = []; + return { + routes, + deps: { + navigateToRoute: (route) => { + routes.push(route); + }, + readWorkspaces: () => workspaces, + }, + }; +} +describe("redirectIfArchivingActiveWorkspace", () => { it("does not replace the route when archiving an inactive workspace", () => { - useSessionStore.getState().initializeSession("server-1", null as unknown as DaemonClient); - useSessionStore.getState().setWorkspaces( - "server-1", - new Map([ - ["main", workspace({ id: "main", workspaceKind: "local_checkout" })], - ["feature", workspace({ id: "feature", name: "feature" })], - ]), - ); + const { deps, routes } = createFakeRouter([ + workspace({ id: "main", workspaceKind: "local_checkout" }), + workspace({ id: "feature", name: "feature" }), + ]); + expect( - redirectIfArchivingActiveWorkspace({ - serverId: "server-1", - workspaceId: "feature", - activeWorkspaceSelection: { serverId: "server-1", workspaceId: "main" }, - }), + redirectIfArchivingActiveWorkspace( + { + serverId: "server-1", + workspaceId: "feature", + activeWorkspaceSelection: { serverId: "server-1", workspaceId: "main" }, + }, + deps, + ), ).toBe(false); - expect(replaceMock).not.toHaveBeenCalled(); + expect(routes).toEqual([]); }); it("replaces the route at action time when archiving the active workspace", () => { - useSessionStore.getState().initializeSession("server-1", null as unknown as DaemonClient); - useSessionStore.getState().setWorkspaces( - "server-1", - new Map([ - ["main", workspace({ id: "main", workspaceKind: "local_checkout" })], - ["feature", workspace({ id: "feature", name: "feature" })], - ]), - ); + const { deps, routes } = createFakeRouter([ + workspace({ id: "main", workspaceKind: "local_checkout" }), + workspace({ id: "feature", name: "feature" }), + ]); + expect( - redirectIfArchivingActiveWorkspace({ - serverId: "server-1", - workspaceId: "feature", - activeWorkspaceSelection: { serverId: "server-1", workspaceId: "feature" }, - }), + redirectIfArchivingActiveWorkspace( + { + serverId: "server-1", + workspaceId: "feature", + activeWorkspaceSelection: { serverId: "server-1", workspaceId: "feature" }, + }, + deps, + ), ).toBe(true); - expect(replaceMock).toHaveBeenCalledWith( - "/h/server-1/new?dir=%2Frepo&name=Project&projectId=project-1", - ); + expect(routes).toEqual(["/h/server-1/new?dir=%2Frepo&name=Project&projectId=project-1"]); }); }); diff --git a/packages/app/src/utils/workspace-archive-redirect.ts b/packages/app/src/utils/workspace-archive-redirect.ts new file mode 100644 index 000000000..0f042bd2d --- /dev/null +++ b/packages/app/src/utils/workspace-archive-redirect.ts @@ -0,0 +1,35 @@ +import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; +import type { WorkspaceDescriptor } from "@/stores/session-store"; +import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation"; + +export interface RedirectIfArchivingActiveWorkspaceInput { + serverId: string; + workspaceId: string; + activeWorkspaceSelection: ActiveWorkspaceSelection | null; +} + +export interface RedirectIfArchivingActiveWorkspaceDeps { + navigateToRoute: (route: string) => void; + readWorkspaces: (serverId: string) => Iterable; +} + +export function redirectIfArchivingActiveWorkspace( + input: RedirectIfArchivingActiveWorkspaceInput, + deps: RedirectIfArchivingActiveWorkspaceDeps, +): boolean { + if ( + input.activeWorkspaceSelection?.serverId !== input.serverId || + input.activeWorkspaceSelection.workspaceId !== input.workspaceId + ) { + return false; + } + + deps.navigateToRoute( + buildWorkspaceArchiveRedirectRoute({ + serverId: input.serverId, + archivedWorkspaceId: input.workspaceId, + workspaces: deps.readWorkspaces(input.serverId), + }), + ); + return true; +} diff --git a/packages/app/src/utils/workspace-navigation.test.ts b/packages/app/src/utils/workspace-navigation.test.ts index 72d4322aa..af08350ab 100644 --- a/packages/app/src/utils/workspace-navigation.test.ts +++ b/packages/app/src/utils/workspace-navigation.test.ts @@ -1,88 +1,96 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const { platformState, routerMock } = vi.hoisted(() => ({ - platformState: { - isWeb: true, - }, - routerMock: { - dismissTo: vi.fn(), - navigate: vi.fn(), - replace: vi.fn(), - }, -})); - -vi.mock("expo-router", () => ({ - router: routerMock, - useLocalSearchParams: () => ({}), - usePathname: () => "/", -})); - -vi.mock("@/constants/platform", () => ({ - get isWeb() { - return platformState.isWeb; - }, -})); - -vi.mock("@react-native-async-storage/async-storage", () => { - const storage = new Map(); - return { - default: { - getItem: vi.fn(async (key: string) => storage.get(key) ?? null), - setItem: vi.fn(async (key: string, value: string) => { - storage.set(key, value); - }), - removeItem: vi.fn(async (key: string) => { - storage.delete(key); - }), - }, - }; -}); - -import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; -import { navigateToPreparedWorkspaceTab, prepareWorkspaceTab } from "@/utils/workspace-navigation"; +import { describe, expect, it } from "vitest"; +import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; +import { navigateToPreparedWorkspaceTab, prepareWorkspaceTab } from "@/utils/prepare-workspace-tab"; const SERVER_ID = "server-1"; const WORKSPACE_ID = "/repo/worktree"; const AGENT_ID = "agent-1"; -describe("prepareWorkspaceTab", () => { - beforeEach(() => { - vi.useRealTimers(); - platformState.isWeb = true; - routerMock.dismissTo.mockReset(); - routerMock.navigate.mockReset(); - routerMock.replace.mockReset(); - useWorkspaceLayoutStore.setState({ - layoutByWorkspace: {}, - splitSizesByWorkspace: {}, - pinnedAgentIdsByWorkspace: {}, - hiddenAgentIdsByWorkspace: {}, - }); - }); +interface RecordedOpenedTab { + key: string; + target: WorkspaceTabTarget; +} +interface RecordedPin { + key: string; + agentId: string; +} + +interface RecordedNavigation { + serverId: string; + workspaceId: string; + currentPathname?: string | null; +} + +function createFakeLayout() { + const openedTabs: RecordedOpenedTab[] = []; + const pinnedAgents: RecordedPin[] = []; + return { + openedTabs, + pinnedAgents, + openTabFocused: (key: string, target: WorkspaceTabTarget) => { + openedTabs.push({ key, target }); + return target.kind === "agent" ? target.agentId : null; + }, + pinAgent: (key: string, agentId: string) => { + pinnedAgents.push({ key, agentId }); + }, + }; +} + +function createFakeNavigator() { + const navigations: RecordedNavigation[] = []; + return { + navigations, + navigateToWorkspace: ( + serverId: string, + workspaceId: string, + options: { currentPathname?: string | null }, + ) => { + navigations.push({ serverId, workspaceId, currentPathname: options.currentPathname }); + }, + }; +} + +describe("prepareWorkspaceTab", () => { it("opens and focuses an agent tab", () => { - const route = prepareWorkspaceTab({ - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { kind: "agent", agentId: AGENT_ID }, - }); + const layout = createFakeLayout(); + + const route = prepareWorkspaceTab( + { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + target: { kind: "agent", agentId: AGENT_ID }, + }, + layout, + ); expect(route).toBe("/h/server-1/workspace/b64_L3JlcG8vd29ya3RyZWU"); - const key = "server-1:/repo/worktree"; - expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(key)).toHaveLength(1); + expect(layout.openedTabs).toEqual([ + { key: "server-1:/repo/worktree", target: { kind: "agent", agentId: AGENT_ID } }, + ]); + expect(layout.pinnedAgents).toEqual([]); }); it("prepares a tab and navigates through the workspace navigation helper", () => { - const route = navigateToPreparedWorkspaceTab({ - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { kind: "agent", agentId: AGENT_ID }, - }); + const layout = createFakeLayout(); + const navigator = createFakeNavigator(); + + const route = navigateToPreparedWorkspaceTab( + { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + target: { kind: "agent", agentId: AGENT_ID }, + }, + { ...layout, navigateToWorkspace: navigator.navigateToWorkspace }, + ); expect(route).toBe("/h/server-1/workspace/b64_L3JlcG8vd29ya3RyZWU"); - expect(routerMock.dismissTo).toHaveBeenCalledWith( - "/h/server-1/workspace/b64_L3JlcG8vd29ya3RyZWU", - ); - expect(routerMock.replace).not.toHaveBeenCalled(); + expect(layout.openedTabs).toEqual([ + { key: "server-1:/repo/worktree", target: { kind: "agent", agentId: AGENT_ID } }, + ]); + expect(navigator.navigations).toEqual([ + { serverId: SERVER_ID, workspaceId: WORKSPACE_ID, currentPathname: undefined }, + ]); }); }); diff --git a/packages/app/src/utils/workspace-navigation.ts b/packages/app/src/utils/workspace-navigation.ts index 401e96e3b..514929c6d 100644 --- a/packages/app/src/utils/workspace-navigation.ts +++ b/packages/app/src/utils/workspace-navigation.ts @@ -1,51 +1,32 @@ -import { navigateToWorkspace } from "@/hooks/use-workspace-navigation"; +import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; -import { generateDraftId } from "@/stores/draft-keys"; import { - buildWorkspaceTabPersistenceKey, - type WorkspaceTabTarget, -} from "@/stores/workspace-tabs-store"; -import { buildHostWorkspaceRoute } from "@/utils/host-routes"; + prepareWorkspaceTab as prepareWorkspaceTabPure, + navigateToPreparedWorkspaceTab as navigateToPreparedWorkspaceTabPure, + type PrepareWorkspaceTabInput, + type NavigateToPreparedWorkspaceTabInput, +} from "./prepare-workspace-tab"; -interface PrepareWorkspaceTabInput { - serverId: string; - workspaceId: string; - target: WorkspaceTabTarget; - pin?: boolean; +export type { + PrepareWorkspaceTabInput, + NavigateToPreparedWorkspaceTabInput, +} from "./prepare-workspace-tab"; + +function layoutStoreDeps() { + const store = useWorkspaceLayoutStore.getState(); + return { + openTabFocused: store.openTabFocused, + pinAgent: store.pinAgent, + }; } -interface NavigateToPreparedWorkspaceTabInput extends PrepareWorkspaceTabInput { - currentPathname?: string | null; -} - -function getPreparedTarget(target: WorkspaceTabTarget): WorkspaceTabTarget { - if (target.kind !== "draft" || target.draftId.trim() !== "new") { - return target; - } - return { kind: "draft", draftId: generateDraftId() }; -} - -export function prepareWorkspaceTab(input: PrepareWorkspaceTabInput) { - const target = getPreparedTarget(input.target); - const key = - buildWorkspaceTabPersistenceKey({ - serverId: input.serverId, - workspaceId: input.workspaceId, - }) ?? ""; - - useWorkspaceLayoutStore.getState().openTabFocused(key, target); - - if (input.pin && target.kind === "agent") { - useWorkspaceLayoutStore.getState().pinAgent(key, target.agentId); - } - - return buildHostWorkspaceRoute(input.serverId, input.workspaceId); +export function prepareWorkspaceTab(input: PrepareWorkspaceTabInput): string { + return prepareWorkspaceTabPure(input, layoutStoreDeps()); } export function navigateToPreparedWorkspaceTab(input: NavigateToPreparedWorkspaceTabInput): string { - const route = prepareWorkspaceTab(input); - navigateToWorkspace(input.serverId, input.workspaceId, { - currentPathname: input.currentPathname, + return navigateToPreparedWorkspaceTabPure(input, { + ...layoutStoreDeps(), + navigateToWorkspace, }); - return route; }