From 4a119b935cdfa9f6fb7d483b302eb071d252b433 Mon Sep 17 00:00:00 2001 From: ethan Date: Fri, 28 Nov 2025 15:15:50 +1100 Subject: [PATCH] Revert: ORPC migration and related commits Reverts: - 470e4ebc perf: fix streaming content delay from ORPC schema validation (#774) - b437977e feat: add backend support for soft-interrupts (#767) - df30cbc0 fix: use ResultSchema for sendMessage output to prevent field stripping (#773) - 41c77efc fix: testUtils formatting (#771) - 3ee72886 refactor: migrate IPC layer to ORPC for type-safe RPC (#763) --- .github/actions/setup-mux/action.yml | 1 + .github/workflows/release.yml | 2 +- .github/workflows/terminal-bench.yml | 37 +- .storybook/mocks/orpc.ts | 217 -- .storybook/preview.tsx | 14 +- babel.config.js | 19 - bun.lock | 902 +++---- docs/AGENTS.md | 4 +- docs/theme/copy-buttons.js | 45 +- docs/theme/custom.css | 7 +- eslint.config.mjs | 20 +- index.html | 3 +- jest.config.js | 19 +- mobile/README.md | 2 +- mobile/app/_layout.tsx | 9 +- mobile/bun.lock | 19 +- mobile/package.json | 1 - mobile/src/api/client.ts | 632 +++++ mobile/src/contexts/WorkspaceCostContext.tsx | 22 +- mobile/src/hooks/useApiClient.ts | 16 + mobile/src/hooks/useProjectsData.ts | 131 +- .../src/hooks/useSlashCommandSuggestions.ts | 10 +- mobile/src/messages/normalizeChatEvent.ts | 181 +- mobile/src/orpc/client.ts | 39 - mobile/src/orpc/react.tsx | 30 - mobile/src/screens/GitReviewScreen.tsx | 45 +- mobile/src/screens/ProjectsScreen.tsx | 40 +- mobile/src/screens/WorkspaceScreen.tsx | 125 +- mobile/src/utils/modelCatalog.ts | 2 - mobile/src/utils/slashCommandHelpers.test.ts | 7 +- mobile/src/utils/slashCommandHelpers.ts | 7 +- mobile/src/utils/slashCommandRunner.test.ts | 26 +- mobile/src/utils/slashCommandRunner.ts | 79 +- mobile/tsconfig.json | 11 +- package.json | 7 - playwright.config.ts | 3 - scripts/build-main-watch.js | 31 +- scripts/generate-icons.ts | 13 +- scripts/mdbook-shiki.ts | 58 +- scripts/wait_pr_checks.sh | 2 +- src/browser/App.stories.tsx | 1613 +++++++----- src/browser/App.tsx | 42 +- src/browser/api.test.ts | 156 ++ src/browser/api.ts | 390 +++ src/browser/components/AIView.tsx | 26 +- src/browser/components/AppLoader.tsx | 25 +- src/browser/components/AuthTokenModal.tsx | 111 - src/browser/components/ChatInput/index.tsx | 470 ++-- src/browser/components/ChatInput/types.ts | 2 +- .../ChatInput/useCreationWorkspace.test.tsx | 301 +-- .../ChatInput/useCreationWorkspace.ts | 30 +- src/browser/components/ChatInputToast.tsx | 5 +- .../components/DirectoryPickerModal.tsx | 53 +- .../components/ProjectCreateModal.stories.tsx | 265 +- src/browser/components/ProjectCreateModal.tsx | 27 +- .../CodeReview/ReviewPanel.stories.tsx | 117 +- .../RightSidebar/CodeReview/ReviewPanel.tsx | 23 +- .../CodeReview/UntrackedStatus.tsx | 20 +- .../components/Settings/Settings.stories.tsx | 98 +- .../Settings/sections/ModelsSection.tsx | 18 +- .../Settings/sections/ProvidersSection.tsx | 87 +- src/browser/components/Settings/types.ts | 17 +- src/browser/components/TerminalView.tsx | 20 - src/browser/components/TitleBar.tsx | 42 +- src/browser/components/WorkspaceHeader.tsx | 6 +- .../components/hooks/useGitBranchDetails.ts | 14 +- src/browser/contexts/ProjectContext.test.tsx | 75 +- src/browser/contexts/ProjectContext.tsx | 74 +- .../contexts/WorkspaceContext.test.tsx | 866 +++++-- src/browser/contexts/WorkspaceContext.tsx | 165 +- src/browser/hooks/useAIViewKeybinds.ts | 12 +- src/browser/hooks/useModelLRU.ts | 12 +- src/browser/hooks/useOpenTerminal.ts | 44 - src/browser/hooks/useResumeManager.ts | 6 +- src/browser/hooks/useSendMessageOptions.ts | 2 +- src/browser/hooks/useStartHere.ts | 56 +- src/browser/hooks/useTerminalSession.ts | 81 +- src/browser/main.tsx | 4 + src/browser/orpc/react.tsx | 237 -- src/browser/stores/GitStatusStore.test.ts | 6 - src/browser/stores/GitStatusStore.ts | 34 +- .../stores/WorkspaceConsumerManager.ts | 16 +- src/browser/stores/WorkspaceStore.test.ts | 254 +- src/browser/stores/WorkspaceStore.ts | 56 +- src/browser/styles/globals.css | 29 +- src/browser/terminal-window.tsx | 28 +- src/browser/testUtils.ts | 13 - src/browser/utils/chatCommands.test.ts | 2 +- src/browser/utils/chatCommands.ts | 83 +- src/browser/utils/commands/sources.test.ts | 10 +- src/browser/utils/commands/sources.ts | 10 +- src/browser/utils/compaction/handler.ts | 4 +- .../utils/messages/ChatEventProcessor.test.ts | 2 +- .../utils/messages/ChatEventProcessor.ts | 6 +- .../messages/StreamingMessageAggregator.ts | 6 +- .../utils/messages/compactionOptions.test.ts | 2 +- .../utils/messages/compactionOptions.ts | 2 +- src/browser/utils/messages/sendOptions.ts | 2 +- src/browser/utils/tokenizer/rendererClient.ts | 41 +- src/browser/utils/ui/keybinds.test.ts | 95 - src/browser/utils/ui/keybinds.ts | 5 - src/cli/debug/agentSessionCli.ts | 2 +- src/cli/debug/send-message.ts | 2 +- src/cli/orpcServer.ts | 165 -- src/cli/server.test.ts | 329 --- src/cli/server.ts | 383 ++- src/common/constants/events.ts | 2 +- src/common/constants/ipc-constants.ts | 81 + src/common/orpc/client.ts | 8 - src/common/orpc/schemas.ts | 104 - src/common/orpc/schemas/api.ts | 371 --- src/common/orpc/schemas/chatStats.ts | 39 - src/common/orpc/schemas/errors.ts | 31 - src/common/orpc/schemas/message.ts | 108 - src/common/orpc/schemas/project.ts | 25 - src/common/orpc/schemas/providerOptions.ts | 73 - src/common/orpc/schemas/result.ts | 13 - src/common/orpc/schemas/runtime.ts | 26 - src/common/orpc/schemas/secrets.ts | 10 - src/common/orpc/schemas/stream.ts | 279 --- src/common/orpc/schemas/terminal.ts | 20 - src/common/orpc/schemas/tools.ts | 54 - src/common/orpc/schemas/workspace.ts | 45 - src/common/orpc/types.ts | 116 - src/common/telemetry/client.test.ts | 6 +- src/common/telemetry/utils.ts | 4 +- src/common/types/chatStats.ts | 19 +- src/common/types/errors.ts | 22 +- src/common/types/global.d.ts | 34 +- src/common/types/ipc.ts | 404 +++ src/common/types/message.ts | 2 +- src/common/types/project.ts | 43 +- src/common/types/providerOptions.ts | 66 +- src/common/types/runtime.ts | 46 +- src/common/types/secrets.ts | 11 +- src/common/types/stream.ts | 140 +- src/common/types/terminal.ts | 26 +- src/common/types/toolParts.ts | 28 +- src/common/types/workspace.ts | 63 +- src/common/utils/tools/toolDefinitions.ts | 3 +- src/common/utils/tools/tools.ts | 6 +- src/desktop/main.ts | 132 +- src/desktop/preload.ts | 226 +- src/desktop/updater.test.ts | 143 +- src/desktop/updater.ts | 38 +- src/node/bench/headlessEnvironment.ts | 12 +- src/node/config.ts | 26 - src/node/orpc/authMiddleware.test.ts | 77 - src/node/orpc/authMiddleware.ts | 83 - src/node/orpc/context.ts | 21 - src/node/orpc/router.ts | 684 ------ src/node/services/agentSession.ts | 73 +- src/node/services/aiService.ts | 25 +- src/node/services/compactionHandler.ts | 2 +- src/node/services/initStateManager.test.ts | 2 +- src/node/services/initStateManager.ts | 2 +- src/node/services/ipcMain.ts | 2164 +++++++++++++++++ src/node/services/log.ts | 27 +- src/node/services/messageQueue.test.ts | 28 +- src/node/services/messageQueue.ts | 20 +- src/node/services/mock/mockScenarioPlayer.ts | 6 +- src/node/services/projectService.test.ts | 136 -- src/node/services/projectService.ts | 173 -- src/node/services/providerService.ts | 135 - src/node/services/serverService.test.ts | 31 - src/node/services/serverService.ts | 17 - src/node/services/serviceContainer.ts | 86 - src/node/services/streamManager.ts | 109 +- src/node/services/terminalService.test.ts | 448 ---- src/node/services/terminalService.ts | 545 ----- src/node/services/tokenizerService.test.ts | 67 - src/node/services/tokenizerService.ts | 44 - src/node/services/tools/bash.test.ts | 8 +- src/node/services/updateService.ts | 106 - src/node/services/windowService.ts | 37 - src/node/services/workspaceService.ts | 1094 --------- src/server/auth.ts | 90 + tests/__mocks__/jsdom.js | 8 +- tests/e2e/scenarios/review.spec.ts | 3 +- tests/e2e/scenarios/slashCommands.spec.ts | 5 +- tests/e2e/utils/ui.ts | 174 +- tests/integration/helpers.ts | 626 ----- tests/integration/initWorkspace.test.ts | 454 ---- tests/integration/orpcTestClient.ts | 9 - tests/integration/projectRefactor.test.ts | 118 - tests/integration/streamCollector.ts | 564 ----- tests/integration/usageDelta.test.ts | 72 - .../anthropic1MContext.test.ts | 20 +- tests/ipcMain/anthropicCacheStrategy.test.ts | 88 + .../createWorkspace.test.ts | 264 +- .../doubleRegister.test.ts | 26 +- .../executeBash.test.ts | 179 +- .../forkWorkspace.test.ts | 146 +- tests/ipcMain/helpers.ts | 816 +++++++ tests/ipcMain/initWorkspace.test.ts | 718 ++++++ .../modelNotFound.test.ts | 35 +- tests/{integration => ipcMain}/ollama.test.ts | 50 +- .../openai-web-search.test.ts | 26 +- .../projectCreate.test.ts | 72 +- .../queuedMessages.test.ts | 310 +-- .../removeWorkspace.test.ts | 94 +- .../renameWorkspace.test.ts | 58 +- .../resumeStream.test.ts | 160 +- tests/ipcMain/runtimeExecuteBash.test.ts | 407 ++++ .../runtimeFileEditing.test.ts | 9 +- tests/ipcMain/sendMessage.basic.test.ts | 523 ++++ tests/ipcMain/sendMessage.context.test.ts | 610 +++++ tests/ipcMain/sendMessage.errors.test.ts | 433 ++++ tests/ipcMain/sendMessage.heavy.test.ts | 127 + tests/ipcMain/sendMessage.images.test.ts | 132 + tests/ipcMain/sendMessage.reasoning.test.ts | 60 + tests/ipcMain/sendMessageTestHelpers.ts | 61 + tests/{integration => ipcMain}/setup.ts | 159 +- .../streamErrorRecovery.test.ts | 137 +- .../{integration => ipcMain}/truncate.test.ts | 132 +- .../websocketHistoryReplay.test.ts | 46 +- .../windowTitle.test.ts | 11 +- tests/setup.ts | 5 +- tsconfig.json | 2 +- vite.config.ts | 30 +- vscode/CHANGELOG.md | 1 - vscode/README.md | 1 - vscode/src/extension.ts | 4 +- 223 files changed, 13622 insertions(+), 13645 deletions(-) delete mode 100644 .storybook/mocks/orpc.ts delete mode 100644 babel.config.js create mode 100644 mobile/src/api/client.ts create mode 100644 mobile/src/hooks/useApiClient.ts delete mode 100644 mobile/src/orpc/client.ts delete mode 100644 mobile/src/orpc/react.tsx create mode 100644 src/browser/api.test.ts create mode 100644 src/browser/api.ts delete mode 100644 src/browser/components/AuthTokenModal.tsx delete mode 100644 src/browser/hooks/useOpenTerminal.ts delete mode 100644 src/browser/orpc/react.tsx delete mode 100644 src/browser/testUtils.ts delete mode 100644 src/cli/orpcServer.ts delete mode 100644 src/cli/server.test.ts create mode 100644 src/common/constants/ipc-constants.ts delete mode 100644 src/common/orpc/client.ts delete mode 100644 src/common/orpc/schemas.ts delete mode 100644 src/common/orpc/schemas/api.ts delete mode 100644 src/common/orpc/schemas/chatStats.ts delete mode 100644 src/common/orpc/schemas/errors.ts delete mode 100644 src/common/orpc/schemas/message.ts delete mode 100644 src/common/orpc/schemas/project.ts delete mode 100644 src/common/orpc/schemas/providerOptions.ts delete mode 100644 src/common/orpc/schemas/result.ts delete mode 100644 src/common/orpc/schemas/runtime.ts delete mode 100644 src/common/orpc/schemas/secrets.ts delete mode 100644 src/common/orpc/schemas/stream.ts delete mode 100644 src/common/orpc/schemas/terminal.ts delete mode 100644 src/common/orpc/schemas/tools.ts delete mode 100644 src/common/orpc/schemas/workspace.ts delete mode 100644 src/common/orpc/types.ts create mode 100644 src/common/types/ipc.ts delete mode 100644 src/node/orpc/authMiddleware.test.ts delete mode 100644 src/node/orpc/authMiddleware.ts delete mode 100644 src/node/orpc/context.ts delete mode 100644 src/node/orpc/router.ts create mode 100644 src/node/services/ipcMain.ts delete mode 100644 src/node/services/projectService.test.ts delete mode 100644 src/node/services/projectService.ts delete mode 100644 src/node/services/providerService.ts delete mode 100644 src/node/services/serverService.test.ts delete mode 100644 src/node/services/serverService.ts delete mode 100644 src/node/services/serviceContainer.ts delete mode 100644 src/node/services/terminalService.test.ts delete mode 100644 src/node/services/terminalService.ts delete mode 100644 src/node/services/tokenizerService.test.ts delete mode 100644 src/node/services/tokenizerService.ts delete mode 100644 src/node/services/updateService.ts delete mode 100644 src/node/services/windowService.ts delete mode 100644 src/node/services/workspaceService.ts create mode 100644 src/server/auth.ts delete mode 100644 tests/integration/helpers.ts delete mode 100644 tests/integration/initWorkspace.test.ts delete mode 100644 tests/integration/orpcTestClient.ts delete mode 100644 tests/integration/projectRefactor.test.ts delete mode 100644 tests/integration/streamCollector.ts delete mode 100644 tests/integration/usageDelta.test.ts rename tests/{integration => ipcMain}/anthropic1MContext.test.ts (90%) create mode 100644 tests/ipcMain/anthropicCacheStrategy.test.ts rename tests/{integration => ipcMain}/createWorkspace.test.ts (79%) rename tests/{integration => ipcMain}/doubleRegister.test.ts (56%) rename tests/{integration => ipcMain}/executeBash.test.ts (64%) rename tests/{integration => ipcMain}/forkWorkspace.test.ts (74%) create mode 100644 tests/ipcMain/helpers.ts create mode 100644 tests/ipcMain/initWorkspace.test.ts rename tests/{integration => ipcMain}/modelNotFound.test.ts (67%) rename tests/{integration => ipcMain}/ollama.test.ts (87%) rename tests/{integration => ipcMain}/openai-web-search.test.ts (81%) rename tests/{integration => ipcMain}/projectCreate.test.ts (74%) rename tests/{integration => ipcMain}/queuedMessages.test.ts (55%) rename tests/{integration => ipcMain}/removeWorkspace.test.ts (89%) rename tests/{integration => ipcMain}/renameWorkspace.test.ts (81%) rename tests/{integration => ipcMain}/resumeStream.test.ts (54%) create mode 100644 tests/ipcMain/runtimeExecuteBash.test.ts rename tests/{integration => ipcMain}/runtimeFileEditing.test.ts (98%) create mode 100644 tests/ipcMain/sendMessage.basic.test.ts create mode 100644 tests/ipcMain/sendMessage.context.test.ts create mode 100644 tests/ipcMain/sendMessage.errors.test.ts create mode 100644 tests/ipcMain/sendMessage.heavy.test.ts create mode 100644 tests/ipcMain/sendMessage.images.test.ts create mode 100644 tests/ipcMain/sendMessage.reasoning.test.ts create mode 100644 tests/ipcMain/sendMessageTestHelpers.ts rename tests/{integration => ipcMain}/setup.ts (64%) rename tests/{integration => ipcMain}/streamErrorRecovery.test.ts (74%) rename tests/{integration => ipcMain}/truncate.test.ts (68%) rename tests/{integration => ipcMain}/websocketHistoryReplay.test.ts (69%) rename tests/{integration => ipcMain}/windowTitle.test.ts (79%) diff --git a/.github/actions/setup-mux/action.yml b/.github/actions/setup-mux/action.yml index 2d01f3ea7..2764a8f58 100644 --- a/.github/actions/setup-mux/action.yml +++ b/.github/actions/setup-mux/action.yml @@ -35,3 +35,4 @@ runs: if: steps.cache-node-modules.outputs.cache-hit != 'true' shell: bash run: bun install --frozen-lockfile + diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c05401b04..cad776d2e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,7 +6,7 @@ on: workflow_dispatch: inputs: tag: - description: "Tag to release (e.g., v1.2.3). If provided, will checkout and release this tag regardless of current branch." + description: 'Tag to release (e.g., v1.2.3). If provided, will checkout and release this tag regardless of current branch.' required: false type: string diff --git a/.github/workflows/terminal-bench.yml b/.github/workflows/terminal-bench.yml index a895afa5e..f74b271bf 100644 --- a/.github/workflows/terminal-bench.yml +++ b/.github/workflows/terminal-bench.yml @@ -4,34 +4,34 @@ on: workflow_call: inputs: model_name: - description: "Model to use (e.g., anthropic:claude-sonnet-4-5)" + description: 'Model to use (e.g., anthropic:claude-sonnet-4-5)' required: false type: string thinking_level: - description: "Thinking level (off, low, medium, high)" + description: 'Thinking level (off, low, medium, high)' required: false type: string dataset: - description: "Terminal-Bench dataset to use" + description: 'Terminal-Bench dataset to use' required: false type: string - default: "terminal-bench-core==0.1.1" + default: 'terminal-bench-core==0.1.1' concurrency: - description: "Number of concurrent tasks (--n-concurrent)" + description: 'Number of concurrent tasks (--n-concurrent)' required: false type: string - default: "4" + default: '4' livestream: - description: "Enable livestream mode (verbose output to console)" + description: 'Enable livestream mode (verbose output to console)' required: false type: boolean default: false sample_size: - description: "Number of random tasks to run (empty = all tasks)" + description: 'Number of random tasks to run (empty = all tasks)' required: false type: string extra_args: - description: "Additional arguments to pass to terminal-bench" + description: 'Additional arguments to pass to terminal-bench' required: false type: string secrets: @@ -42,34 +42,34 @@ on: workflow_dispatch: inputs: dataset: - description: "Terminal-Bench dataset to use" + description: 'Terminal-Bench dataset to use' required: false - default: "terminal-bench-core==0.1.1" + default: 'terminal-bench-core==0.1.1' type: string concurrency: - description: "Number of concurrent tasks (--n-concurrent)" + description: 'Number of concurrent tasks (--n-concurrent)' required: false - default: "4" + default: '4' type: string livestream: - description: "Enable livestream mode (verbose output to console)" + description: 'Enable livestream mode (verbose output to console)' required: false default: false type: boolean sample_size: - description: "Number of random tasks to run (empty = all tasks)" + description: 'Number of random tasks to run (empty = all tasks)' required: false type: string model_name: - description: "Model to use (e.g., anthropic:claude-sonnet-4-5, openai:gpt-5.1-codex)" + description: 'Model to use (e.g., anthropic:claude-sonnet-4-5, openai:gpt-5.1-codex)' required: false type: string thinking_level: - description: "Thinking level (off, low, medium, high)" + description: 'Thinking level (off, low, medium, high)' required: false type: string extra_args: - description: "Additional arguments to pass to terminal-bench" + description: 'Additional arguments to pass to terminal-bench' required: false type: string @@ -147,3 +147,4 @@ jobs: benchmark.log if-no-files-found: warn retention-days: 30 + diff --git a/.storybook/mocks/orpc.ts b/.storybook/mocks/orpc.ts deleted file mode 100644 index 85d54999f..000000000 --- a/.storybook/mocks/orpc.ts +++ /dev/null @@ -1,217 +0,0 @@ -/** - * Mock ORPC client factory for Storybook stories. - * - * Creates a client that matches the AppRouter interface with configurable mock data. - */ -import type { ORPCClient } from "@/browser/orpc/react"; -import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; -import type { ProjectConfig } from "@/node/config"; -import type { WorkspaceChatMessage } from "@/common/orpc/types"; -import type { ChatStats } from "@/common/types/chatStats"; -import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace"; - -export interface MockORPCClientOptions { - projects?: Map; - workspaces?: FrontendWorkspaceMetadata[]; - /** Per-workspace chat callback. Return messages to emit, or use the callback for streaming. */ - onChat?: (workspaceId: string, emit: (msg: WorkspaceChatMessage) => void) => (() => void) | void; - /** Mock for executeBash per workspace */ - executeBash?: ( - workspaceId: string, - script: string - ) => Promise<{ success: true; output: string; exitCode: number; wall_duration_ms: number }>; -} - -/** - * Creates a mock ORPC client for Storybook. - * - * Usage: - * ```tsx - * const client = createMockORPCClient({ - * projects: new Map([...]), - * workspaces: [...], - * onChat: (wsId, emit) => { - * emit({ type: "caught-up" }); - * // optionally return cleanup function - * }, - * }); - * - * return ; - * ``` - */ -export function createMockORPCClient(options: MockORPCClientOptions = {}): ORPCClient { - const { projects = new Map(), workspaces = [], onChat, executeBash } = options; - - const workspaceMap = new Map(workspaces.map((w) => [w.id, w])); - - const mockStats: ChatStats = { - consumers: [], - totalTokens: 0, - model: "mock-model", - tokenizerName: "mock-tokenizer", - usageHistory: [], - }; - - // Cast to ORPCClient - TypeScript can't fully validate the proxy structure - return { - tokenizer: { - countTokens: async () => 0, - countTokensBatch: async (_input: { model: string; texts: string[] }) => - _input.texts.map(() => 0), - calculateStats: async () => mockStats, - }, - server: { - getLaunchProject: async () => null, - }, - providers: { - list: async () => [], - getConfig: async () => ({}), - setProviderConfig: async () => ({ success: true, data: undefined }), - setModels: async () => ({ success: true, data: undefined }), - }, - general: { - listDirectory: async () => ({ entries: [], hasMore: false }), - ping: async (input: string) => `Pong: ${input}`, - tick: async function* () { - // No-op generator - }, - }, - projects: { - list: async () => Array.from(projects.entries()), - create: async () => ({ - success: true, - data: { projectConfig: { workspaces: [] }, normalizedPath: "/mock/project" }, - }), - pickDirectory: async () => null, - listBranches: async () => ({ - branches: ["main", "develop"], - recommendedTrunk: "main", - }), - remove: async () => ({ success: true, data: undefined }), - secrets: { - get: async () => [], - update: async () => ({ success: true, data: undefined }), - }, - }, - workspace: { - list: async () => workspaces, - create: async (input: { projectPath: string; branchName: string }) => ({ - success: true, - metadata: { - id: Math.random().toString(36).substring(2, 12), - name: input.branchName, - projectPath: input.projectPath, - projectName: input.projectPath.split("/").pop() ?? "project", - namedWorkspacePath: `/mock/workspace/${input.branchName}`, - runtimeConfig: DEFAULT_RUNTIME_CONFIG, - }, - }), - remove: async () => ({ success: true }), - rename: async (input: { workspaceId: string }) => ({ - success: true, - data: { newWorkspaceId: input.workspaceId }, - }), - fork: async () => ({ success: false, error: "Not implemented in mock" }), - sendMessage: async () => ({ success: true, data: undefined }), - resumeStream: async () => ({ success: true, data: undefined }), - interruptStream: async () => ({ success: true, data: undefined }), - clearQueue: async () => ({ success: true, data: undefined }), - truncateHistory: async () => ({ success: true, data: undefined }), - replaceChatHistory: async () => ({ success: true, data: undefined }), - getInfo: async (input: { workspaceId: string }) => - workspaceMap.get(input.workspaceId) ?? null, - executeBash: async (input: { workspaceId: string; script: string }) => { - if (executeBash) { - const result = await executeBash(input.workspaceId, input.script); - return { success: true, data: result }; - } - return { - success: true, - data: { success: true, output: "", exitCode: 0, wall_duration_ms: 0 }, - }; - }, - onChat: async function* (input: { workspaceId: string }) { - if (!onChat) { - yield { type: "caught-up" } as WorkspaceChatMessage; - return; - } - - // Create a queue-based async iterator - const queue: WorkspaceChatMessage[] = []; - let resolveNext: ((msg: WorkspaceChatMessage) => void) | null = null; - let ended = false; - - const emit = (msg: WorkspaceChatMessage) => { - if (ended) return; - if (resolveNext) { - const resolve = resolveNext; - resolveNext = null; - resolve(msg); - } else { - queue.push(msg); - } - }; - - // Call the user's onChat handler - const cleanup = onChat(input.workspaceId, emit); - - try { - while (!ended) { - if (queue.length > 0) { - yield queue.shift()!; - } else { - const msg = await new Promise((resolve) => { - resolveNext = resolve; - }); - yield msg; - } - } - } finally { - ended = true; - cleanup?.(); - } - }, - onMetadata: async function* () { - // Empty generator - no metadata updates in mock - await new Promise(() => {}); // Never resolves, keeps stream open - }, - activity: { - list: async () => ({}), - subscribe: async function* () { - await new Promise(() => {}); // Never resolves - }, - }, - }, - window: { - setTitle: async () => undefined, - }, - terminal: { - create: async () => ({ - sessionId: "mock-session", - workspaceId: "mock-workspace", - cols: 80, - rows: 24, - }), - close: async () => undefined, - resize: async () => undefined, - sendInput: () => undefined, - onOutput: async function* () { - await new Promise(() => {}); - }, - onExit: async function* () { - await new Promise(() => {}); - }, - openWindow: async () => undefined, - closeWindow: async () => undefined, - openNative: async () => undefined, - }, - update: { - check: async () => undefined, - download: async () => undefined, - install: () => undefined, - onStatus: async function* () { - await new Promise(() => {}); - }, - }, - } as unknown as ORPCClient; -} diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 04bddcec7..a97672148 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -1,8 +1,6 @@ -import React, { useMemo } from "react"; +import React from "react"; import type { Preview } from "@storybook/react-vite"; import { ThemeProvider, type ThemeMode } from "../src/browser/contexts/ThemeContext"; -import { ORPCProvider } from "../src/browser/orpc/react"; -import { createMockORPCClient } from "./mocks/orpc"; import "../src/browser/styles/globals.css"; const preview: Preview = { @@ -24,16 +22,6 @@ const preview: Preview = { theme: "dark", }, decorators: [ - // Global ORPC provider - ensures useORPC works in all stories - (Story) => { - const client = useMemo(() => createMockORPCClient(), []); - return ( - - - - ); - }, - // Theme provider (Story, context) => { // Default to dark if mode not set (e.g., Chromatic headless browser defaults to light) const mode = (context.globals.theme as ThemeMode | undefined) ?? "dark"; diff --git a/babel.config.js b/babel.config.js deleted file mode 100644 index d780814fb..000000000 --- a/babel.config.js +++ /dev/null @@ -1,19 +0,0 @@ -module.exports = { - presets: [ - [ - "@babel/preset-env", - { - targets: { - node: "current", - }, - modules: "commonjs", - }, - ], - [ - "@babel/preset-typescript", - { - allowDeclareFields: true, - }, - ], - ], -}; diff --git a/bun.lock b/bun.lock index da15ce93c..169a8b478 100644 --- a/bun.lock +++ b/bun.lock @@ -1,9 +1,8 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { - "name": "mux", + "name": "@coder/cmux", "dependencies": { "@ai-sdk/amazon-bedrock": "^3.0.61", "@ai-sdk/anthropic": "^2.0.47", @@ -14,9 +13,6 @@ "@lydell/node-pty": "1.1.0", "@mozilla/readability": "^0.6.0", "@openrouter/ai-sdk-provider": "^1.2.5", - "@orpc/client": "^1.11.3", - "@orpc/server": "^1.11.3", - "@orpc/zod": "^1.11.3", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -58,9 +54,6 @@ "zod-to-json-schema": "^3.24.6", }, "devDependencies": { - "@babel/core": "^7.28.5", - "@babel/preset-env": "^7.28.5", - "@babel/preset-typescript": "^7.28.5", "@electron/rebuild": "^4.0.1", "@eslint/js": "^9.36.0", "@playwright/test": "^1.56.0", @@ -91,7 +84,6 @@ "@typescript/native-preview": "^7.0.0-dev.20251014.1", "@vitejs/plugin-react": "^4.0.0", "autoprefixer": "^10.4.21", - "babel-jest": "^30.2.0", "babel-plugin-react-compiler": "^1.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -153,9 +145,9 @@ "@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="], - "@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@3.0.62", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.50", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.18", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-vVtndaj5zfHmgw8NSqN4baFDbFDTBZP6qufhKfqSNLtygEm8+8PL9XQX9urgzSzU3zp+zi3AmNNemvKLkkqblg=="], + "@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@3.0.61", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.49", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.17", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-sgMNLtII+vvHbe8S8nVxVAf3I60PcSKRvBvB6CvwdaO3yc5CVCHEulfcasxTR9jThV60aUZ2Q5BzheSwIyo9hg=="], - "@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.48", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.17" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Uy6AU25LWQOT2jeuFPrugOLPWl9lTRdfj1u3eEsulP+aPP/sd9Et7CJ75FnVngJCm96nTJM2EWMPZfg+u++R6g=="], + "@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.47", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.17" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YioBDTTQ6z2fijcOByG6Gj7me0ITqaJACprHROis7fXFzYIBzyAwxhsCnOrXO+oXv+9Ixddgy/Cahdmu84uRvQ=="], "@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.15", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.17", "@vercel/oidc": "3.0.5" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-i1YVKzC1dg9LGvt+GthhD7NlRhz9J4+ZRj3KELU14IZ/MHPsOBiFeEoCCIDLR+3tqT8/+5nIsK3eZ7DFRfMfdw=="], @@ -167,7 +159,7 @@ "@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="], - "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.18", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ypv1xXMsgGcNKUP+hglKqtdDuMg68nWHucPPAhIENrbFAI+xCHiqPVN8Zllxyv1TNZwGWUghPxJXU+Mqps0YRQ=="], + "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.17", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-TR3Gs4I3Tym4Ll+EPdzRdvo/rc8Js6c4nVhFLuvGLX/Y4V9ZcQMa/HTiYsHEgmYrf1zVi6Q145UEZUfleOwOjw=="], "@ai-sdk/xai": ["@ai-sdk/xai@2.0.36", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.27", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.17" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-tQuCDVNK4W4fiom59r2UnU7u9SAz58fpl5yKYoS9IbMOrDRO3fzQGWmj2p8MUvz9LzXf6hiyUkVNFGzzx+uZcw=="], @@ -253,58 +245,26 @@ "@babel/generator": ["@babel/generator@7.28.5", "", { "dependencies": { "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ=="], - "@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.27.3", "", { "dependencies": { "@babel/types": "^7.27.3" } }, "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg=="], - "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.27.2", "", { "dependencies": { "@babel/compat-data": "^7.27.2", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ=="], - "@babel/helper-create-class-features-plugin": ["@babel/helper-create-class-features-plugin@7.28.5", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-member-expression-to-functions": "^7.28.5", "@babel/helper-optimise-call-expression": "^7.27.1", "@babel/helper-replace-supers": "^7.27.1", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", "@babel/traverse": "^7.28.5", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-q3WC4JfdODypvxArsJQROfupPBq9+lMwjKq7C33GhbFYJsufD0yd/ziwD+hJucLeWsnFPWZjsU2DNFqBPE7jwQ=="], - - "@babel/helper-create-regexp-features-plugin": ["@babel/helper-create-regexp-features-plugin@7.28.5", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "regexpu-core": "^6.3.1", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw=="], - - "@babel/helper-define-polyfill-provider": ["@babel/helper-define-polyfill-provider@0.6.5", "", { "dependencies": { "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-plugin-utils": "^7.27.1", "debug": "^4.4.1", "lodash.debounce": "^4.0.8", "resolve": "^1.22.10" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-uJnGFcPsWQK8fvjgGP5LZUZZsYGIoPeRjSF5PGwrelYgq7Q15/Ft9NGFp1zglwgIv//W0uG4BevRuSJRyylZPg=="], - "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], - "@babel/helper-member-expression-to-functions": ["@babel/helper-member-expression-to-functions@7.28.5", "", { "dependencies": { "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5" } }, "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg=="], - "@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="], "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.3", "", { "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", "@babel/traverse": "^7.28.3" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw=="], - "@babel/helper-optimise-call-expression": ["@babel/helper-optimise-call-expression@7.27.1", "", { "dependencies": { "@babel/types": "^7.27.1" } }, "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw=="], - "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="], - "@babel/helper-remap-async-to-generator": ["@babel/helper-remap-async-to-generator@7.27.1", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.1", "@babel/helper-wrap-function": "^7.27.1", "@babel/traverse": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA=="], - - "@babel/helper-replace-supers": ["@babel/helper-replace-supers@7.27.1", "", { "dependencies": { "@babel/helper-member-expression-to-functions": "^7.27.1", "@babel/helper-optimise-call-expression": "^7.27.1", "@babel/traverse": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA=="], - - "@babel/helper-skip-transparent-expression-wrappers": ["@babel/helper-skip-transparent-expression-wrappers@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg=="], - "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], - "@babel/helper-wrap-function": ["@babel/helper-wrap-function@7.28.3", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.3", "@babel/types": "^7.28.2" } }, "sha512-zdf983tNfLZFletc0RRXYrHrucBEg95NIFMkn6K9dbeMYnsgHaSBGcQqdsCSStG2PYwRre0Qc2NNSCXbG+xc6g=="], - "@babel/helpers": ["@babel/helpers@7.28.4", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.28.4" } }, "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w=="], "@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], - "@babel/plugin-bugfix-firefox-class-in-computed-class-key": ["@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.28.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/traverse": "^7.28.5" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q=="], - - "@babel/plugin-bugfix-safari-class-field-initializer-scope": ["@babel/plugin-bugfix-safari-class-field-initializer-scope@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA=="], - - "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": ["@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA=="], - - "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": ["@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", "@babel/plugin-transform-optional-chaining": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.13.0" } }, "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw=="], - - "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": ["@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.28.3", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/traverse": "^7.28.3" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-b6YTX108evsvE4YgWyQ921ZAFFQm3Bn+CA3+ZXlNVnPhx+UfsVURoPjfGAPCjBgrqo30yX/C2nZGX96DxvR9Iw=="], - - "@babel/plugin-proposal-private-property-in-object": ["@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2", "", { "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w=="], - "@babel/plugin-syntax-async-generators": ["@babel/plugin-syntax-async-generators@7.8.4", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw=="], "@babel/plugin-syntax-bigint": ["@babel/plugin-syntax-bigint@7.8.3", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg=="], @@ -313,8 +273,6 @@ "@babel/plugin-syntax-class-static-block": ["@babel/plugin-syntax-class-static-block@7.14.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.14.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw=="], - "@babel/plugin-syntax-import-assertions": ["@babel/plugin-syntax-import-assertions@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-UT/Jrhw57xg4ILHLFnzFpPDlMbcdEicaAtjPQpbj9wa8T4r5KVWCimHcL/460g8Ht0DMxDyjsLgiWSkVjnwPFg=="], - "@babel/plugin-syntax-import-attributes": ["@babel/plugin-syntax-import-attributes@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww=="], "@babel/plugin-syntax-import-meta": ["@babel/plugin-syntax-import-meta@7.10.4", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.10.4" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g=="], @@ -341,122 +299,10 @@ "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ=="], - "@babel/plugin-syntax-unicode-sets-regex": ["@babel/plugin-syntax-unicode-sets-regex@7.18.6", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.18.6", "@babel/helper-plugin-utils": "^7.18.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg=="], - - "@babel/plugin-transform-arrow-functions": ["@babel/plugin-transform-arrow-functions@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA=="], - - "@babel/plugin-transform-async-generator-functions": ["@babel/plugin-transform-async-generator-functions@7.28.0", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-remap-async-to-generator": "^7.27.1", "@babel/traverse": "^7.28.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-BEOdvX4+M765icNPZeidyADIvQ1m1gmunXufXxvRESy/jNNyfovIqUyE7MVgGBjWktCoJlzvFA1To2O4ymIO3Q=="], - - "@babel/plugin-transform-async-to-generator": ["@babel/plugin-transform-async-to-generator@7.27.1", "", { "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-remap-async-to-generator": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-NREkZsZVJS4xmTr8qzE5y8AfIPqsdQfRuUiLRTEzb7Qii8iFWCyDKaUV2c0rCuh4ljDZ98ALHP/PetiBV2nddA=="], - - "@babel/plugin-transform-block-scoped-functions": ["@babel/plugin-transform-block-scoped-functions@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg=="], - - "@babel/plugin-transform-block-scoping": ["@babel/plugin-transform-block-scoping@7.28.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-45DmULpySVvmq9Pj3X9B+62Xe+DJGov27QravQJU1LLcapR6/10i+gYVAucGGJpHBp5mYxIMK4nDAT/QDLr47g=="], - - "@babel/plugin-transform-class-properties": ["@babel/plugin-transform-class-properties@7.27.1", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA=="], - - "@babel/plugin-transform-class-static-block": ["@babel/plugin-transform-class-static-block@7.28.3", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.28.3", "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.12.0" } }, "sha512-LtPXlBbRoc4Njl/oh1CeD/3jC+atytbnf/UqLoqTDcEYGUPj022+rvfkbDYieUrSj3CaV4yHDByPE+T2HwfsJg=="], - - "@babel/plugin-transform-classes": ["@babel/plugin-transform-classes@7.28.4", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-globals": "^7.28.0", "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-replace-supers": "^7.27.1", "@babel/traverse": "^7.28.4" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-cFOlhIYPBv/iBoc+KS3M6et2XPtbT2HiCRfBXWtfpc9OAyostldxIf9YAYB6ypURBBbx+Qv6nyrLzASfJe+hBA=="], - - "@babel/plugin-transform-computed-properties": ["@babel/plugin-transform-computed-properties@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/template": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-lj9PGWvMTVksbWiDT2tW68zGS/cyo4AkZ/QTp0sQT0mjPopCmrSkzxeXkznjqBxzDI6TclZhOJbBmbBLjuOZUw=="], - - "@babel/plugin-transform-destructuring": ["@babel/plugin-transform-destructuring@7.28.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/traverse": "^7.28.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw=="], - - "@babel/plugin-transform-dotall-regex": ["@babel/plugin-transform-dotall-regex@7.27.1", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-gEbkDVGRvjj7+T1ivxrfgygpT7GUd4vmODtYpbs0gZATdkX8/iSnOtZSxiZnsgm1YjTgjI6VKBGSJJevkrclzw=="], - - "@babel/plugin-transform-duplicate-keys": ["@babel/plugin-transform-duplicate-keys@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q=="], - - "@babel/plugin-transform-duplicate-named-capturing-groups-regex": ["@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.27.1", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-hkGcueTEzuhB30B3eJCbCYeCaaEQOmQR0AdvzpD4LoN0GXMWzzGSuRrxR2xTnCrvNbVwK9N6/jQ92GSLfiZWoQ=="], - - "@babel/plugin-transform-dynamic-import": ["@babel/plugin-transform-dynamic-import@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A=="], - - "@babel/plugin-transform-explicit-resource-management": ["@babel/plugin-transform-explicit-resource-management@7.28.0", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/plugin-transform-destructuring": "^7.28.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-K8nhUcn3f6iB+P3gwCv/no7OdzOZQcKchW6N389V6PD8NUWKZHzndOd9sPDVbMoBsbmjMqlB4L9fm+fEFNVlwQ=="], - - "@babel/plugin-transform-exponentiation-operator": ["@babel/plugin-transform-exponentiation-operator@7.28.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-D4WIMaFtwa2NizOp+dnoFjRez/ClKiC2BqqImwKd1X28nqBtZEyCYJ2ozQrrzlxAFrcrjxo39S6khe9RNDlGzw=="], - - "@babel/plugin-transform-export-namespace-from": ["@babel/plugin-transform-export-namespace-from@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ=="], - - "@babel/plugin-transform-for-of": ["@babel/plugin-transform-for-of@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw=="], - - "@babel/plugin-transform-function-name": ["@babel/plugin-transform-function-name@7.27.1", "", { "dependencies": { "@babel/helper-compilation-targets": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1", "@babel/traverse": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ=="], - - "@babel/plugin-transform-json-strings": ["@babel/plugin-transform-json-strings@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6WVLVJiTjqcQauBhn1LkICsR2H+zm62I3h9faTDKt1qP4jn2o72tSvqMwtGFKGTpojce0gJs+76eZ2uCHRZh0Q=="], - - "@babel/plugin-transform-literals": ["@babel/plugin-transform-literals@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA=="], - - "@babel/plugin-transform-logical-assignment-operators": ["@babel/plugin-transform-logical-assignment-operators@7.28.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-axUuqnUTBuXyHGcJEVVh9pORaN6wC5bYfE7FGzPiaWa3syib9m7g+/IT/4VgCOe2Upef43PHzeAvcrVek6QuuA=="], - - "@babel/plugin-transform-member-expression-literals": ["@babel/plugin-transform-member-expression-literals@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ=="], - - "@babel/plugin-transform-modules-amd": ["@babel/plugin-transform-modules-amd@7.27.1", "", { "dependencies": { "@babel/helper-module-transforms": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA=="], - - "@babel/plugin-transform-modules-commonjs": ["@babel/plugin-transform-modules-commonjs@7.27.1", "", { "dependencies": { "@babel/helper-module-transforms": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-OJguuwlTYlN0gBZFRPqwOGNWssZjfIUdS7HMYtN8c1KmwpwHFBwTeFZrg9XZa+DFTitWOW5iTAG7tyCUPsCCyw=="], - - "@babel/plugin-transform-modules-systemjs": ["@babel/plugin-transform-modules-systemjs@7.28.5", "", { "dependencies": { "@babel/helper-module-transforms": "^7.28.3", "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-vn5Jma98LCOeBy/KpeQhXcV2WZgaRUtjwQmjoBuLNlOmkg0fB5pdvYVeWRYI69wWKwK2cD1QbMiUQnoujWvrew=="], - - "@babel/plugin-transform-modules-umd": ["@babel/plugin-transform-modules-umd@7.27.1", "", { "dependencies": { "@babel/helper-module-transforms": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w=="], - - "@babel/plugin-transform-named-capturing-groups-regex": ["@babel/plugin-transform-named-capturing-groups-regex@7.27.1", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-SstR5JYy8ddZvD6MhV0tM/j16Qds4mIpJTOd1Yu9J9pJjH93bxHECF7pgtc28XvkzTD6Pxcm/0Z73Hvk7kb3Ng=="], - - "@babel/plugin-transform-new-target": ["@babel/plugin-transform-new-target@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ=="], - - "@babel/plugin-transform-nullish-coalescing-operator": ["@babel/plugin-transform-nullish-coalescing-operator@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA=="], - - "@babel/plugin-transform-numeric-separator": ["@babel/plugin-transform-numeric-separator@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-fdPKAcujuvEChxDBJ5c+0BTaS6revLV7CJL08e4m3de8qJfNIuCc2nc7XJYOjBoTMJeqSmwXJ0ypE14RCjLwaw=="], - - "@babel/plugin-transform-object-rest-spread": ["@babel/plugin-transform-object-rest-spread@7.28.4", "", { "dependencies": { "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-plugin-utils": "^7.27.1", "@babel/plugin-transform-destructuring": "^7.28.0", "@babel/plugin-transform-parameters": "^7.27.7", "@babel/traverse": "^7.28.4" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-373KA2HQzKhQCYiRVIRr+3MjpCObqzDlyrM6u4I201wL8Mp2wHf7uB8GhDwis03k2ti8Zr65Zyyqs1xOxUF/Ew=="], - - "@babel/plugin-transform-object-super": ["@babel/plugin-transform-object-super@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-replace-supers": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng=="], - - "@babel/plugin-transform-optional-catch-binding": ["@babel/plugin-transform-optional-catch-binding@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-txEAEKzYrHEX4xSZN4kJ+OfKXFVSWKB2ZxM9dpcE3wT7smwkNmXo5ORRlVzMVdJbD+Q8ILTgSD7959uj+3Dm3Q=="], - - "@babel/plugin-transform-optional-chaining": ["@babel/plugin-transform-optional-chaining@7.28.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-N6fut9IZlPnjPwgiQkXNhb+cT8wQKFlJNqcZkWlcTqkcqx6/kU4ynGmLFoa4LViBSirn05YAwk+sQBbPfxtYzQ=="], - - "@babel/plugin-transform-parameters": ["@babel/plugin-transform-parameters@7.27.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg=="], - - "@babel/plugin-transform-private-methods": ["@babel/plugin-transform-private-methods@7.27.1", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-10FVt+X55AjRAYI9BrdISN9/AQWHqldOeZDUoLyif1Kn05a56xVBXb8ZouL8pZ9jem8QpXaOt8TS7RHUIS+GPA=="], - - "@babel/plugin-transform-private-property-in-object": ["@babel/plugin-transform-private-property-in-object@7.27.1", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.1", "@babel/helper-create-class-features-plugin": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-5J+IhqTi1XPa0DXF83jYOaARrX+41gOewWbkPyjMNRDqgOCqdffGh8L3f/Ek5utaEBZExjSAzcyjmV9SSAWObQ=="], - - "@babel/plugin-transform-property-literals": ["@babel/plugin-transform-property-literals@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ=="], - "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="], "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="], - "@babel/plugin-transform-regenerator": ["@babel/plugin-transform-regenerator@7.28.4", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+ZEdQlBoRg9m2NnzvEeLgtvBMO4tkFBw5SQIUgLICgTrumLoU7lr+Oghi6km2PFj+dbUt2u1oby2w3BDO9YQnA=="], - - "@babel/plugin-transform-regexp-modifiers": ["@babel/plugin-transform-regexp-modifiers@7.27.1", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-TtEciroaiODtXvLZv4rmfMhkCv8jx3wgKpL68PuiPh2M4fvz5jhsA7697N1gMvkvr/JTF13DrFYyEbY9U7cVPA=="], - - "@babel/plugin-transform-reserved-words": ["@babel/plugin-transform-reserved-words@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw=="], - - "@babel/plugin-transform-shorthand-properties": ["@babel/plugin-transform-shorthand-properties@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ=="], - - "@babel/plugin-transform-spread": ["@babel/plugin-transform-spread@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-kpb3HUqaILBJcRFVhFUs6Trdd4mkrzcGXss+6/mxUd273PfbWqSDHRzMT2234gIg2QYfAjvXLSquP1xECSg09Q=="], - - "@babel/plugin-transform-sticky-regex": ["@babel/plugin-transform-sticky-regex@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g=="], - - "@babel/plugin-transform-template-literals": ["@babel/plugin-transform-template-literals@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg=="], - - "@babel/plugin-transform-typeof-symbol": ["@babel/plugin-transform-typeof-symbol@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw=="], - - "@babel/plugin-transform-typescript": ["@babel/plugin-transform-typescript@7.28.5", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-create-class-features-plugin": "^7.28.5", "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-x2Qa+v/CuEoX7Dr31iAfr0IhInrVOWZU/2vJMJ00FOR/2nM0BcBEclpaf9sWCDc+v5e9dMrhSH8/atq/kX7+bA=="], - - "@babel/plugin-transform-unicode-escapes": ["@babel/plugin-transform-unicode-escapes@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg=="], - - "@babel/plugin-transform-unicode-property-regex": ["@babel/plugin-transform-unicode-property-regex@7.27.1", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-uW20S39PnaTImxp39O5qFlHLS9LJEmANjMG7SxIhap8rCHqu0Ik+tLEPX5DKmHn6CsWQ7j3lix2tFOa5YtL12Q=="], - - "@babel/plugin-transform-unicode-regex": ["@babel/plugin-transform-unicode-regex@7.27.1", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw=="], - - "@babel/plugin-transform-unicode-sets-regex": ["@babel/plugin-transform-unicode-sets-regex@7.27.1", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-EtkOujbc4cgvb0mlpQefi4NTPBzhSIevblFevACNLUspmrALgmEBdL/XfnyyITfd8fKBZrZys92zOWcik7j9Tw=="], - - "@babel/preset-env": ["@babel/preset-env@7.28.5", "", { "dependencies": { "@babel/compat-data": "^7.28.5", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.28.5", "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.27.1", "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.27.1", "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.27.1", "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.28.3", "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", "@babel/plugin-syntax-import-assertions": "^7.27.1", "@babel/plugin-syntax-import-attributes": "^7.27.1", "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", "@babel/plugin-transform-arrow-functions": "^7.27.1", "@babel/plugin-transform-async-generator-functions": "^7.28.0", "@babel/plugin-transform-async-to-generator": "^7.27.1", "@babel/plugin-transform-block-scoped-functions": "^7.27.1", "@babel/plugin-transform-block-scoping": "^7.28.5", "@babel/plugin-transform-class-properties": "^7.27.1", "@babel/plugin-transform-class-static-block": "^7.28.3", "@babel/plugin-transform-classes": "^7.28.4", "@babel/plugin-transform-computed-properties": "^7.27.1", "@babel/plugin-transform-destructuring": "^7.28.5", "@babel/plugin-transform-dotall-regex": "^7.27.1", "@babel/plugin-transform-duplicate-keys": "^7.27.1", "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.27.1", "@babel/plugin-transform-dynamic-import": "^7.27.1", "@babel/plugin-transform-explicit-resource-management": "^7.28.0", "@babel/plugin-transform-exponentiation-operator": "^7.28.5", "@babel/plugin-transform-export-namespace-from": "^7.27.1", "@babel/plugin-transform-for-of": "^7.27.1", "@babel/plugin-transform-function-name": "^7.27.1", "@babel/plugin-transform-json-strings": "^7.27.1", "@babel/plugin-transform-literals": "^7.27.1", "@babel/plugin-transform-logical-assignment-operators": "^7.28.5", "@babel/plugin-transform-member-expression-literals": "^7.27.1", "@babel/plugin-transform-modules-amd": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-modules-systemjs": "^7.28.5", "@babel/plugin-transform-modules-umd": "^7.27.1", "@babel/plugin-transform-named-capturing-groups-regex": "^7.27.1", "@babel/plugin-transform-new-target": "^7.27.1", "@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1", "@babel/plugin-transform-numeric-separator": "^7.27.1", "@babel/plugin-transform-object-rest-spread": "^7.28.4", "@babel/plugin-transform-object-super": "^7.27.1", "@babel/plugin-transform-optional-catch-binding": "^7.27.1", "@babel/plugin-transform-optional-chaining": "^7.28.5", "@babel/plugin-transform-parameters": "^7.27.7", "@babel/plugin-transform-private-methods": "^7.27.1", "@babel/plugin-transform-private-property-in-object": "^7.27.1", "@babel/plugin-transform-property-literals": "^7.27.1", "@babel/plugin-transform-regenerator": "^7.28.4", "@babel/plugin-transform-regexp-modifiers": "^7.27.1", "@babel/plugin-transform-reserved-words": "^7.27.1", "@babel/plugin-transform-shorthand-properties": "^7.27.1", "@babel/plugin-transform-spread": "^7.27.1", "@babel/plugin-transform-sticky-regex": "^7.27.1", "@babel/plugin-transform-template-literals": "^7.27.1", "@babel/plugin-transform-typeof-symbol": "^7.27.1", "@babel/plugin-transform-unicode-escapes": "^7.27.1", "@babel/plugin-transform-unicode-property-regex": "^7.27.1", "@babel/plugin-transform-unicode-regex": "^7.27.1", "@babel/plugin-transform-unicode-sets-regex": "^7.27.1", "@babel/preset-modules": "0.1.6-no-external-plugins", "babel-plugin-polyfill-corejs2": "^0.4.14", "babel-plugin-polyfill-corejs3": "^0.13.0", "babel-plugin-polyfill-regenerator": "^0.6.5", "core-js-compat": "^3.43.0", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-S36mOoi1Sb6Fz98fBfE+UZSpYw5mJm0NUHtIKrOuNcqeFauy1J6dIvXm2KRVKobOSaGq4t/hBXdN4HGU3wL9Wg=="], - - "@babel/preset-modules": ["@babel/preset-modules@0.1.6-no-external-plugins", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.0.0", "@babel/types": "^7.4.4", "esutils": "^2.0.2" }, "peerDependencies": { "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" } }, "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA=="], - - "@babel/preset-typescript": ["@babel/preset-typescript@7.28.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.28.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g=="], - "@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="], "@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="], @@ -505,63 +351,63 @@ "@electron/universal": ["@electron/universal@1.5.1", "", { "dependencies": { "@electron/asar": "^3.2.1", "@malept/cross-spawn-promise": "^1.1.0", "debug": "^4.3.1", "dir-compare": "^3.0.0", "fs-extra": "^9.0.1", "minimatch": "^3.0.4", "plist": "^3.0.4" } }, "sha512-kbgXxyEauPJiQQUNG2VgUeyfQNFk6hBF11ISN2PNI6agUgPl55pv4eQmaqHzTAzchBvqZ2tQuRVaPStGf0mxGw=="], - "@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="], + "@emnapi/core": ["@emnapi/core@1.6.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-zq/ay+9fNIJJtJiZxdTnXS20PllcYMX3OE23ESc4HK/bdYu3cOWYVhsOhVnXALfU/uqJIxn5NBPd9z4v+SfoSg=="], "@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="], "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.11", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg=="], - "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.11", "", { "os": "android", "cpu": "arm" }, "sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg=="], - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.11", "", { "os": "android", "cpu": "arm64" }, "sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ=="], - "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.11", "", { "os": "android", "cpu": "x64" }, "sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g=="], - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w=="], - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ=="], - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.11", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA=="], - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.11", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw=="], - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.11", "", { "os": "linux", "cpu": "arm" }, "sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw=="], - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA=="], - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.11", "", { "os": "linux", "cpu": "ia32" }, "sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw=="], - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.11", "", { "os": "linux", "cpu": "none" }, "sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw=="], - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.11", "", { "os": "linux", "cpu": "none" }, "sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ=="], - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.11", "", { "os": "linux", "cpu": "ppc64" }, "sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw=="], - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.11", "", { "os": "linux", "cpu": "none" }, "sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww=="], - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.11", "", { "os": "linux", "cpu": "s390x" }, "sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw=="], - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.11", "", { "os": "linux", "cpu": "x64" }, "sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ=="], - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.11", "", { "os": "none", "cpu": "arm64" }, "sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg=="], - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.11", "", { "os": "none", "cpu": "x64" }, "sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A=="], - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.11", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg=="], - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.11", "", { "os": "openbsd", "cpu": "x64" }, "sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw=="], - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.11", "", { "os": "none", "cpu": "arm64" }, "sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ=="], - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.11", "", { "os": "sunos", "cpu": "x64" }, "sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA=="], - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q=="], - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.11", "", { "os": "win32", "cpu": "ia32" }, "sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA=="], - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.11", "", { "os": "win32", "cpu": "x64" }, "sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA=="], "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g=="], @@ -569,17 +415,17 @@ "@eslint/config-array": ["@eslint/config-array@0.21.1", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA=="], - "@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="], + "@eslint/config-helpers": ["@eslint/config-helpers@0.4.1", "", { "dependencies": { "@eslint/core": "^0.16.0" } }, "sha512-csZAzkNhsgwb0I/UAV6/RGFTbiakPCf0ZrGmrIxQpYvGZ00PhTkSnyKNolphgIvmnJeGw6rcGVEXfTzUnFuEvw=="], - "@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="], + "@eslint/core": ["@eslint/core@0.16.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q=="], "@eslint/eslintrc": ["@eslint/eslintrc@3.3.1", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ=="], - "@eslint/js": ["@eslint/js@9.39.1", "", {}, "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw=="], + "@eslint/js": ["@eslint/js@9.38.0", "", {}, "sha512-UZ1VpFvXf9J06YG9xQBdnzU+kthors6KjhMAl6f4gH4usHyh31rUf2DLGInT8RFYIReYXNSydgPY0V2LuWgl7A=="], "@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="], - "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.0", "", { "dependencies": { "@eslint/core": "^0.16.0", "levn": "^0.4.1" } }, "sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A=="], "@floating-ui/core": ["@floating-ui/core@1.7.3", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w=="], @@ -743,23 +589,23 @@ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="], - "@next/env": ["@next/env@16.0.4", "", {}, "sha512-FDPaVoB1kYhtOz6Le0Jn2QV7RZJ3Ngxzqri7YX4yu3Ini+l5lciR7nA9eNDpKTmDm7LWZtxSju+/CQnwRBn2pA=="], + "@next/env": ["@next/env@16.0.3", "", {}, "sha512-IqgtY5Vwsm14mm/nmQaRMmywCU+yyMIYfk3/MHZ2ZTJvwVbBn3usZnjMi1GacrMVzVcAxJShTCpZlPs26EdEjQ=="], - "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TN0cfB4HT2YyEio9fLwZY33J+s+vMIgC84gQCOLZOYusW7ptgjIn8RwxQt0BUpoo9XRRVVWEHLld0uhyux1ZcA=="], + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-MOnbd92+OByu0p6QBAzq1ahVWzF6nyfiH07dQDez4/Nku7G249NjxDVyEfVhz8WkLiOEU+KFVnqtgcsfP2nLXg=="], - "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-XsfI23jvimCaA7e+9f3yMCoVjrny2D11G6H8NCcgv+Ina/TQhKPXB9P4q0WjTuEoyZmcNvPdrZ+XtTh3uPfH7Q=="], + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-i70C4O1VmbTivYdRlk+5lj9xRc2BlK3oUikt3yJeHT1unL4LsNtN7UiOhVanFdc7vDAgZn1tV/9mQwMkWOJvHg=="], - "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-uo8X7qHDy4YdJUhaoJDMAbL8VT5Ed3lijip2DdBHIB4tfKAvB1XBih6INH2L4qIi4jA0Qq1J0ErxcOocBmUSwg=="], + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-O88gCZ95sScwD00mn/AtalyCoykhhlokxH/wi1huFK+rmiP5LAYVs/i2ruk7xST6SuXN4NI5y4Xf5vepb2jf6A=="], - "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-pvR/AjNIAxsIz0PCNcZYpH+WmNIKNLcL4XYEfo+ArDi7GsxKWFO5BvVBLXbhti8Coyv3DE983NsitzUsGH5yTw=="], + "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-CEErFt78S/zYXzFIiv18iQCbRbLgBluS8z1TNDQoyPi8/Jr5qhR3e8XHAIxVxPBjDbEMITprqELVc5KTfFj0gg=="], - "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-2hebpsd5MRRtgqmT7Jj/Wze+wG+ZEXUK2KFFL4IlZ0amEEFADo4ywsifJNeFTQGsamH3/aXkKWymDvgEi+pc2Q=="], + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Tc3i+nwt6mQ+Dwzcri/WNDj56iWdycGVh5YwwklleClzPzz7UpfaMw1ci7bLl6GRYMXhWDBfe707EXNjKtiswQ=="], - "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-pzRXf0LZZ8zMljH78j8SeLncg9ifIOp3ugAFka+Bq8qMzw6hPXOc7wydY7ardIELlczzzreahyTpwsim/WL3Sg=="], + "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-zTh03Z/5PBBPdTurgEtr6nY0vI9KR9Ifp/jZCcHlODzwVOEKcKRBtQIGrkc7izFgOMuXDEJBmirwpGqdM/ZixA=="], - "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.0.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-7G/yJVzum52B5HOqqbQYX9bJHkN+c4YyZ2AIvEssMHQlbAWOn3iIJjD4sM6ihWsBxuljiTKJovEYlD1K8lCUHw=="], + "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.0.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-Jc1EHxtZovcJcg5zU43X3tuqzl/sS+CmLgjRP28ZT4vk869Ncm2NoF8qSTaL99gh6uOzgM99Shct06pSO6kA6g=="], - "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-0Vy4g8SSeVkuU89g2OFHqGKM4rxsQtihGfenjx2tRckPrge5+gtFnRWGAAwvGXr0ty3twQvcnYjEyOrLHJ4JWA=="], + "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-N7EJ6zbxgIYpI/sWNzpVKRMbfEGgsWuOIvzkML7wxAAZhPk1Msxuo/JDu1PKjWGrAoOLaZcIX5s+/pF5LIbBBg=="], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], @@ -775,47 +621,17 @@ "@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@1.2.5", "", { "dependencies": { "@openrouter/sdk": "^0.1.8" }, "peerDependencies": { "ai": "^5.0.0", "zod": "^3.24.1 || ^v4" } }, "sha512-NrvJFPvdEUo6DYUQIVWPGfhafuZ2PAIX7+CUMKGknv8TcTNVo0TyP1y5SU7Bgjf/Wup9/74UFKUB07icOhVZjQ=="], - "@openrouter/sdk": ["@openrouter/sdk@0.1.27", "", { "dependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-RH//L10bSmc81q25zAZudiI4kNkLgxF2E+WU42vghp3N6TEvZ6F0jK7uT3tOxkEn91gzmMw9YVmDENy7SJsajQ=="], + "@openrouter/sdk": ["@openrouter/sdk@0.1.11", "", { "dependencies": { "zod": "^3.25.0 || ^4.0.0" }, "peerDependencies": { "@tanstack/react-query": "^5", "react": "^18 || ^19", "react-dom": "^18 || ^19" }, "optionalPeers": ["@tanstack/react-query", "react", "react-dom"] }, "sha512-OuPc8qqidL/PUM8+9WgrOfSR9+b6rKIWiezGcUJ54iPTdh+Gye5Qjut6hrLWlOCMZE7Z853gN90r1ft4iChj7Q=="], "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], - "@orpc/client": ["@orpc/client@1.11.3", "", { "dependencies": { "@orpc/shared": "1.11.3", "@orpc/standard-server": "1.11.3", "@orpc/standard-server-fetch": "1.11.3", "@orpc/standard-server-peer": "1.11.3" } }, "sha512-USuUOvG07odUzrn3/xGE0V+JbK6DV+eYqURa98kMelSoGRLP0ceqomu49s1+paKYgT1fefRDMaCKxo04hgRNhg=="], - - "@orpc/contract": ["@orpc/contract@1.11.3", "", { "dependencies": { "@orpc/client": "1.11.3", "@orpc/shared": "1.11.3", "@standard-schema/spec": "^1.0.0", "openapi-types": "^12.1.3" } }, "sha512-tEZ2jGVCtSHd6gijl/ASA9RhJOUAtaDtsDtkwARCxeA9gshxcaAHXTcG1l1Vvy4fezcj1xZ1fzS8uYWlcrVF7A=="], - - "@orpc/interop": ["@orpc/interop@1.11.3", "", {}, "sha512-NOTXLsp1jkFyHGzZM0qST9LtCrBUr5qN7OEDpslPXm2xV6I1IFok15QoVtxg033vEBXD5AbtTVCkzmaLb5JJ1w=="], - - "@orpc/json-schema": ["@orpc/json-schema@1.11.3", "", { "dependencies": { "@orpc/contract": "1.11.3", "@orpc/interop": "1.11.3", "@orpc/openapi": "1.11.3", "@orpc/server": "1.11.3", "@orpc/shared": "1.11.3" } }, "sha512-xaJfzXFDdo2HXkXBC0oWT+RjHaipyxn+r2nS8XfQdkDfQ/6CL0TFdN2irFcMaTXkWzEpyUuzZ+/vElZ4QVeQ+w=="], - - "@orpc/openapi": ["@orpc/openapi@1.11.3", "", { "dependencies": { "@orpc/client": "1.11.3", "@orpc/contract": "1.11.3", "@orpc/interop": "1.11.3", "@orpc/openapi-client": "1.11.3", "@orpc/server": "1.11.3", "@orpc/shared": "1.11.3", "@orpc/standard-server": "1.11.3", "rou3": "^0.7.10" } }, "sha512-whhg5o75IvkCQ+90JE9XypbpAikH7DasewmUnkB32xLrL90QXdQz5WME4d3lkVDSBISM06ZKh+VIKtY8w9D9Ew=="], - - "@orpc/openapi-client": ["@orpc/openapi-client@1.11.3", "", { "dependencies": { "@orpc/client": "1.11.3", "@orpc/contract": "1.11.3", "@orpc/shared": "1.11.3", "@orpc/standard-server": "1.11.3" } }, "sha512-6xjf4O5J7Ge6m1mLlsTrM/SQaOOvcIFpW9uxGJImlXmfYn36Ui0FshU/z+mV6xSYbiywLIfM3VKPMrUUQTbweg=="], - - "@orpc/server": ["@orpc/server@1.11.3", "", { "dependencies": { "@orpc/client": "1.11.3", "@orpc/contract": "1.11.3", "@orpc/interop": "1.11.3", "@orpc/shared": "1.11.3", "@orpc/standard-server": "1.11.3", "@orpc/standard-server-aws-lambda": "1.11.3", "@orpc/standard-server-fastify": "1.11.3", "@orpc/standard-server-fetch": "1.11.3", "@orpc/standard-server-node": "1.11.3", "@orpc/standard-server-peer": "1.11.3", "cookie": "^1.0.2" }, "peerDependencies": { "crossws": ">=0.3.4", "ws": ">=8.18.1" }, "optionalPeers": ["crossws", "ws"] }, "sha512-lgwIAk8VzeoIrR/i9x2VWj/KdmCrg4lqfQeybsXABBR9xJsPAZtW3ClgjNq60+leqiGnVTpj2Xxphja22bGA0A=="], - - "@orpc/shared": ["@orpc/shared@1.11.3", "", { "dependencies": { "radash": "^12.1.1", "type-fest": "^5.2.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0" }, "optionalPeers": ["@opentelemetry/api"] }, "sha512-hOPZhNI0oIhw91NNu4ndrmpWLdZyXTGx7tzq/bG5LwtuHuUsl4FalRsUfSIuap/V1ESOnPqSzmmSOdRv+ITcRA=="], - - "@orpc/standard-server": ["@orpc/standard-server@1.11.3", "", { "dependencies": { "@orpc/shared": "1.11.3" } }, "sha512-j61f0TqITURN+5zft3vDjuyHjwTkusx91KrTGxfZ3E6B/dP2SLtoPCvTF8aecozxb5KvyhvAvbuDQMPeyqXvDg=="], - - "@orpc/standard-server-aws-lambda": ["@orpc/standard-server-aws-lambda@1.11.3", "", { "dependencies": { "@orpc/shared": "1.11.3", "@orpc/standard-server": "1.11.3", "@orpc/standard-server-fetch": "1.11.3", "@orpc/standard-server-node": "1.11.3" } }, "sha512-LYJkps5hRKtBpeVeXE5xxdXhgPFj8I1wPtl+PJj06LIkuwuNWEmWdlrGH5lcyh5pWtJn8yJSDOIuGqHbuMTB7Q=="], - - "@orpc/standard-server-fastify": ["@orpc/standard-server-fastify@1.11.3", "", { "dependencies": { "@orpc/shared": "1.11.3", "@orpc/standard-server": "1.11.3", "@orpc/standard-server-node": "1.11.3" }, "peerDependencies": { "fastify": ">=5.6.1" }, "optionalPeers": ["fastify"] }, "sha512-Zom7Q4dDZW27KE4gco9HEH59dmBx2GLIqoRuy8LB97boktsGlbF/CVQ2W1ivcLOZ4yuJ0YXmq4egoWQ20apZww=="], - - "@orpc/standard-server-fetch": ["@orpc/standard-server-fetch@1.11.3", "", { "dependencies": { "@orpc/shared": "1.11.3", "@orpc/standard-server": "1.11.3" } }, "sha512-wiudo8W/NHaosygIpU/NJGZVBTueSHSRU4y0pIwvAhA0f9ZQ9/aCwnYxR7lnvCizzb2off8kxxKKqkS3xYRepA=="], - - "@orpc/standard-server-node": ["@orpc/standard-server-node@1.11.3", "", { "dependencies": { "@orpc/shared": "1.11.3", "@orpc/standard-server": "1.11.3", "@orpc/standard-server-fetch": "1.11.3" } }, "sha512-PvGKFMs1CGZ/phiftEadUh1KwLZXgN2Q5XEw2NNE8Q8YXAClwPBSLcCRp4dVRMwo06hONznW04uUubh2OA0MWA=="], - - "@orpc/standard-server-peer": ["@orpc/standard-server-peer@1.11.3", "", { "dependencies": { "@orpc/shared": "1.11.3", "@orpc/standard-server": "1.11.3" } }, "sha512-GkINRYjWRTOKQIsPWvqCvbjNjaLnhDAVJLrQNGTaqy7yLTDG8ome7hCrmH3bdjDY4nDlt8OoUaq9oABE/1rMew=="], - - "@orpc/zod": ["@orpc/zod@1.11.3", "", { "dependencies": { "@orpc/json-schema": "1.11.3", "@orpc/openapi": "1.11.3", "@orpc/shared": "1.11.3", "escape-string-regexp": "^5.0.0", "wildcard-match": "^5.1.3" }, "peerDependencies": { "@orpc/contract": "1.11.3", "@orpc/server": "1.11.3", "zod": ">=3.25.0" } }, "sha512-nkZMK+LfNo4qtN59NCAyf+bG83R+T79Mvqx8KiRdjfGF/4nfFhaGIuNieQJIVRgddpzr7nFcHcJJf9DEyp2KnQ=="], - "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], "@pkgr/core": ["@pkgr/core@0.2.9", "", {}, "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA=="], - "@playwright/test": ["@playwright/test@1.57.0", "", { "dependencies": { "playwright": "1.57.0" }, "bin": { "playwright": "cli.js" } }, "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA=="], + "@playwright/test": ["@playwright/test@1.56.1", "", { "dependencies": { "playwright": "1.56.1" }, "bin": { "playwright": "cli.js" } }, "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg=="], - "@posthog/core": ["@posthog/core@1.6.0", "", { "dependencies": { "cross-spawn": "^7.0.6" } }, "sha512-Tbh8UACwbb7jFdDC7wwXHtfNzO+4wKh3VbyMHmp2UBe6w1jliJixexTJNfkqdGZm+ht3M10mcKvGGPnoZ2zLBg=="], + "@posthog/core": ["@posthog/core@1.4.0", "", {}, "sha512-jmW8/I//YOHAfjzokqas+Qtc2T57Ux8d2uIJu7FLcMGxywckHsl6od59CD18jtUzKToQdjQhV6Y3429qj+KeNw=="], "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], @@ -855,7 +671,7 @@ "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="], - "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], + "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="], @@ -863,9 +679,9 @@ "@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="], - "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="], + "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="], - "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], + "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="], @@ -907,61 +723,61 @@ "@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="], - "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.53.3", "", { "os": "android", "cpu": "arm" }, "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w=="], + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.52.5", "", { "os": "android", "cpu": "arm" }, "sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ=="], - "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.53.3", "", { "os": "android", "cpu": "arm64" }, "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w=="], + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.52.5", "", { "os": "android", "cpu": "arm64" }, "sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA=="], - "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.53.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA=="], + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.52.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA=="], - "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.53.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ=="], + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.52.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-W901Pla8Ya95WpxDn//VF9K9u2JbocwV/v75TE0YIHNTbhqUTv9w4VuQ9MaWlNOkkEfFwkdNhXgcLqPSmHy0fA=="], - "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.53.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w=="], + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.52.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA=="], - "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.53.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q=="], + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.52.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jr21b/99ew8ujZubPo9skbrItHEIE50WdV86cdSoRkKtmWa+DDr6fu2c/xyRT0F/WazZpam6kk7IHBerSL7LDQ=="], - "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.53.3", "", { "os": "linux", "cpu": "arm" }, "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw=="], + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.52.5", "", { "os": "linux", "cpu": "arm" }, "sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ=="], - "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.53.3", "", { "os": "linux", "cpu": "arm" }, "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg=="], + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.52.5", "", { "os": "linux", "cpu": "arm" }, "sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ=="], - "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.53.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w=="], + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.52.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg=="], - "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.53.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A=="], + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.52.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q=="], - "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.53.3", "", { "os": "linux", "cpu": "none" }, "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g=="], + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.52.5", "", { "os": "linux", "cpu": "none" }, "sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA=="], - "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.53.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw=="], + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.52.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw=="], - "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.53.3", "", { "os": "linux", "cpu": "none" }, "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g=="], + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.52.5", "", { "os": "linux", "cpu": "none" }, "sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw=="], - "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.53.3", "", { "os": "linux", "cpu": "none" }, "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A=="], + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.52.5", "", { "os": "linux", "cpu": "none" }, "sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg=="], - "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.53.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg=="], + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.52.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ=="], - "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.53.3", "", { "os": "linux", "cpu": "x64" }, "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w=="], + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.52.5", "", { "os": "linux", "cpu": "x64" }, "sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q=="], - "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.53.3", "", { "os": "linux", "cpu": "x64" }, "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q=="], + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.52.5", "", { "os": "linux", "cpu": "x64" }, "sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg=="], - "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.53.3", "", { "os": "none", "cpu": "arm64" }, "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw=="], + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.52.5", "", { "os": "none", "cpu": "arm64" }, "sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw=="], - "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.53.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw=="], + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.52.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w=="], - "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.53.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA=="], + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.52.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg=="], - "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.53.3", "", { "os": "win32", "cpu": "x64" }, "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg=="], + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.52.5", "", { "os": "win32", "cpu": "x64" }, "sha512-UGBUGPFp1vkj6p8wCRraqNhqwX/4kNQPS57BCFc8wYh0g94iVIW33wJtQAx3G7vrjjNtRaxiMUylM0ktp/TRSQ=="], - "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.53.3", "", { "os": "win32", "cpu": "x64" }, "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ=="], + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.52.5", "", { "os": "win32", "cpu": "x64" }, "sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg=="], - "@shikijs/core": ["@shikijs/core@3.15.0", "", { "dependencies": { "@shikijs/types": "3.15.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-8TOG6yG557q+fMsSVa8nkEDOZNTSxjbbR8l6lF2gyr6Np+jrPlslqDxQkN6rMXCECQ3isNPZAGszAfYoJOPGlg=="], + "@shikijs/core": ["@shikijs/core@3.14.0", "", { "dependencies": { "@shikijs/types": "3.14.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-qRSeuP5vlYHCNUIrpEBQFO7vSkR7jn7Kv+5X3FO/zBKVDGQbcnlScD3XhkrHi/R8Ltz0kEjvFR9Szp/XMRbFMw=="], - "@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.15.0", "", { "dependencies": { "@shikijs/types": "3.15.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-ZedbOFpopibdLmvTz2sJPJgns8Xvyabe2QbmqMTz07kt1pTzfEvKZc5IqPVO/XFiEbbNyaOpjPBkkr1vlwS+qg=="], + "@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.14.0", "", { "dependencies": { "@shikijs/types": "3.14.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-3v1kAXI2TsWQuwv86cREH/+FK9Pjw3dorVEykzQDhwrZj0lwsHYlfyARaKmn6vr5Gasf8aeVpb8JkzeWspxOLQ=="], - "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.15.0", "", { "dependencies": { "@shikijs/types": "3.15.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-HnqFsV11skAHvOArMZdLBZZApRSYS4LSztk2K3016Y9VCyZISnlYUYsL2hzlS7tPqKHvNqmI5JSUJZprXloMvA=="], + "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.14.0", "", { "dependencies": { "@shikijs/types": "3.14.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-TNcYTYMbJyy+ZjzWtt0bG5y4YyMIWC2nyePz+CFMWqm+HnZZyy9SWMgo8Z6KBJVIZnx8XUXS8U2afO6Y0g1Oug=="], - "@shikijs/langs": ["@shikijs/langs@3.15.0", "", { "dependencies": { "@shikijs/types": "3.15.0" } }, "sha512-WpRvEFvkVvO65uKYW4Rzxs+IG0gToyM8SARQMtGGsH4GDMNZrr60qdggXrFOsdfOVssG/QQGEl3FnJ3EZ+8w8A=="], + "@shikijs/langs": ["@shikijs/langs@3.14.0", "", { "dependencies": { "@shikijs/types": "3.14.0" } }, "sha512-DIB2EQY7yPX1/ZH7lMcwrK5pl+ZkP/xoSpUzg9YC8R+evRCCiSQ7yyrvEyBsMnfZq4eBzLzBlugMyTAf13+pzg=="], - "@shikijs/themes": ["@shikijs/themes@3.15.0", "", { "dependencies": { "@shikijs/types": "3.15.0" } }, "sha512-8ow2zWb1IDvCKjYb0KiLNrK4offFdkfNVPXb1OZykpLCzRU6j+efkY+Y7VQjNlNFXonSw+4AOdGYtmqykDbRiQ=="], + "@shikijs/themes": ["@shikijs/themes@3.14.0", "", { "dependencies": { "@shikijs/types": "3.14.0" } }, "sha512-fAo/OnfWckNmv4uBoUu6dSlkcBc+SA1xzj5oUSaz5z3KqHtEbUypg/9xxgJARtM6+7RVm0Q6Xnty41xA1ma1IA=="], - "@shikijs/types": ["@shikijs/types@3.15.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-BnP+y/EQnhihgHy4oIAN+6FFtmfTekwOLsQbRw9hOKwqgNy8Bdsjq8B05oAt/ZgvIWWFrshV71ytOrlPfYjIJw=="], + "@shikijs/types": ["@shikijs/types@3.14.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-bQGgC6vrY8U/9ObG1Z/vTro+uclbjjD/uG58RvfxKZVD5p9Yc1ka3tVyEFy7BNJLzxuWyHH5NWynP9zZZS59eQ=="], "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], @@ -1063,25 +879,25 @@ "@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], - "@storybook/addon-docs": ["@storybook/addon-docs@10.0.8", "", { "dependencies": { "@mdx-js/react": "^3.0.0", "@storybook/csf-plugin": "10.0.8", "@storybook/icons": "^1.6.0", "@storybook/react-dom-shim": "10.0.8", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "ts-dedent": "^2.0.0" }, "peerDependencies": { "storybook": "^10.0.8" } }, "sha512-PYuaGXGycsamK/7OrFoE4syHGy22mdqqArl67cfosRwmRxZEI9ManQK0jTjNQM9ZX14NpThMOSWNGoWLckkxog=="], + "@storybook/addon-docs": ["@storybook/addon-docs@10.0.0", "", { "dependencies": { "@mdx-js/react": "^3.0.0", "@storybook/csf-plugin": "10.0.0", "@storybook/icons": "^1.6.0", "@storybook/react-dom-shim": "10.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "ts-dedent": "^2.0.0" }, "peerDependencies": { "storybook": "^10.0.0" } }, "sha512-mwEI/os48ncIQMrLFAI3rJf88Ge/2/7Pj+g6+MRYjWAz5x9zCLrOgRUJFRvuzVY4SJKsKuSPYplrbmj4L+YlRQ=="], - "@storybook/addon-links": ["@storybook/addon-links@10.0.8", "", { "dependencies": { "@storybook/global": "^5.0.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.0.8" }, "optionalPeers": ["react"] }, "sha512-LnakruogdN5ND0cF0SOKyhzbEeIGDe1njkufX2aR9LOXQ0mMj5S2P86TdP87dR5R9bJjYYPPg/F7sjsAiI1Lqg=="], + "@storybook/addon-links": ["@storybook/addon-links@10.0.0", "", { "dependencies": { "@storybook/global": "^5.0.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.0.0" }, "optionalPeers": ["react"] }, "sha512-HCMA2eLuUyAZVyoEAgROvrrpKQYMD3BsjG7cc6nNxVQQO9xw5vcC6uKp/o6Yim3iiT5A+Vy/jSH72Lj9v9E0qA=="], - "@storybook/builder-vite": ["@storybook/builder-vite@10.0.8", "", { "dependencies": { "@storybook/csf-plugin": "10.0.8", "ts-dedent": "^2.0.0" }, "peerDependencies": { "storybook": "^10.0.8", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-kaf/pUENzXxYgQMHGGPNiIk1ieb+SOMuSeLKx8wAUOlQOrzhtSH+ItACW/l43t+O6YZ8jYHoNBMF1kdQ1+Y5+w=="], + "@storybook/builder-vite": ["@storybook/builder-vite@10.0.0", "", { "dependencies": { "@storybook/csf-plugin": "10.0.0", "ts-dedent": "^2.0.0" }, "peerDependencies": { "storybook": "^10.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-D8rcLAJSKeAol/xFA+uB9YGKOzg/SZiSMw12DkrJGgJD7GGM9xPR7VwQVxPtMUewmQrPtYB7LZ3Eaa+7PlMQ4Q=="], - "@storybook/csf-plugin": ["@storybook/csf-plugin@10.0.8", "", { "dependencies": { "unplugin": "^2.3.5" }, "peerDependencies": { "esbuild": "*", "rollup": "*", "storybook": "^10.0.8", "vite": "*", "webpack": "*" }, "optionalPeers": ["esbuild", "rollup", "vite", "webpack"] }, "sha512-OtLUWHIm3SDGtclQn6Mdd/YsWizLBgdEBRAdekGtwI/TvICfT7gpWYIycP53v2t9ufu2MIXjsxtV2maZKs8sZg=="], + "@storybook/csf-plugin": ["@storybook/csf-plugin@10.0.0", "", { "dependencies": { "unplugin": "^2.3.5" }, "peerDependencies": { "esbuild": "*", "rollup": "*", "storybook": "^10.0.0", "vite": "*", "webpack": "*" }, "optionalPeers": ["esbuild", "rollup", "vite", "webpack"] }, "sha512-PLmhyDOCD71gRiWI1sUhf515PNNopp9MxWPEFfXN7ijBYZA4WJwHz1DBXK2qif/cY+e+Z12Wirhf0wM2kkOBJg=="], "@storybook/global": ["@storybook/global@5.0.0", "", {}, "sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ=="], "@storybook/icons": ["@storybook/icons@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta" } }, "sha512-hcFZIjW8yQz8O8//2WTIXylm5Xsgc+lW9ISLgUk1xGmptIJQRdlhVIXCpSyLrQaaRiyhQRaVg7l3BD9S216BHw=="], - "@storybook/react": ["@storybook/react@10.0.8", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/react-dom-shim": "10.0.8" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.0.8", "typescript": ">= 4.9.x" }, "optionalPeers": ["typescript"] }, "sha512-PkuPb8sAqmjjkowSzm3rutiSuETvZI2F8SnjbHE6FRqZWWK4iFoaUrQbrg5kpPAtX//xIrqkdFwlbmQ3skhiPA=="], + "@storybook/react": ["@storybook/react@10.0.0", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/react-dom-shim": "10.0.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.0.0", "typescript": ">= 4.9.x" }, "optionalPeers": ["typescript"] }, "sha512-9e0RMlMG1QJFbga258AchHQlpD9uF+uGALi63kVILm5OApVyc9sC1FGgHtVS7DrEIdW5wVCWAFLNzgSw2YFC2w=="], - "@storybook/react-dom-shim": ["@storybook/react-dom-shim@10.0.8", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.0.8" } }, "sha512-ojuH22MB9Sz6rWbhTmC5IErZr0ZADbZijtPteUdydezY7scORT00UtbNoBcG0V6iVjdChgDtSKw2KHUUfchKqg=="], + "@storybook/react-dom-shim": ["@storybook/react-dom-shim@10.0.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.0.0" } }, "sha512-A4+DCu9o1F0ONpJx5yHIZ37Q7h63zxHIhK1MfDpOLfwfrapUkc/uag3WZuhwXrQMUbgFUgNA1A+8TceU5W4czA=="], - "@storybook/react-vite": ["@storybook/react-vite@10.0.8", "", { "dependencies": { "@joshwooding/vite-plugin-react-docgen-typescript": "0.6.1", "@rollup/pluginutils": "^5.0.2", "@storybook/builder-vite": "10.0.8", "@storybook/react": "10.0.8", "empathic": "^2.0.0", "magic-string": "^0.30.0", "react-docgen": "^8.0.0", "resolve": "^1.22.8", "tsconfig-paths": "^4.2.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.0.8", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-HS2X4qlitrZr3/sN2+ollxAaNE813IasZRE8lOez1Ey1ISGBtYIb9rmJs82MK35+yDM0pHdiDjkFMD4SkNYh2g=="], + "@storybook/react-vite": ["@storybook/react-vite@10.0.0", "", { "dependencies": { "@joshwooding/vite-plugin-react-docgen-typescript": "0.6.1", "@rollup/pluginutils": "^5.0.2", "@storybook/builder-vite": "10.0.0", "@storybook/react": "10.0.0", "empathic": "^2.0.0", "magic-string": "^0.30.0", "react-docgen": "^8.0.0", "resolve": "^1.22.8", "tsconfig-paths": "^4.2.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-2R9RHuZsPuuNZZMyL3R+h+FJ2mhkj34zIJRgWNFx+41RujOjNUBFEAxUZ7aKcmZvWLN5SRzmAwKR3g42JNtS+A=="], - "@storybook/test-runner": ["@storybook/test-runner@0.24.1", "", { "dependencies": { "@babel/core": "^7.22.5", "@babel/generator": "^7.22.5", "@babel/template": "^7.22.5", "@babel/types": "^7.22.5", "@jest/types": "^30.0.1", "@swc/core": "^1.5.22", "@swc/jest": "^0.2.38", "expect-playwright": "^0.8.0", "jest": "^30.0.4", "jest-circus": "^30.0.4", "jest-environment-node": "^30.0.4", "jest-junit": "^16.0.0", "jest-process-manager": "^0.4.0", "jest-runner": "^30.0.4", "jest-serializer-html": "^7.1.0", "jest-watch-typeahead": "^3.0.1", "nyc": "^15.1.0", "playwright": "^1.14.0", "playwright-core": ">=1.2.0", "rimraf": "^3.0.2", "uuid": "^8.3.2" }, "peerDependencies": { "storybook": "^0.0.0-0 || ^10.0.0 || ^10.0.0-0 || ^10.1.0-0 || ^10.2.0-0 || ^10.3.0-0" }, "bin": { "test-storybook": "dist/test-storybook.js" } }, "sha512-hDBoQz6wJj7CumdfccsVGMYpJ9lfozwMXWd7rvyhy46Mwo6eZnOWv6xNbZRNZeNtZsCFUai6o8K1Ts9Qd+nzQg=="], + "@storybook/test-runner": ["@storybook/test-runner@0.24.0", "", { "dependencies": { "@babel/core": "^7.22.5", "@babel/generator": "^7.22.5", "@babel/template": "^7.22.5", "@babel/types": "^7.22.5", "@jest/types": "^30.0.1", "@swc/core": "^1.5.22", "@swc/jest": "^0.2.38", "expect-playwright": "^0.8.0", "jest": "^30.0.4", "jest-circus": "^30.0.4", "jest-environment-node": "^30.0.4", "jest-junit": "^16.0.0", "jest-process-manager": "^0.4.0", "jest-runner": "^30.0.4", "jest-serializer-html": "^7.1.0", "jest-watch-typeahead": "^3.0.1", "nyc": "^15.1.0", "playwright": "^1.14.0", "playwright-core": ">=1.2.0", "rimraf": "^3.0.2", "uuid": "^8.3.2" }, "peerDependencies": { "storybook": "^0.0.0-0 || ^10.0.0 || ^10.0.0-0" }, "bin": { "test-storybook": "dist/test-storybook.js" } }, "sha512-kEpxTUUidqMibTKWVwUBEf1+ka/wCO6kVVwl0xi7lHoxhvjOF4PyXLt6B9G2GJ+BwKJByioRbc+ywgZJuF6Vkg=="], "@svgr/babel-plugin-add-jsx-attribute": ["@svgr/babel-plugin-add-jsx-attribute@8.0.0", "", { "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g=="], @@ -1107,27 +923,27 @@ "@svgr/plugin-jsx": ["@svgr/plugin-jsx@8.1.0", "", { "dependencies": { "@babel/core": "^7.21.3", "@svgr/babel-preset": "8.1.0", "@svgr/hast-util-to-babel-ast": "8.0.0", "svg-parser": "^2.0.4" }, "peerDependencies": { "@svgr/core": "*" } }, "sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA=="], - "@swc/core": ["@swc/core@1.15.3", "", { "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.25" }, "optionalDependencies": { "@swc/core-darwin-arm64": "1.15.3", "@swc/core-darwin-x64": "1.15.3", "@swc/core-linux-arm-gnueabihf": "1.15.3", "@swc/core-linux-arm64-gnu": "1.15.3", "@swc/core-linux-arm64-musl": "1.15.3", "@swc/core-linux-x64-gnu": "1.15.3", "@swc/core-linux-x64-musl": "1.15.3", "@swc/core-win32-arm64-msvc": "1.15.3", "@swc/core-win32-ia32-msvc": "1.15.3", "@swc/core-win32-x64-msvc": "1.15.3" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" }, "optionalPeers": ["@swc/helpers"] }, "sha512-Qd8eBPkUFL4eAONgGjycZXj1jFCBW8Fd+xF0PzdTlBCWQIV1xnUT7B93wUANtW3KGjl3TRcOyxwSx/u/jyKw/Q=="], + "@swc/core": ["@swc/core@1.13.5", "", { "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.24" }, "optionalDependencies": { "@swc/core-darwin-arm64": "1.13.5", "@swc/core-darwin-x64": "1.13.5", "@swc/core-linux-arm-gnueabihf": "1.13.5", "@swc/core-linux-arm64-gnu": "1.13.5", "@swc/core-linux-arm64-musl": "1.13.5", "@swc/core-linux-x64-gnu": "1.13.5", "@swc/core-linux-x64-musl": "1.13.5", "@swc/core-win32-arm64-msvc": "1.13.5", "@swc/core-win32-ia32-msvc": "1.13.5", "@swc/core-win32-x64-msvc": "1.13.5" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" }, "optionalPeers": ["@swc/helpers"] }, "sha512-WezcBo8a0Dg2rnR82zhwoR6aRNxeTGfK5QCD6TQ+kg3xx/zNT02s/0o+81h/3zhvFSB24NtqEr8FTw88O5W/JQ=="], - "@swc/core-darwin-arm64": ["@swc/core-darwin-arm64@1.15.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-AXfeQn0CvcQ4cndlIshETx6jrAM45oeUrK8YeEY6oUZU/qzz0Id0CyvlEywxkWVC81Ajpd8TQQ1fW5yx6zQWkQ=="], + "@swc/core-darwin-arm64": ["@swc/core-darwin-arm64@1.13.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lKNv7SujeXvKn16gvQqUQI5DdyY8v7xcoO3k06/FJbHJS90zEwZdQiMNRiqpYw/orU543tPaWgz7cIYWhbopiQ=="], - "@swc/core-darwin-x64": ["@swc/core-darwin-x64@1.15.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-p68OeCz1ui+MZYG4wmfJGvcsAcFYb6Sl25H9TxWl+GkBgmNimIiRdnypK9nBGlqMZAcxngNPtnG3kEMNnvoJ2A=="], + "@swc/core-darwin-x64": ["@swc/core-darwin-x64@1.13.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-ILd38Fg/w23vHb0yVjlWvQBoE37ZJTdlLHa8LRCFDdX4WKfnVBiblsCU9ar4QTMNdeTBEX9iUF4IrbNWhaF1Ng=="], - "@swc/core-linux-arm-gnueabihf": ["@swc/core-linux-arm-gnueabihf@1.15.3", "", { "os": "linux", "cpu": "arm" }, "sha512-Nuj5iF4JteFgwrai97mUX+xUOl+rQRHqTvnvHMATL/l9xE6/TJfPBpd3hk/PVpClMXG3Uvk1MxUFOEzM1JrMYg=="], + "@swc/core-linux-arm-gnueabihf": ["@swc/core-linux-arm-gnueabihf@1.13.5", "", { "os": "linux", "cpu": "arm" }, "sha512-Q6eS3Pt8GLkXxqz9TAw+AUk9HpVJt8Uzm54MvPsqp2yuGmY0/sNaPPNVqctCX9fu/Nu8eaWUen0si6iEiCsazQ=="], - "@swc/core-linux-arm64-gnu": ["@swc/core-linux-arm64-gnu@1.15.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-2Nc/s8jE6mW2EjXWxO/lyQuLKShcmTrym2LRf5Ayp3ICEMX6HwFqB1EzDhwoMa2DcUgmnZIalesq2lG3krrUNw=="], + "@swc/core-linux-arm64-gnu": ["@swc/core-linux-arm64-gnu@1.13.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-aNDfeN+9af+y+M2MYfxCzCy/VDq7Z5YIbMqRI739o8Ganz6ST+27kjQFd8Y/57JN/hcnUEa9xqdS3XY7WaVtSw=="], - "@swc/core-linux-arm64-musl": ["@swc/core-linux-arm64-musl@1.15.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-j4SJniZ/qaZ5g8op+p1G9K1z22s/EYGg1UXIb3+Cg4nsxEpF5uSIGEE4mHUfA70L0BR9wKT2QF/zv3vkhfpX4g=="], + "@swc/core-linux-arm64-musl": ["@swc/core-linux-arm64-musl@1.13.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-9+ZxFN5GJag4CnYnq6apKTnnezpfJhCumyz0504/JbHLo+Ue+ZtJnf3RhyA9W9TINtLE0bC4hKpWi8ZKoETyOQ=="], - "@swc/core-linux-x64-gnu": ["@swc/core-linux-x64-gnu@1.15.3", "", { "os": "linux", "cpu": "x64" }, "sha512-aKttAZnz8YB1VJwPQZtyU8Uk0BfMP63iDMkvjhJzRZVgySmqt/apWSdnoIcZlUoGheBrcqbMC17GGUmur7OT5A=="], + "@swc/core-linux-x64-gnu": ["@swc/core-linux-x64-gnu@1.13.5", "", { "os": "linux", "cpu": "x64" }, "sha512-WD530qvHrki8Ywt/PloKUjaRKgstQqNGvmZl54g06kA+hqtSE2FTG9gngXr3UJxYu/cNAjJYiBifm7+w4nbHbA=="], - "@swc/core-linux-x64-musl": ["@swc/core-linux-x64-musl@1.15.3", "", { "os": "linux", "cpu": "x64" }, "sha512-oe8FctPu1gnUsdtGJRO2rvOUIkkIIaHqsO9xxN0bTR7dFTlPTGi2Fhk1tnvXeyAvCPxLIcwD8phzKg6wLv9yug=="], + "@swc/core-linux-x64-musl": ["@swc/core-linux-x64-musl@1.13.5", "", { "os": "linux", "cpu": "x64" }, "sha512-Luj8y4OFYx4DHNQTWjdIuKTq2f5k6uSXICqx+FSabnXptaOBAbJHNbHT/06JZh6NRUouaf0mYXN0mcsqvkhd7Q=="], - "@swc/core-win32-arm64-msvc": ["@swc/core-win32-arm64-msvc@1.15.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-L9AjzP2ZQ/Xh58e0lTRMLvEDrcJpR7GwZqAtIeNLcTK7JVE+QineSyHp0kLkO1rttCHyCy0U74kDTj0dRz6raA=="], + "@swc/core-win32-arm64-msvc": ["@swc/core-win32-arm64-msvc@1.13.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-cZ6UpumhF9SDJvv4DA2fo9WIzlNFuKSkZpZmPG1c+4PFSEMy5DFOjBSllCvnqihCabzXzpn6ykCwBmHpy31vQw=="], - "@swc/core-win32-ia32-msvc": ["@swc/core-win32-ia32-msvc@1.15.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-B8UtogMzErUPDWUoKONSVBdsgKYd58rRyv2sHJWKOIMCHfZ22FVXICR4O/VwIYtlnZ7ahERcjayBHDlBZpR0aw=="], + "@swc/core-win32-ia32-msvc": ["@swc/core-win32-ia32-msvc@1.13.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-C5Yi/xIikrFUzZcyGj9L3RpKljFvKiDMtyDzPKzlsDrKIw2EYY+bF88gB6oGY5RGmv4DAX8dbnpRAqgFD0FMEw=="], - "@swc/core-win32-x64-msvc": ["@swc/core-win32-x64-msvc@1.15.3", "", { "os": "win32", "cpu": "x64" }, "sha512-SpZKMR9QBTecHeqpzJdYEfgw30Oo8b/Xl6rjSzBt1g0ZsXyy60KLXrp6IagQyfTYqNYE/caDvwtF2FPn7pomog=="], + "@swc/core-win32-x64-msvc": ["@swc/core-win32-x64-msvc@1.13.5", "", { "os": "win32", "cpu": "x64" }, "sha512-YrKdMVxbYmlfybCSbRtrilc6UA8GF5aPmGKBdPvjrarvsmf4i7ZHGCEnLtfOMd3Lwbs2WUZq3WdMbozYeLU93Q=="], "@swc/counter": ["@swc/counter@0.1.3", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="], @@ -1137,39 +953,39 @@ "@swc/types": ["@swc/types@0.1.25", "", { "dependencies": { "@swc/counter": "^0.1.3" } }, "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g=="], - "@swc/wasm": ["@swc/wasm@1.15.3", "", {}, "sha512-NrjGmAplk+v4wokIaLxp1oLoCMVqdQcWoBXopQg57QqyPRcJXLKe+kg5ehhW6z8XaU4Bu5cRkDxUTDY5P0Zy9Q=="], + "@swc/wasm": ["@swc/wasm@1.13.21", "", {}, "sha512-fnirreOh8nsRgZoHvBRW9bJL9y2cbiEM6qzSxVEU07PWTD+xFxLdBs0829tf3XSqRDPuivAPc2bDvw1K5itnXA=="], "@szmarczak/http-timer": ["@szmarczak/http-timer@4.0.6", "", { "dependencies": { "defer-to-connect": "^2.0.0" } }, "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w=="], - "@tailwindcss/node": ["@tailwindcss/node@4.1.17", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.17" } }, "sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg=="], + "@tailwindcss/node": ["@tailwindcss/node@4.1.16", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.19", "source-map-js": "^1.2.1", "tailwindcss": "4.1.16" } }, "sha512-BX5iaSsloNuvKNHRN3k2RcCuTEgASTo77mofW0vmeHkfrDWaoFAFvNHpEgtu0eqyypcyiBkDWzSMxJhp3AUVcw=="], - "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.17", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.17", "@tailwindcss/oxide-darwin-arm64": "4.1.17", "@tailwindcss/oxide-darwin-x64": "4.1.17", "@tailwindcss/oxide-freebsd-x64": "4.1.17", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.17", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.17", "@tailwindcss/oxide-linux-arm64-musl": "4.1.17", "@tailwindcss/oxide-linux-x64-gnu": "4.1.17", "@tailwindcss/oxide-linux-x64-musl": "4.1.17", "@tailwindcss/oxide-wasm32-wasi": "4.1.17", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.17", "@tailwindcss/oxide-win32-x64-msvc": "4.1.17" } }, "sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA=="], + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.16", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.16", "@tailwindcss/oxide-darwin-arm64": "4.1.16", "@tailwindcss/oxide-darwin-x64": "4.1.16", "@tailwindcss/oxide-freebsd-x64": "4.1.16", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.16", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.16", "@tailwindcss/oxide-linux-arm64-musl": "4.1.16", "@tailwindcss/oxide-linux-x64-gnu": "4.1.16", "@tailwindcss/oxide-linux-x64-musl": "4.1.16", "@tailwindcss/oxide-wasm32-wasi": "4.1.16", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.16", "@tailwindcss/oxide-win32-x64-msvc": "4.1.16" } }, "sha512-2OSv52FRuhdlgyOQqgtQHuCgXnS8nFSYRp2tJ+4WZXKgTxqPy7SMSls8c3mPT5pkZ17SBToGM5LHEJBO7miEdg=="], - "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.17", "", { "os": "android", "cpu": "arm64" }, "sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ=="], + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.16", "", { "os": "android", "cpu": "arm64" }, "sha512-8+ctzkjHgwDJ5caq9IqRSgsP70xhdhJvm+oueS/yhD5ixLhqTw9fSL1OurzMUhBwE5zK26FXLCz2f/RtkISqHA=="], - "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.17", "", { "os": "darwin", "cpu": "arm64" }, "sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg=="], + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-C3oZy5042v2FOALBZtY0JTDnGNdS6w7DxL/odvSny17ORUnaRKhyTse8xYi3yKGyfnTUOdavRCdmc8QqJYwFKA=="], - "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.17", "", { "os": "darwin", "cpu": "x64" }, "sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog=="], + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-vjrl/1Ub9+JwU6BP0emgipGjowzYZMjbWCDqwA2Z4vCa+HBSpP4v6U2ddejcHsolsYxwL5r4bPNoamlV0xDdLg=="], - "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.17", "", { "os": "freebsd", "cpu": "x64" }, "sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g=="], + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.16", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TSMpPYpQLm+aR1wW5rKuUuEruc/oOX3C7H0BTnPDn7W/eMw8W+MRMpiypKMkXZfwH8wqPIRKppuZoedTtNj2tg=="], - "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.17", "", { "os": "linux", "cpu": "arm" }, "sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ=="], + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.16", "", { "os": "linux", "cpu": "arm" }, "sha512-p0GGfRg/w0sdsFKBjMYvvKIiKy/LNWLWgV/plR4lUgrsxFAoQBFrXkZ4C0w8IOXfslB9vHK/JGASWD2IefIpvw=="], - "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ=="], + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-DoixyMmTNO19rwRPdqviTrG1rYzpxgyYJl8RgQvdAQUzxC1ToLRqtNJpU/ATURSKgIg6uerPw2feW0aS8SNr/w=="], - "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg=="], + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-H81UXMa9hJhWhaAUca6bU2wm5RRFpuHImrwXBUvPbYb+3jo32I9VIwpOX6hms0fPmA6f2pGVlybO6qU8pF4fzQ=="], - "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.17", "", { "os": "linux", "cpu": "x64" }, "sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ=="], + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.16", "", { "os": "linux", "cpu": "x64" }, "sha512-ZGHQxDtFC2/ruo7t99Qo2TTIvOERULPl5l0K1g0oK6b5PGqjYMga+FcY1wIUnrUxY56h28FxybtDEla+ICOyew=="], - "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.17", "", { "os": "linux", "cpu": "x64" }, "sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ=="], + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.16", "", { "os": "linux", "cpu": "x64" }, "sha512-Oi1tAaa0rcKf1Og9MzKeINZzMLPbhxvm7rno5/zuP1WYmpiG0bEHq4AcRUiG2165/WUzvxkW4XDYCscZWbTLZw=="], - "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.17", "", { "dependencies": { "@emnapi/core": "^1.6.0", "@emnapi/runtime": "^1.6.0", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.0.7", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg=="], + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.16", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.0.7", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-B01u/b8LteGRwucIBmCQ07FVXLzImWESAIMcUU6nvFt/tYsQ6IHz8DmZ5KtvmwxD+iTYBtM1xwoGXswnlu9v0Q=="], - "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.17", "", { "os": "win32", "cpu": "arm64" }, "sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A=="], + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-zX+Q8sSkGj6HKRTMJXuPvOcP8XfYON24zJBRPlszcH1Np7xuHXhWn8qfFjIujVzvH3BHU+16jBXwgpl20i+v9A=="], - "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.17", "", { "os": "win32", "cpu": "x64" }, "sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw=="], + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.16", "", { "os": "win32", "cpu": "x64" }, "sha512-m5dDFJUEejbFqP+UXVstd4W/wnxA4F61q8SoL+mqTypId2T2ZpuxosNSgowiCnLp2+Z+rivdU0AqpfgiD7yCBg=="], - "@tailwindcss/vite": ["@tailwindcss/vite@4.1.17", "", { "dependencies": { "@tailwindcss/node": "4.1.17", "@tailwindcss/oxide": "4.1.17", "tailwindcss": "4.1.17" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-4+9w8ZHOiGnpcGI6z1TVVfWaX/koK7fKeSYF3qlYg2xpBtbteP2ddBxiarL+HVgfSJGeK5RIxRQmKm4rTJJAwA=="], + "@tailwindcss/vite": ["@tailwindcss/vite@4.1.16", "", { "dependencies": { "@tailwindcss/node": "4.1.16", "@tailwindcss/oxide": "4.1.16", "tailwindcss": "4.1.16" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-bbguNBcDxsRmi9nnlWJxhfDWamY3lmcyACHcdO1crxfzuLpOhHLLtEIN/nCbbAtj5rchUgQD17QVAKi1f7IsKg=="], "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], @@ -1195,7 +1011,7 @@ "@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="], - "@types/bun": ["@types/bun@1.3.3", "", { "dependencies": { "bun-types": "1.3.3" } }, "sha512-ogrKbJ2X5N0kWLLFKeytG0eHDleBYtngtlbu9cyBKFtNL3cnpDZkNdQj8flVf6WTZUX5ulI9AY1oa7ljhSrp+g=="], + "@types/bun": ["@types/bun@1.3.1", "", { "dependencies": { "bun-types": "1.3.1" } }, "sha512-4jNMk2/K9YJtfqwoAa28c8wK+T7nvJFOjxI4h/7sORWcypRNxBpr+TPNaCfVWq70tLCJsqoFwcf0oI0JU/fvMQ=="], "@types/cacheable-request": ["@types/cacheable-request@6.0.3", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="], @@ -1329,7 +1145,7 @@ "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], - "@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="], + "@types/node": ["@types/node@22.18.13", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Bo45YKIjnmFtv6I1TuC8AaHBbqXtIo+Om5fE4QiU1Tj8QR/qt+8O3BAtOimG5IFmwaWiPmB3Mv3jtYzBA4Us2A=="], "@types/plist": ["@types/plist@3.0.5", "", { "dependencies": { "@types/node": "*", "xmlbuilder": ">=11.0.1" } }, "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA=="], @@ -1339,7 +1155,7 @@ "@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="], - "@types/react": ["@types/react@18.3.27", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w=="], + "@types/react": ["@types/react@18.3.26", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" } }, "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA=="], "@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="], @@ -1371,47 +1187,47 @@ "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], - "@types/yargs": ["@types/yargs@17.0.35", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg=="], + "@types/yargs": ["@types/yargs@17.0.34", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-KExbHVa92aJpw9WDQvzBaGVE2/Pz+pLZQloT2hjL8IqsZnV62rlPOYvNnLmf/L2dyllfVUOVBj64M0z/46eR2A=="], "@types/yargs-parser": ["@types/yargs-parser@21.0.3", "", {}, "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="], "@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="], - "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.48.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.48.0", "@typescript-eslint/type-utils": "8.48.0", "@typescript-eslint/utils": "8.48.0", "@typescript-eslint/visitor-keys": "8.48.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.48.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-XxXP5tL1txl13YFtrECECQYeZjBZad4fyd3cFV4a19LkAY/bIp9fev3US4S5fDVV2JaYFiKAZ/GRTOLer+mbyQ=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.46.2", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/type-utils": "8.46.2", "@typescript-eslint/utils": "8.46.2", "@typescript-eslint/visitor-keys": "8.46.2", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.46.2", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-ZGBMToy857/NIPaaCucIUQgqueOiq7HeAKkhlvqVV4lm089zUFW6ikRySx2v+cAhKeUCPuWVHeimyk6Dw1iY3w=="], - "@typescript-eslint/parser": ["@typescript-eslint/parser@8.48.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.48.0", "@typescript-eslint/types": "8.48.0", "@typescript-eslint/typescript-estree": "8.48.0", "@typescript-eslint/visitor-keys": "8.48.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ=="], + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.46.2", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/types": "8.46.2", "@typescript-eslint/typescript-estree": "8.46.2", "@typescript-eslint/visitor-keys": "8.46.2", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g=="], - "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.48.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.48.0", "@typescript-eslint/types": "^8.48.0", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Ne4CTZyRh1BecBf84siv42wv5vQvVmgtk8AuiEffKTUo3DrBaGYZueJSxxBZ8fjk/N3DrgChH4TOdIOwOwiqqw=="], + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.46.2", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.46.2", "@typescript-eslint/types": "^8.46.2", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-PULOLZ9iqwI7hXcmL4fVfIsBi6AN9YxRc0frbvmg8f+4hQAjQ5GYNKK0DIArNo+rOKmR/iBYwkpBmnIwin4wBg=="], - "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.48.0", "", { "dependencies": { "@typescript-eslint/types": "8.48.0", "@typescript-eslint/visitor-keys": "8.48.0" } }, "sha512-uGSSsbrtJrLduti0Q1Q9+BF1/iFKaxGoQwjWOIVNJv0o6omrdyR8ct37m4xIl5Zzpkp69Kkmvom7QFTtue89YQ=="], + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.46.2", "", { "dependencies": { "@typescript-eslint/types": "8.46.2", "@typescript-eslint/visitor-keys": "8.46.2" } }, "sha512-LF4b/NmGvdWEHD2H4MsHD8ny6JpiVNDzrSZr3CsckEgCbAGZbYM4Cqxvi9L+WqDMT+51Ozy7lt2M+d0JLEuBqA=="], - "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.48.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-WNebjBdFdyu10sR1M4OXTt2OkMd5KWIL+LLfeH9KhgP+jzfDV/LI3eXzwJ1s9+Yc0Kzo2fQCdY/OpdusCMmh6w=="], + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.46.2", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-a7QH6fw4S57+F5y2FIxxSDyi5M4UfGF+Jl1bCGd7+L4KsaUY80GsiF/t0UoRFDHAguKlBaACWJRmdrc6Xfkkag=="], - "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.48.0", "", { "dependencies": { "@typescript-eslint/types": "8.48.0", "@typescript-eslint/typescript-estree": "8.48.0", "@typescript-eslint/utils": "8.48.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-zbeVaVqeXhhab6QNEKfK96Xyc7UQuoFWERhEnj3mLVnUWrQnv15cJNseUni7f3g557gm0e46LZ6IJ4NJVOgOpw=="], + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.46.2", "", { "dependencies": { "@typescript-eslint/types": "8.46.2", "@typescript-eslint/typescript-estree": "8.46.2", "@typescript-eslint/utils": "8.46.2", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-HbPM4LbaAAt/DjxXaG9yiS9brOOz6fabal4uvUmaUYe6l3K1phQDMQKBRUrr06BQkxkvIZVVHttqiybM9nJsLA=="], - "@typescript-eslint/types": ["@typescript-eslint/types@8.48.0", "", {}, "sha512-cQMcGQQH7kwKoVswD1xdOytxQR60MWKM1di26xSUtxehaDs/32Zpqsu5WJlXTtTTqyAVK8R7hvsUnIXRS+bjvA=="], + "@typescript-eslint/types": ["@typescript-eslint/types@8.46.2", "", {}, "sha512-lNCWCbq7rpg7qDsQrd3D6NyWYu+gkTENkG5IKYhUIcxSb59SQC/hEQ+MrG4sTgBVghTonNWq42bA/d4yYumldQ=="], - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.48.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.48.0", "@typescript-eslint/tsconfig-utils": "8.48.0", "@typescript-eslint/types": "8.48.0", "@typescript-eslint/visitor-keys": "8.48.0", "debug": "^4.3.4", "minimatch": "^9.0.4", "semver": "^7.6.0", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-ljHab1CSO4rGrQIAyizUS6UGHHCiAYhbfcIZ1zVJr5nMryxlXMVWS3duFPSKvSUbFPwkXMFk1k0EMIjub4sRRQ=="], + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.46.2", "", { "dependencies": { "@typescript-eslint/project-service": "8.46.2", "@typescript-eslint/tsconfig-utils": "8.46.2", "@typescript-eslint/types": "8.46.2", "@typescript-eslint/visitor-keys": "8.46.2", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-f7rW7LJ2b7Uh2EiQ+7sza6RDZnajbNbemn54Ob6fRwQbgcIn+GWfyuHDHRYgRoZu1P4AayVScrRW+YfbTvPQoQ=="], - "@typescript-eslint/utils": ["@typescript-eslint/utils@8.48.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.48.0", "@typescript-eslint/types": "8.48.0", "@typescript-eslint/typescript-estree": "8.48.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-yTJO1XuGxCsSfIVt1+1UrLHtue8xz16V8apzPYI06W0HbEbEWHxHXgZaAgavIkoh+GeV6hKKd5jm0sS6OYxWXQ=="], + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.46.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/types": "8.46.2", "@typescript-eslint/typescript-estree": "8.46.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-sExxzucx0Tud5tE0XqR0lT0psBQvEpnpiul9XbGUB1QwpWJJAps1O/Z7hJxLGiZLBKMCutjTzDgmd1muEhBnVg=="], - "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.48.0", "", { "dependencies": { "@typescript-eslint/types": "8.48.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-T0XJMaRPOH3+LBbAfzR2jalckP1MSG/L9eUtY0DEzUyVaXJ/t6zN0nR7co5kz0Jko/nkSYCBRkz1djvjajVTTg=="], + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.46.2", "", { "dependencies": { "@typescript-eslint/types": "8.46.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-tUFMXI4gxzzMXt4xpGJEsBsTox0XbNQ1y94EwlD/CuZwFcQP79xfQqMhau9HsRc/J0cAPA/HZt1dZPtGn9V/7w=="], - "@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20251125.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20251125.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20251125.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20251125.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20251125.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20251125.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20251125.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20251125.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-E1EboijTfMS99duAYDzPiIHzJDXA1xEj4UHvpjarlniYYmCFO/Rla4boiRBMns4eXNNkyEkvU4WSkjpOl0fzTg=="], + "@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20251029.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20251029.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20251029.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20251029.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20251029.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20251029.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20251029.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20251029.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-IRmYCDgwZQEfjy2GNJnQbqoRUrvdCbzLE0sLhwc6TP4I0Hx5TnHv3sJGKAgdmcbHmKHtwJeppXjgTRGtFTWRHQ=="], - "@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20251125.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8fkL3vtHtrKoj8LGrsEfvZDNLd47ScCVOVyC+vn4t3SNGo6eLvHqaBUd5WlBEVLHAO6o71BDS4hHDNGiMc0hEA=="], + "@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20251029.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DBJ3jFP6/MaQj/43LN1TC7tjR4SXZUNDnREiVjtFzpOG4Q71D1LB6QryskkRZsNtxLaTuVV57l2ubCE8tNmz0w=="], - "@typescript/native-preview-darwin-x64": ["@typescript/native-preview-darwin-x64@7.0.0-dev.20251125.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-Odq4ZtNOzlpTbjRpdP5AaCfVRVx0L05F7cI3UpPQgXjxJejKin14z6r+k2qlo77pwnpaviM2fou+hbNX5cj1oQ=="], + "@typescript/native-preview-darwin-x64": ["@typescript/native-preview-darwin-x64@7.0.0-dev.20251029.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-fnxZZtlXeud6f3bev3q50QMR+FrnuTyVr5akp5G2/o4jfkqLV6cKzseGnY6so+ftwfwP/PX3GOkfL6Ag8NzR0Q=="], - "@typescript/native-preview-linux-arm": ["@typescript/native-preview-linux-arm@7.0.0-dev.20251125.1", "", { "os": "linux", "cpu": "arm" }, "sha512-abP56lp5GIDizVjQ3/36mryOawUTY+ODtw/rUJ+XMnH/zy6OSNS4g8z8XsmTnizsLLaWrrAYD3+PCdi0c6ra8w=="], + "@typescript/native-preview-linux-arm": ["@typescript/native-preview-linux-arm@7.0.0-dev.20251029.1", "", { "os": "linux", "cpu": "arm" }, "sha512-1ok8pxcIlwMTMggySPIVt926lymLWNhCgPTzO751zKFTDTJcmpzmpmSWbiFQQ3fcPzO8LocsLXRfBwYDd/uqQA=="], - "@typescript/native-preview-linux-arm64": ["@typescript/native-preview-linux-arm64@7.0.0-dev.20251125.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-YiM49tIFLfq0LHfPVhSufBABsyS79OqurRZwznkFUiv4HHFWuZ66Ne1w2eXzv3BeZkDOnPtrkmZ+ZSAeYtoEhw=="], + "@typescript/native-preview-linux-arm64": ["@typescript/native-preview-linux-arm64@7.0.0-dev.20251029.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WK/N4Tk9nxI+k6AwJ7d80Gnd4+8kbBwmryIgOGPQNNvNJticYg6QiQsFGgC+HnCqvWDQ0fAyW+wdcPG6fwn/EA=="], - "@typescript/native-preview-linux-x64": ["@typescript/native-preview-linux-x64@7.0.0-dev.20251125.1", "", { "os": "linux", "cpu": "x64" }, "sha512-nl0itKQowgb4snWPH4LjkdSzMIalG+qDoheAqadMEDUekKexNTmUAqbK0+qje0jsW9Jc/1+MCQHIcDr20abkzA=="], + "@typescript/native-preview-linux-x64": ["@typescript/native-preview-linux-x64@7.0.0-dev.20251029.1", "", { "os": "linux", "cpu": "x64" }, "sha512-GvTl9BeItX0Ox0wXiMIHkktl9sCTkTPBe6f6hEs4XfJlAKm+JHbYtB9UEs62QyPYBFMx2phCytVNejpaUZRJmQ=="], - "@typescript/native-preview-win32-arm64": ["@typescript/native-preview-win32-arm64@7.0.0-dev.20251125.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-99AZ4Lv0Ez/RqtCszFDWCE+8Qrzjjw1Bsq2DYRnszeTIbwvr3I6x3edk2gr8/EuulrQLv7fzcintyp3EQgeZlQ=="], + "@typescript/native-preview-win32-arm64": ["@typescript/native-preview-win32-arm64@7.0.0-dev.20251029.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-BUEC+M6gViaa/zDzOjAOEqpOZeUJxuwrjwOokqxXyUavX+mC6zb6ALqx4r7GAWrfY9sSvGUacW4ZbqDTXe8KAg=="], - "@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20251125.1", "", { "os": "win32", "cpu": "x64" }, "sha512-f483lMqW97udDCG0Deotbcmr+khmvcr9U0i5DB6z1ePjIVk8HkvdoFDnKuzSdtov0KvqPGkyRui0Vdqy/IwYJQ=="], + "@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20251029.1", "", { "os": "win32", "cpu": "x64" }, "sha512-ODcXFgM62KpXxHqG5NMG+ipBqTbQ1pGkrzSByBwgRx0c/gTUhgML8UT7iK3nTrTtp9OBgPYPLLDNwiSLyzaIxA=="], "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], @@ -1491,7 +1307,7 @@ "ajv-keywords": ["ajv-keywords@3.5.2", "", { "peerDependencies": { "ajv": "^6.9.1" } }, "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ=="], - "ansi-escapes": ["ansi-escapes@7.2.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw=="], + "ansi-escapes": ["ansi-escapes@7.1.1", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-Zhl0ErHcSRUaVfGUeUdDuLgpkEo8KIFjB4Y9uAc46ScOpdDiU1Dbyplh7qWJeJ/ZHpbyMSM26+X3BySgnIz40Q=="], "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -1555,13 +1371,13 @@ "at-least-node": ["at-least-node@1.0.0", "", {}, "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg=="], - "autoprefixer": ["autoprefixer@10.4.22", "", { "dependencies": { "browserslist": "^4.27.0", "caniuse-lite": "^1.0.30001754", "fraction.js": "^5.3.4", "normalize-range": "^0.1.2", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-ARe0v/t9gO28Bznv6GgqARmVqcWOV3mfgUPn9becPHMiD3o9BwlRgaeccZnwTpZ7Zwqrm+c1sUSsMxIzQzc8Xg=="], + "autoprefixer": ["autoprefixer@10.4.21", "", { "dependencies": { "browserslist": "^4.24.4", "caniuse-lite": "^1.0.30001702", "fraction.js": "^4.3.7", "normalize-range": "^0.1.2", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ=="], "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], "aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="], - "axios": ["axios@1.13.2", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA=="], + "axios": ["axios@1.13.1", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-hU4EGxxt+j7TQijx1oYdAjw4xuIp1wRQSsbMFwSthCWeBQur1eF+qJ5iQ5sN3Tw8YRzQNKb8jszgBdMDVqwJcw=="], "babel-jest": ["babel-jest@30.2.0", "", { "dependencies": { "@jest/transform": "30.2.0", "@types/babel__core": "^7.20.5", "babel-plugin-istanbul": "^7.0.1", "babel-preset-jest": "30.2.0", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "slash": "^3.0.0" }, "peerDependencies": { "@babel/core": "^7.11.0 || ^8.0.0-0" } }, "sha512-0YiBEOxWqKkSQWL9nNGGEgndoeL0ZpWrbLMNL5u/Kaxrli3Eaxlt3ZtIDktEvXt4L/R9r3ODr2zKwGM/2BjxVw=="], @@ -1569,12 +1385,6 @@ "babel-plugin-jest-hoist": ["babel-plugin-jest-hoist@30.2.0", "", { "dependencies": { "@types/babel__core": "^7.20.5" } }, "sha512-ftzhzSGMUnOzcCXd6WHdBGMyuwy15Wnn0iyyWGKgBDLxf9/s5ABuraCSpBX2uG0jUg4rqJnxsLc5+oYBqoxVaA=="], - "babel-plugin-polyfill-corejs2": ["babel-plugin-polyfill-corejs2@0.4.14", "", { "dependencies": { "@babel/compat-data": "^7.27.7", "@babel/helper-define-polyfill-provider": "^0.6.5", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-Co2Y9wX854ts6U8gAAPXfn0GmAyctHuK8n0Yhfjd6t30g7yvKjspvvOo9yG+z52PZRgFErt7Ka2pYnXCjLKEpg=="], - - "babel-plugin-polyfill-corejs3": ["babel-plugin-polyfill-corejs3@0.13.0", "", { "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.5", "core-js-compat": "^3.43.0" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A=="], - - "babel-plugin-polyfill-regenerator": ["babel-plugin-polyfill-regenerator@0.6.5", "", { "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.5" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-ISqQ2frbiNU9vIJkzg7dlPpznPZ4jOiUQ1uSmB0fEHeowtN3COYRsXr/xexn64NpU13P06jc/L5TgiJXOgrbEg=="], - "babel-plugin-react-compiler": ["babel-plugin-react-compiler@1.0.0", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw=="], "babel-preset-current-node-syntax": ["babel-preset-current-node-syntax@1.2.0", "", { "dependencies": { "@babel/plugin-syntax-async-generators": "^7.8.4", "@babel/plugin-syntax-bigint": "^7.8.3", "@babel/plugin-syntax-class-properties": "^7.12.13", "@babel/plugin-syntax-class-static-block": "^7.14.5", "@babel/plugin-syntax-import-attributes": "^7.24.7", "@babel/plugin-syntax-import-meta": "^7.10.4", "@babel/plugin-syntax-json-strings": "^7.8.3", "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", "@babel/plugin-syntax-numeric-separator": "^7.10.4", "@babel/plugin-syntax-object-rest-spread": "^7.8.3", "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", "@babel/plugin-syntax-optional-chaining": "^7.8.3", "@babel/plugin-syntax-private-property-in-object": "^7.14.5", "@babel/plugin-syntax-top-level-await": "^7.14.5" }, "peerDependencies": { "@babel/core": "^7.0.0 || ^8.0.0-0" } }, "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg=="], @@ -1587,7 +1397,7 @@ "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.8.31", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-a28v2eWrrRWPpJSzxc+mKwm0ZtVx/G8SepdQZDArnXYU/XS+IF6mp8aB/4E+hH1tyGCoDo3KlUCdlSxGDsRkAw=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.8.20", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-JMWsdF+O8Orq3EMukbUN1QfbLK9mX2CkUmQBcW2T0s8OmdAUL5LLM/6wFwSrqXzlXB13yhyK9gTKS1rIizOduQ=="], "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], @@ -1599,7 +1409,7 @@ "bluebird-lst": ["bluebird-lst@1.0.9", "", { "dependencies": { "bluebird": "^3.5.5" } }, "sha512-7B1Rtx82hjnSD4PGLAjVWeYH3tHAcVUmChh85a3lltKQm6FresXh9ErQo6oAv6CqxttczC3/kEg8SY5NluPuUw=="], - "body-parser": ["body-parser@2.2.1", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw=="], + "body-parser": ["body-parser@2.2.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.0", "http-errors": "^2.0.0", "iconv-lite": "^0.6.3", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.0", "type-is": "^2.0.0" } }, "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg=="], "boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="], @@ -1609,7 +1419,7 @@ "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], - "browserslist": ["browserslist@4.28.0", "", { "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", "electron-to-chromium": "^1.5.249", "node-releases": "^2.0.27", "update-browserslist-db": "^1.1.4" }, "bin": { "browserslist": "cli.js" } }, "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ=="], + "browserslist": ["browserslist@4.27.0", "", { "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", "electron-to-chromium": "^1.5.238", "node-releases": "^2.0.26", "update-browserslist-db": "^1.1.4" }, "bin": { "browserslist": "cli.js" } }, "sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw=="], "bs-logger": ["bs-logger@0.2.6", "", { "dependencies": { "fast-json-stable-stringify": "2.x" } }, "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog=="], @@ -1627,7 +1437,7 @@ "builder-util-runtime": ["builder-util-runtime@9.2.4", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-upp+biKpN/XZMLim7aguUyW8s0FUpDvOtK6sbanMFDAMBzpHDqdhgVYm6zc9HJ6nWo7u2Lxk60i2M6Jd3aiNrA=="], - "bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="], + "bun-types": ["bun-types@1.3.1", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-NMrcy7smratanWJ2mMXdpatalovtxVggkj11bScuWuiOoXTiKIu2eVS1/7qbyI/4yHedtsn175n4Sm4JcdHLXw=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], @@ -1649,7 +1459,7 @@ "camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="], - "caniuse-lite": ["caniuse-lite@1.0.30001757", "", {}, "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ=="], + "caniuse-lite": ["caniuse-lite@1.0.30001751", "", {}, "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw=="], "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], @@ -1681,7 +1491,7 @@ "ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="], - "cjs-module-lexer": ["cjs-module-lexer@2.1.1", "", {}, "sha512-+CmxIZ/L2vNcEfvNtLdU0ZQ6mbq3FZnwAP2PPTiKP+1QOoKwlKlPgb8UKV0Dds7QVaMnHm+FwSft2VB0s/SLjQ=="], + "cjs-module-lexer": ["cjs-module-lexer@2.1.0", "", {}, "sha512-UX0OwmYRYQQetfrLEZeewIFFI+wSTofC+pMBLNuH3RUuu/xzG1oz84UCEDOSoQlN3fZ4+AzmV50ZYvGqkMh9yA=="], "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], @@ -1719,7 +1529,7 @@ "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], - "commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="], + "commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], "commondir": ["commondir@1.0.1", "", {}, "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg=="], @@ -1737,19 +1547,17 @@ "console-control-strings": ["console-control-strings@1.1.0", "", {}, "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ=="], - "content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="], + "content-disposition": ["content-disposition@1.0.0", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg=="], "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], - "cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="], + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], - "core-js": ["core-js@3.47.0", "", {}, "sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg=="], - - "core-js-compat": ["core-js-compat@3.47.0", "", { "dependencies": { "browserslist": "^4.28.0" } }, "sha512-IGfuznZ/n7Kp9+nypamBhvwdwLsW6KC8IOaURw2doAK5e98AG3acVLdh0woOnEqCfUtS+Vu882JE4k/DAm3ItQ=="], + "core-js": ["core-js@3.46.0", "", {}, "sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA=="], "core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="], @@ -1773,7 +1581,7 @@ "cssstyle": ["cssstyle@5.3.3", "", { "dependencies": { "@asamuzakjp/css-color": "^4.0.3", "@csstools/css-syntax-patches-for-csstree": "^1.0.14", "css-tree": "^3.1.0" } }, "sha512-OytmFH+13/QXONJcC75QNdMtKpceNk3u8ThBjyyYjkEcy/ekBwR1mMAuNvi3gdBPW3N5TlCzQ0WZw8H0lN/bDw=="], - "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], "cwd": ["cwd@0.10.0", "", { "dependencies": { "find-pkg": "^0.1.2", "fs-exists-sync": "^0.1.0" } }, "sha512-YGZxdTTL9lmLkCUTpg4j0zQ7IhRB5ZmqNBbGCl3Tg6MP/d5/6sY7L5mmTjzbc6JKgVZYiqTQTNhPFsbXNGlRaA=="], @@ -1859,7 +1667,7 @@ "date-fns": ["date-fns@2.30.0", "", { "dependencies": { "@babel/runtime": "^7.21.0" } }, "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw=="], - "dayjs": ["dayjs@1.11.19", "", {}, "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw=="], + "dayjs": ["dayjs@1.11.18", "", {}, "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], @@ -1953,7 +1761,7 @@ "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], - "electron": ["electron@38.7.1", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^22.7.7", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-mdFVpL80nZvIvajtl1Xz+2Q/a9tFGVnPO0YW/N+MqQUyZG8D9r3wrWoaEVBXTc1jI+Vkg77Eqqwh5FLiaYRI+A=="], + "electron": ["electron@38.4.0", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^22.7.7", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-9CsXKbGf2qpofVe2pQYSgom2E//zLDJO2rGLLbxgy9tkdTOs7000Gte+d/PUtzLjI/DS95jDK0ojYAeqjLvpYg=="], "electron-builder": ["electron-builder@24.13.3", "", { "dependencies": { "app-builder-lib": "24.13.3", "builder-util": "24.13.1", "builder-util-runtime": "9.2.4", "chalk": "^4.1.2", "dmg-builder": "24.13.3", "fs-extra": "^10.1.0", "is-ci": "^3.0.0", "lazy-val": "^1.0.5", "read-config-file": "6.3.2", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "cli.js", "install-app-deps": "install-app-deps.js" } }, "sha512-yZSgVHft5dNVlo31qmJAe4BVKQfFdwpRw7sFp1iQglDRCDD6r22zfRJuZlhtB5gp9FHUxCMEoWGq10SkCnMAIg=="], @@ -1967,7 +1775,7 @@ "electron-rebuild": ["electron-rebuild@3.2.9", "", { "dependencies": { "@malept/cross-spawn-promise": "^2.0.0", "chalk": "^4.0.0", "debug": "^4.1.1", "detect-libc": "^2.0.1", "fs-extra": "^10.0.0", "got": "^11.7.0", "lzma-native": "^8.0.5", "node-abi": "^3.0.0", "node-api-version": "^0.1.4", "node-gyp": "^9.0.0", "ora": "^5.1.0", "semver": "^7.3.5", "tar": "^6.0.5", "yargs": "^17.0.1" }, "bin": { "electron-rebuild": "lib/src/cli.js" } }, "sha512-FkEZNFViUem3P0RLYbZkUjC8LUFIK+wKq09GHoOITSJjfDAVQv964hwaNseTTWt58sITQX3/5fHNYcTefqaCWw=="], - "electron-to-chromium": ["electron-to-chromium@1.5.260", "", {}, "sha512-ov8rBoOBhVawpzdre+Cmz4FB+y66Eqrk6Gwqd8NGxuhv99GQ8XqMAr351KEkOt7gukXWDg6gJWEMKgL2RLMPtA=="], + "electron-to-chromium": ["electron-to-chromium@1.5.243", "", {}, "sha512-ZCphxFW3Q1TVhcgS9blfut1PX8lusVi2SvXQgmEEnK4TCmE1JhH2JkjJN+DNt0pJJwfBri5AROBnz2b/C+YU9g=="], "electron-updater": ["electron-updater@6.6.2", "", { "dependencies": { "builder-util-runtime": "9.3.1", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", "lodash.escaperegexp": "^4.1.2", "lodash.isequal": "^4.5.0", "semver": "^7.6.3", "tiny-typed-emitter": "^2.1.0" } }, "sha512-Cr4GDOkbAUqRHP5/oeOmH/L2Bn6+FQPxVLZtPbcmKZC63a1F3uu5EefYOssgZXG3u/zBlubbJ5PJdITdMVggbw=="], @@ -2013,7 +1821,7 @@ "es6-error": ["es6-error@4.1.1", "", {}, "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg=="], - "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + "esbuild": ["esbuild@0.25.11", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.11", "@esbuild/android-arm": "0.25.11", "@esbuild/android-arm64": "0.25.11", "@esbuild/android-x64": "0.25.11", "@esbuild/darwin-arm64": "0.25.11", "@esbuild/darwin-x64": "0.25.11", "@esbuild/freebsd-arm64": "0.25.11", "@esbuild/freebsd-x64": "0.25.11", "@esbuild/linux-arm": "0.25.11", "@esbuild/linux-arm64": "0.25.11", "@esbuild/linux-ia32": "0.25.11", "@esbuild/linux-loong64": "0.25.11", "@esbuild/linux-mips64el": "0.25.11", "@esbuild/linux-ppc64": "0.25.11", "@esbuild/linux-riscv64": "0.25.11", "@esbuild/linux-s390x": "0.25.11", "@esbuild/linux-x64": "0.25.11", "@esbuild/netbsd-arm64": "0.25.11", "@esbuild/netbsd-x64": "0.25.11", "@esbuild/openbsd-arm64": "0.25.11", "@esbuild/openbsd-x64": "0.25.11", "@esbuild/openharmony-arm64": "0.25.11", "@esbuild/sunos-x64": "0.25.11", "@esbuild/win32-arm64": "0.25.11", "@esbuild/win32-ia32": "0.25.11", "@esbuild/win32-x64": "0.25.11" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], @@ -2021,7 +1829,7 @@ "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], - "eslint": ["eslint@9.39.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.1", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.39.1", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g=="], + "eslint": ["eslint@9.38.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.1", "@eslint/config-helpers": "^0.4.1", "@eslint/core": "^0.16.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.38.0", "@eslint/plugin-kit": "^0.4.0", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw=="], "eslint-plugin-react": ["eslint-plugin-react@7.37.5", "", { "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", "array.prototype.flatmap": "^1.3.3", "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", "es-iterator-helpers": "^1.2.1", "estraverse": "^5.3.0", "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", "object.entries": "^1.1.9", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.5", "semver": "^6.3.1", "string.prototype.matchall": "^4.0.12", "string.prototype.repeat": "^1.0.0" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA=="], @@ -2071,7 +1879,7 @@ "express": ["express@5.1.0", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA=="], - "exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="], + "exsolve": ["exsolve@1.0.7", "", {}, "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw=="], "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], @@ -2127,11 +1935,11 @@ "foreground-child": ["foreground-child@2.0.0", "", { "dependencies": { "cross-spawn": "^7.0.0", "signal-exit": "^3.0.2" } }, "sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA=="], - "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], + "form-data": ["form-data@4.0.4", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow=="], "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], - "fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="], + "fraction.js": ["fraction.js@4.3.7", "", {}, "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew=="], "framer-motion": ["framer-motion@12.23.24", "", { "dependencies": { "motion-dom": "^12.23.23", "motion-utils": "^12.23.6", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w=="], @@ -2167,8 +1975,6 @@ "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], - "get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="], - "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], @@ -2185,7 +1991,7 @@ "ghostty-web": ["ghostty-web@0.2.1", "", {}, "sha512-wrovbPlHcl+nIkp7S7fY7vOTsmBjwMFihZEe2PJe/M6G4/EwuyJnwaWTTzNfuY7RcM/lVlN+PvGWqJIhKSB5hw=="], - "glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], + "glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="], "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], @@ -2233,8 +2039,6 @@ "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], - "hast": ["hast@1.0.0", "", {}, "sha512-vFUqlRV5C+xqP76Wwq2SrM0kipnmpxJm7OfvVXpB35Fp+Fn4MV+ozr+JZr5qFvyR1q/U+Foim2x+3P+x9S1PLA=="], - "hast-util-from-dom": ["hast-util-from-dom@5.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hastscript": "^9.0.0", "web-namespaces": "^2.0.0" } }, "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q=="], "hast-util-from-html": ["hast-util-from-html@2.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.1.0", "hast-util-from-parse5": "^8.0.0", "parse5": "^7.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw=="], @@ -2279,7 +2083,7 @@ "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="], - "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + "http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="], "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], @@ -2319,11 +2123,11 @@ "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], - "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], + "inline-style-parser": ["inline-style-parser@0.2.4", "", {}, "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q=="], "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], - "internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], + "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], @@ -2417,7 +2221,7 @@ "isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], - "isbinaryfile": ["isbinaryfile@5.0.7", "", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="], + "isbinaryfile": ["isbinaryfile@5.0.6", "", {}, "sha512-I+NmIfBHUl+r2wcDd6JwE9yWje/PIVY/R5/CmV8dXLZd5K+L9X2klAOwfAHNnondLXkbHyTAleQAWonpTJBTtw=="], "isexe": ["isexe@3.1.1", "", {}, "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ=="], @@ -2505,7 +2309,7 @@ "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], "jsdom": ["jsdom@27.2.0", "", { "dependencies": { "@acemir/cssom": "^0.9.23", "@asamuzakjp/dom-selector": "^6.7.4", "cssstyle": "^5.3.3", "data-urls": "^6.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^4.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.0", "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^15.1.0", "ws": "^8.18.3", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-454TI39PeRDW1LgpyLPyURtB4Zx1tklSr6+OFOipsxGUH1WMTvk6C65JQdrj455+DP2uJ1+veBEHTGFKWVLFoA=="], @@ -2593,8 +2397,6 @@ "lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="], - "lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="], - "lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="], "lodash.difference": ["lodash.difference@4.5.0", "", {}, "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA=="], @@ -2651,7 +2453,7 @@ "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], - "marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="], + "marked": ["marked@16.4.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-ntROs7RaN3EvWfy3EZi14H4YxmT6A5YvywfhO+0pm+cH/dnSQRmdAmoFIc3B9aiwTehyk7pESH4ofyBY+V5hZg=="], "matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="], @@ -2683,7 +2485,7 @@ "mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="], - "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="], + "mdast-util-to-hast": ["mdast-util-to-hast@13.2.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA=="], "mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="], @@ -2707,12 +2509,6 @@ "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], - "micromark-extension-cjk-friendly": ["micromark-extension-cjk-friendly@1.2.3", "", { "dependencies": { "devlop": "^1.1.0", "micromark-extension-cjk-friendly-util": "2.1.1", "micromark-util-chunked": "^2.0.1", "micromark-util-resolve-all": "^2.0.1", "micromark-util-symbol": "^2.0.1" }, "peerDependencies": { "micromark": "^4.0.0", "micromark-util-types": "^2.0.0" }, "optionalPeers": ["micromark-util-types"] }, "sha512-gRzVLUdjXBLX6zNPSnHGDoo+ZTp5zy+MZm0g3sv+3chPXY7l9gW+DnrcHcZh/jiPR6MjPKO4AEJNp4Aw6V9z5Q=="], - - "micromark-extension-cjk-friendly-gfm-strikethrough": ["micromark-extension-cjk-friendly-gfm-strikethrough@1.2.3", "", { "dependencies": { "devlop": "^1.1.0", "get-east-asian-width": "^1.3.0", "micromark-extension-cjk-friendly-util": "2.1.1", "micromark-util-character": "^2.1.1", "micromark-util-chunked": "^2.0.1", "micromark-util-resolve-all": "^2.0.1", "micromark-util-symbol": "^2.0.1" }, "peerDependencies": { "micromark": "^4.0.0", "micromark-util-types": "^2.0.0" }, "optionalPeers": ["micromark-util-types"] }, "sha512-gSPnxgHDDqXYOBvQRq6lerrq9mjDhdtKn+7XETuXjxWcL62yZEfUdA28Ml1I2vDIPfAOIKLa0h2XDSGkInGHFQ=="], - - "micromark-extension-cjk-friendly-util": ["micromark-extension-cjk-friendly-util@2.1.1", "", { "dependencies": { "get-east-asian-width": "^1.3.0", "micromark-util-character": "^2.1.1", "micromark-util-symbol": "^2.0.1" } }, "sha512-egs6+12JU2yutskHY55FyR48ZiEcFOJFyk9rsiyIhcJ6IvWB6ABBqVrBw8IobqJTDZ/wdSr9eoXDPb5S2nW1bg=="], - "micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="], "micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="], @@ -2773,7 +2569,7 @@ "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], - "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "mime-types": ["mime-types@3.0.1", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA=="], "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], @@ -2811,7 +2607,7 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "mylas": ["mylas@2.1.14", "", {}, "sha512-BzQguy9W9NJgoVn2mRWzbFrFWWztGCcng2QI9+41frfk+Athwgx3qhqhvStz7ExeUUu7Kzw427sNzHpEZNINog=="], + "mylas": ["mylas@2.1.13", "", {}, "sha512-+MrqnJRtxdF+xngFfUUkIMQrUUL0KsxbADUkn23Z/4ibGg192Q+z+CQyiYwvWTsYjJygmMR8+w3ZDa98Zh6ESg=="], "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], @@ -2823,7 +2619,7 @@ "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], - "next": ["next@16.0.4", "", { "dependencies": { "@next/env": "16.0.4", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.0.4", "@next/swc-darwin-x64": "16.0.4", "@next/swc-linux-arm64-gnu": "16.0.4", "@next/swc-linux-arm64-musl": "16.0.4", "@next/swc-linux-x64-gnu": "16.0.4", "@next/swc-linux-x64-musl": "16.0.4", "@next/swc-win32-arm64-msvc": "16.0.4", "@next/swc-win32-x64-msvc": "16.0.4", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-vICcxKusY8qW7QFOzTvnRL1ejz2ClTqDKtm1AcUjm2mPv/lVAdgpGNsftsPRIDJOXOjRQO68i1dM8Lp8GZnqoA=="], + "next": ["next@16.0.3", "", { "dependencies": { "@next/env": "16.0.3", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.0.3", "@next/swc-darwin-x64": "16.0.3", "@next/swc-linux-arm64-gnu": "16.0.3", "@next/swc-linux-arm64-musl": "16.0.3", "@next/swc-linux-x64-gnu": "16.0.3", "@next/swc-linux-x64-musl": "16.0.3", "@next/swc-win32-arm64-msvc": "16.0.3", "@next/swc-win32-x64-msvc": "16.0.3", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-Ka0/iNBblPFcIubTA1Jjh6gvwqfjrGq1Y2MTI5lbjeLIAfmC+p5bQmojpRZqgHHVu5cG4+qdIiwXiBSm/8lZ3w=="], "no-case": ["no-case@3.0.4", "", { "dependencies": { "lower-case": "^2.0.2", "tslib": "^2.0.3" } }, "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg=="], @@ -2843,9 +2639,9 @@ "node-pty": ["node-pty@1.1.0-beta39", "", { "dependencies": { "node-addon-api": "^7.1.0" } }, "sha512-1xnN2dbS0QngT4xenpS/6Q77QtaDQo5vE6f4slATgZsFIv3NP4ObE7vAjYnZtMFG5OEh3jyDRZc+hy1DjDF7dg=="], - "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], + "node-releases": ["node-releases@2.0.26", "", {}, "sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA=="], - "nodemon": ["nodemon@3.1.11", "", { "dependencies": { "chokidar": "^3.5.2", "debug": "^4", "ignore-by-default": "^1.0.1", "minimatch": "^3.1.2", "pstree.remy": "^1.1.8", "semver": "^7.5.3", "simple-update-notifier": "^2.0.0", "supports-color": "^5.5.0", "touch": "^3.1.0", "undefsafe": "^2.0.5" }, "bin": { "nodemon": "bin/nodemon.js" } }, "sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g=="], + "nodemon": ["nodemon@3.1.10", "", { "dependencies": { "chokidar": "^3.5.2", "debug": "^4", "ignore-by-default": "^1.0.1", "minimatch": "^3.1.2", "pstree.remy": "^1.1.8", "semver": "^7.5.3", "simple-update-notifier": "^2.0.0", "supports-color": "^5.5.0", "touch": "^3.1.0", "undefsafe": "^2.0.5" }, "bin": { "nodemon": "bin/nodemon.js" } }, "sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw=="], "nopt": ["nopt@8.1.0", "", { "dependencies": { "abbrev": "^3.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A=="], @@ -2875,7 +2671,7 @@ "object.values": ["object.values@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="], - "ollama-ai-provider-v2": ["ollama-ai-provider-v2@1.5.5", "", { "dependencies": { "@ai-sdk/provider": "^2.0.0", "@ai-sdk/provider-utils": "^3.0.17" }, "peerDependencies": { "zod": "^4.0.16" } }, "sha512-1YwTFdPjhPNHny/DrOHO+s8oVGGIE5Jib61/KnnjPRNWQhVVimrJJdaAX3e6nNRRDXrY5zbb9cfm2+yVvgsrqw=="], + "ollama-ai-provider-v2": ["ollama-ai-provider-v2@1.5.4", "", { "dependencies": { "@ai-sdk/provider": "^2.0.0", "@ai-sdk/provider-utils": "^3.0.17" }, "peerDependencies": { "zod": "^4.0.16" } }, "sha512-OTxzIvxW7GutgkyYe55Y4lJeUbnDjH1jDkAQhjGiynffkDn0wyWbv/dD92A8HX1ni5Ec+i+ksYMXXlVOYPQR4g=="], "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], @@ -2885,9 +2681,7 @@ "oniguruma-parser": ["oniguruma-parser@0.12.1", "", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="], - "oniguruma-to-es": ["oniguruma-to-es@4.3.4", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA=="], - - "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], + "oniguruma-to-es": ["oniguruma-to-es@4.3.3", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-rPiZhzC3wXwE59YQMRDodUwwT9FZ9nNBwQQfsd1wfdtlKEyCdRV0avrTcSZ5xlIvGRVPd/cx6ZN45ECmS39xvg=="], "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], @@ -2959,9 +2753,9 @@ "pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="], - "playwright": ["playwright@1.57.0", "", { "dependencies": { "playwright-core": "1.57.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw=="], + "playwright": ["playwright@1.56.1", "", { "dependencies": { "playwright-core": "1.56.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw=="], - "playwright-core": ["playwright-core@1.57.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ=="], + "playwright-core": ["playwright-core@1.56.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ=="], "plimit-lit": ["plimit-lit@1.6.1", "", { "dependencies": { "queue-lit": "^1.5.1" } }, "sha512-B7+VDyb8Tl6oMJT9oSO2CW8XC/T4UcJGrwOVoNGwOQsQYhlpfajmrMj5xeejqaASq3V/EqThyOeATEOMuSEXiA=="], @@ -2977,7 +2771,7 @@ "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], - "posthog-js": ["posthog-js@1.298.0", "", { "dependencies": { "@posthog/core": "1.6.0", "core-js": "^3.38.1", "fflate": "^0.4.8", "preact": "^10.19.3", "web-vitals": "^4.2.4" } }, "sha512-Zwzsf7TO8qJ6DFLuUlQSsT/5OIOcxSBZlKOSk3satkEnwKdmnBXUuxgVXRHrvq1kj7OB2PVAPgZiQ8iHHj9DRA=="], + "posthog-js": ["posthog-js@1.281.0", "", { "dependencies": { "@posthog/core": "1.4.0", "core-js": "^3.38.1", "fflate": "^0.4.8", "preact": "^10.19.3", "web-vitals": "^4.2.4" } }, "sha512-t3sAlgVozpU1W1ppiF5zLG6eBRPUs0hmtxN8R1V7P0qZFmnECshAAk2cBxCsxEanadT3iUpS8Z7crBytATqWQQ=="], "preact": ["preact@10.27.2", "", {}, "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg=="], @@ -3029,11 +2823,9 @@ "quick-lru": ["quick-lru@5.1.1", "", {}, "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="], - "radash": ["radash@12.1.1", "", {}, "sha512-h36JMxKRqrAxVD8201FrCpyeNuUY9Y5zZwujr20fFO77tpUtGa6EZzfKw/3WaiBX95fq7+MpsuMLNdSnORAwSA=="], - "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], - "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + "raw-body": ["raw-body@3.0.1", "", { "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", "iconv-lite": "0.7.0", "unpipe": "1.0.0" } }, "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA=="], "react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], @@ -3051,6 +2843,8 @@ "react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="], + "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], "react-remove-scroll": ["react-remove-scroll@2.7.1", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA=="], @@ -3077,10 +2871,6 @@ "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], - "regenerate": ["regenerate@1.4.2", "", {}, "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A=="], - - "regenerate-unicode-properties": ["regenerate-unicode-properties@10.2.2", "", { "dependencies": { "regenerate": "^1.4.2" } }, "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g=="], - "regex": ["regex@6.0.1", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA=="], "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="], @@ -3089,12 +2879,6 @@ "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], - "regexpu-core": ["regexpu-core@6.4.0", "", { "dependencies": { "regenerate": "^1.4.2", "regenerate-unicode-properties": "^10.2.2", "regjsgen": "^0.8.0", "regjsparser": "^0.13.0", "unicode-match-property-ecmascript": "^2.0.0", "unicode-match-property-value-ecmascript": "^2.2.1" } }, "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA=="], - - "regjsgen": ["regjsgen@0.8.0", "", {}, "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q=="], - - "regjsparser": ["regjsparser@0.13.0", "", { "dependencies": { "jsesc": "~3.1.0" }, "bin": { "regjsparser": "bin/parser" } }, "sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q=="], - "rehype-harden": ["rehype-harden@1.1.5", "", {}, "sha512-JrtBj5BVd/5vf3H3/blyJatXJbzQfRT9pJBmjafbTaPouQCAKxHwRyCc7dle9BXQKxv4z1OzZylz/tNamoiG3A=="], "rehype-katex": ["rehype-katex@7.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/katex": "^0.16.0", "hast-util-from-html-isomorphic": "^2.0.0", "hast-util-to-text": "^4.0.0", "katex": "^0.16.0", "unist-util-visit-parents": "^6.0.0", "vfile": "^6.0.0" } }, "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA=="], @@ -3103,10 +2887,6 @@ "release-zalgo": ["release-zalgo@1.0.0", "", { "dependencies": { "es6-error": "^4.0.1" } }, "sha512-gUAyHVHPPC5wdqX/LG4LWtRYtgjxyX78oanFNTMMyFEfOqdC54s3eE82imuWKbOeqYht2CrNf64Qb8vgmmtZGA=="], - "remark-cjk-friendly": ["remark-cjk-friendly@1.2.3", "", { "dependencies": { "micromark-extension-cjk-friendly": "1.2.3" }, "peerDependencies": { "@types/mdast": "^4.0.0", "unified": "^11.0.0" }, "optionalPeers": ["@types/mdast"] }, "sha512-UvAgxwlNk+l9Oqgl/9MWK2eWRS7zgBW/nXX9AthV7nd/3lNejF138E7Xbmk9Zs4WjTJGs721r7fAEc7tNFoH7g=="], - - "remark-cjk-friendly-gfm-strikethrough": ["remark-cjk-friendly-gfm-strikethrough@1.2.3", "", { "dependencies": { "micromark-extension-cjk-friendly-gfm-strikethrough": "1.2.3" }, "peerDependencies": { "@types/mdast": "^4.0.0", "unified": "^11.0.0" }, "optionalPeers": ["@types/mdast"] }, "sha512-bXfMZtsaomK6ysNN/UGRIcasQAYkC10NtPmP0oOHOV8YOhA2TXmwRXCku4qOzjIFxAPfish5+XS0eIug2PzNZA=="], - "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="], "remark-math": ["remark-math@6.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-math": "^3.0.0", "micromark-extension-math": "^3.0.0", "unified": "^11.0.0" } }, "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA=="], @@ -3149,9 +2929,7 @@ "robust-predicates": ["robust-predicates@3.0.2", "", {}, "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg=="], - "rollup": ["rollup@4.53.3", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.53.3", "@rollup/rollup-android-arm64": "4.53.3", "@rollup/rollup-darwin-arm64": "4.53.3", "@rollup/rollup-darwin-x64": "4.53.3", "@rollup/rollup-freebsd-arm64": "4.53.3", "@rollup/rollup-freebsd-x64": "4.53.3", "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", "@rollup/rollup-linux-arm-musleabihf": "4.53.3", "@rollup/rollup-linux-arm64-gnu": "4.53.3", "@rollup/rollup-linux-arm64-musl": "4.53.3", "@rollup/rollup-linux-loong64-gnu": "4.53.3", "@rollup/rollup-linux-ppc64-gnu": "4.53.3", "@rollup/rollup-linux-riscv64-gnu": "4.53.3", "@rollup/rollup-linux-riscv64-musl": "4.53.3", "@rollup/rollup-linux-s390x-gnu": "4.53.3", "@rollup/rollup-linux-x64-gnu": "4.53.3", "@rollup/rollup-linux-x64-musl": "4.53.3", "@rollup/rollup-openharmony-arm64": "4.53.3", "@rollup/rollup-win32-arm64-msvc": "4.53.3", "@rollup/rollup-win32-ia32-msvc": "4.53.3", "@rollup/rollup-win32-x64-gnu": "4.53.3", "@rollup/rollup-win32-x64-msvc": "4.53.3", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA=="], - - "rou3": ["rou3@0.7.10", "", {}, "sha512-aoFj6f7MJZ5muJ+Of79nrhs9N3oLGqi2VEMe94Zbkjb6Wupha46EuoYgpWSOZlXww3bbd8ojgXTAA2mzimX5Ww=="], + "rollup": ["rollup@4.52.5", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.52.5", "@rollup/rollup-android-arm64": "4.52.5", "@rollup/rollup-darwin-arm64": "4.52.5", "@rollup/rollup-darwin-x64": "4.52.5", "@rollup/rollup-freebsd-arm64": "4.52.5", "@rollup/rollup-freebsd-x64": "4.52.5", "@rollup/rollup-linux-arm-gnueabihf": "4.52.5", "@rollup/rollup-linux-arm-musleabihf": "4.52.5", "@rollup/rollup-linux-arm64-gnu": "4.52.5", "@rollup/rollup-linux-arm64-musl": "4.52.5", "@rollup/rollup-linux-loong64-gnu": "4.52.5", "@rollup/rollup-linux-ppc64-gnu": "4.52.5", "@rollup/rollup-linux-riscv64-gnu": "4.52.5", "@rollup/rollup-linux-riscv64-musl": "4.52.5", "@rollup/rollup-linux-s390x-gnu": "4.52.5", "@rollup/rollup-linux-x64-gnu": "4.52.5", "@rollup/rollup-linux-x64-musl": "4.52.5", "@rollup/rollup-openharmony-arm64": "4.52.5", "@rollup/rollup-win32-arm64-msvc": "4.52.5", "@rollup/rollup-win32-ia32-msvc": "4.52.5", "@rollup/rollup-win32-x64-gnu": "4.52.5", "@rollup/rollup-win32-x64-msvc": "4.52.5", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw=="], "roughjs": ["roughjs@4.6.6", "", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ=="], @@ -3165,7 +2943,7 @@ "safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="], - "safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], "safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="], @@ -3175,13 +2953,13 @@ "sanitize-filename": ["sanitize-filename@1.6.3", "", { "dependencies": { "truncate-utf8-bytes": "^1.0.0" } }, "sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg=="], - "sax": ["sax@1.4.3", "", {}, "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ=="], + "sax": ["sax@1.4.1", "", {}, "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg=="], "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], "scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], - "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], "semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="], @@ -3211,9 +2989,9 @@ "shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="], - "shescape": ["shescape@2.1.7", "", { "dependencies": { "which": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0" } }, "sha512-Y1syY0ggm3ow7mE1zrcK9YrOhAqv/IGbm3+J9S+MXLukwXf/M8yzL3hZp7ubVeSy250TT7M5SVKikTZkKyib6w=="], + "shescape": ["shescape@2.1.6", "", { "dependencies": { "which": "^3.0.0 || ^4.0.0 || ^5.0.0" } }, "sha512-c9Ns1I+Tl0TC+cpsOT1FeZcvFalfd0WfHeD/CMccJH20xwochmJzq6AqtenndlyAw/BUi3BMcv92dYLVrqX+dw=="], - "shiki": ["shiki@3.15.0", "", { "dependencies": { "@shikijs/core": "3.15.0", "@shikijs/engine-javascript": "3.15.0", "@shikijs/engine-oniguruma": "3.15.0", "@shikijs/langs": "3.15.0", "@shikijs/themes": "3.15.0", "@shikijs/types": "3.15.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-kLdkY6iV3dYbtPwS9KXU7mjfmDm25f5m0IPNFnaXO7TBPcvbUOY72PYXSuSqDzwp+vlH/d7MXpHlKO/x+QoLXw=="], + "shiki": ["shiki@3.14.0", "", { "dependencies": { "@shikijs/core": "3.14.0", "@shikijs/engine-javascript": "3.14.0", "@shikijs/engine-oniguruma": "3.14.0", "@shikijs/langs": "3.14.0", "@shikijs/themes": "3.14.0", "@shikijs/types": "3.14.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-J0yvpLI7LSig3Z3acIuDLouV5UCKQqu8qOArwMx+/yPVC3WRMgrP67beaG8F+j4xfEWE0eVC4GeBCIXeOPra1g=="], "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], @@ -3267,9 +3045,9 @@ "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], - "storybook": ["storybook@10.0.8", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^1.6.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/user-event": "^14.6.1", "@vitest/expect": "3.2.4", "@vitest/mocker": "3.2.4", "@vitest/spy": "3.2.4", "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0", "recast": "^0.23.5", "semver": "^7.6.2", "ws": "^8.18.0" }, "peerDependencies": { "prettier": "^2 || ^3" }, "optionalPeers": ["prettier"], "bin": "./dist/bin/dispatcher.js" }, "sha512-vQMufKKA9TxgoEDHJv3esrqUkjszuuRiDkThiHxENFPdQawHhm2Dei+iwNRwH5W671zTDy9iRT9P1KDjcU5Iyw=="], + "storybook": ["storybook@10.0.0", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^1.6.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/user-event": "^14.6.1", "@vitest/expect": "3.2.4", "@vitest/mocker": "3.2.4", "@vitest/spy": "3.2.4", "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0", "recast": "^0.23.5", "semver": "^7.6.2", "ws": "^8.18.0" }, "peerDependencies": { "prettier": "^2 || ^3" }, "optionalPeers": ["prettier"], "bin": "./dist/bin/dispatcher.js" }, "sha512-lJfn3+4koKQW1kp3RotkAYlvV8C/3lnhXOJYm+4aD9CACoT48qEOLwEmvIho6u+KTlbDnGonP5697Jw6rZ2E9A=="], - "streamdown": ["streamdown@1.6.8", "", { "dependencies": { "clsx": "^2.1.1", "hast": "^1.0.0", "hast-util-to-jsx-runtime": "^2.3.6", "html-url-attributes": "^3.0.1", "katex": "^0.16.22", "lucide-react": "^0.542.0", "marked": "^16.2.1", "mermaid": "^11.11.0", "rehype-harden": "^1.1.5", "rehype-katex": "^7.0.1", "rehype-raw": "^7.0.0", "remark-cjk-friendly": "^1.2.3", "remark-cjk-friendly-gfm-strikethrough": "^1.2.3", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "shiki": "^3.12.2", "tailwind-merge": "^3.3.1", "unist-util-visit": "^5.0.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-SmVS8MRLfEQIYWx1EWmQQ6lCxiY7n9Hlg/EDXl17ZYcbCdTd8caMVngBNlIHxwQPvQDyXozrEzcgkhzYyMmN/w=="], + "streamdown": ["streamdown@1.4.0", "", { "dependencies": { "clsx": "^2.1.1", "katex": "^0.16.22", "lucide-react": "^0.542.0", "marked": "^16.2.1", "mermaid": "^11.11.0", "react-markdown": "^10.1.0", "rehype-harden": "^1.1.5", "rehype-katex": "^7.0.1", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", "shiki": "^3.12.2", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-ylhDSQ4HpK5/nAH9v7OgIIdGJxlJB2HoYrYkJNGrO8lMpnWuKUcrz/A8xAMwA6eILA27469vIavcOTjmxctrKg=="], "string-length": ["string-length@6.0.0", "", { "dependencies": { "strip-ansi": "^7.1.0" } }, "sha512-1U361pxZHEQ+FeSjzqRpV+cu2vTzYeWeafXFLykiFlv4Vc0n3njgU8HrMbyik5uwm77naWMuVG8fhEF+Ovb1Kg=="], @@ -3287,7 +3065,7 @@ "string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="], - "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + "string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], @@ -3305,9 +3083,9 @@ "strnum": ["strnum@2.1.1", "", {}, "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw=="], - "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], + "style-to-js": ["style-to-js@1.1.18", "", { "dependencies": { "style-to-object": "1.0.11" } }, "sha512-JFPn62D4kJaPTnhFUI244MThx+FEGbi+9dw1b9yBBQ+1CZpV7QAT8kUtJ7b7EUNdHajjF/0x8fT+16oLJoojLg=="], - "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], + "style-to-object": ["style-to-object@1.0.11", "", { "dependencies": { "inline-style-parser": "0.2.4" } }, "sha512-5A560JmXr7wDyGLK12Nq/EYS38VkGlglVzkis1JEdbGWSnbQIEhZzTJhzURXN5/8WwwFCs/f/VVcmkTppbXLow=="], "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], @@ -3327,13 +3105,11 @@ "synckit": ["synckit@0.11.11", "", { "dependencies": { "@pkgr/core": "^0.2.9" } }, "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw=="], - "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], - "tailwind-api-utils": ["tailwind-api-utils@1.0.3", "", { "dependencies": { "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", "local-pkg": "^1.1.1" }, "peerDependencies": { "tailwindcss": "^3.3.0 || ^4.0.0 || ^4.0.0-beta" } }, "sha512-KpzUHkH1ug1sq4394SLJX38ZtpeTiqQ1RVyFTTSY2XuHsNSTWUkRo108KmyyrMWdDbQrLYkSHaNKj/a3bmA4sQ=="], - "tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="], + "tailwind-merge": ["tailwind-merge@3.3.1", "", {}, "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g=="], - "tailwindcss": ["tailwindcss@4.1.17", "", {}, "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q=="], + "tailwindcss": ["tailwindcss@4.1.16", "", {}, "sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA=="], "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], @@ -3349,7 +3125,7 @@ "tiny-typed-emitter": ["tiny-typed-emitter@2.1.0", "", {}, "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA=="], - "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], + "tinyexec": ["tinyexec@1.0.1", "", {}, "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw=="], "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], @@ -3419,7 +3195,7 @@ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "typescript-eslint": ["typescript-eslint@8.48.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.48.0", "@typescript-eslint/parser": "8.48.0", "@typescript-eslint/typescript-estree": "8.48.0", "@typescript-eslint/utils": "8.48.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-fcKOvQD9GUn3Xw63EgiDqhvWJ5jsyZUaekl3KVpGsDJnN46WJTe3jWxtQP9lMZm1LJNkFLlTaWAxK2vUQR+cqw=="], + "typescript-eslint": ["typescript-eslint@8.46.2", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.46.2", "@typescript-eslint/parser": "8.46.2", "@typescript-eslint/typescript-estree": "8.46.2", "@typescript-eslint/utils": "8.46.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-vbw8bOmiuYNdzzV3lsiWv6sRwjyuKJMQqWulBOU7M0RrxedXledX8G8kBbQeiOYDnTfiXz0Y4081E1QMNB6iQg=="], "uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="], @@ -3433,15 +3209,7 @@ "undici": ["undici@7.16.0", "", {}, "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g=="], - "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - - "unicode-canonical-property-names-ecmascript": ["unicode-canonical-property-names-ecmascript@2.0.1", "", {}, "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg=="], - - "unicode-match-property-ecmascript": ["unicode-match-property-ecmascript@2.0.0", "", { "dependencies": { "unicode-canonical-property-names-ecmascript": "^2.0.0", "unicode-property-aliases-ecmascript": "^2.0.0" } }, "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q=="], - - "unicode-match-property-value-ecmascript": ["unicode-match-property-value-ecmascript@2.2.1", "", {}, "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg=="], - - "unicode-property-aliases-ecmascript": ["unicode-property-aliases-ecmascript@2.2.0", "", {}, "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ=="], + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], @@ -3467,7 +3235,7 @@ "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], - "unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], + "unplugin": ["unplugin@2.3.10", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-6NCPkv1ClwH+/BGE9QeoTIl09nuiAt0gS28nn1PvYXsGKRwM2TCbFA2QiilmehPDTXIe684k4rZI1yl3A1PCUw=="], "unrs-resolver": ["unrs-resolver@1.11.1", "", { "dependencies": { "napi-postinstall": "^0.3.0" }, "optionalDependencies": { "@unrs/resolver-binding-android-arm-eabi": "1.11.1", "@unrs/resolver-binding-android-arm64": "1.11.1", "@unrs/resolver-binding-darwin-arm64": "1.11.1", "@unrs/resolver-binding-darwin-x64": "1.11.1", "@unrs/resolver-binding-freebsd-x64": "1.11.1", "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", "@unrs/resolver-binding-linux-x64-musl": "1.11.1", "@unrs/resolver-binding-wasm32-wasi": "1.11.1", "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" } }, "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg=="], @@ -3499,7 +3267,7 @@ "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], - "vite": ["vite@7.2.4", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w=="], + "vite": ["vite@7.1.12", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug=="], "vite-plugin-svgr": ["vite-plugin-svgr@4.5.0", "", { "dependencies": { "@rollup/pluginutils": "^5.2.0", "@svgr/core": "^8.1.0", "@svgr/plugin-jsx": "^8.1.0" }, "peerDependencies": { "vite": ">=2.6.0" } }, "sha512-W+uoSpmVkSmNOGPSsDCWVW/DDAyv+9fap9AZXBvWiQqrboJ08j2vh0tFxTD/LjwqwAd3yYSVJgm54S/1GhbdnA=="], @@ -3555,8 +3323,6 @@ "wide-align": ["wide-align@1.1.5", "", { "dependencies": { "string-width": "^1.0.2 || 2 || 3 || 4" } }, "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg=="], - "wildcard-match": ["wildcard-match@5.1.4", "", {}, "sha512-wldeCaczs8XXq7hj+5d/F38JE2r7EXgb6WQDM84RVwxy81T/sxB5e9+uZLK9Q9oNz1mlvjut+QtvgaOQFPVq/g=="], - "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], "wordwrap": ["wordwrap@1.0.0", "", {}, "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q=="], @@ -3595,46 +3361,38 @@ "zip-stream": ["zip-stream@4.1.1", "", { "dependencies": { "archiver-utils": "^3.0.4", "compress-commons": "^4.1.2", "readable-stream": "^3.6.0" } }, "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ=="], - "zod": ["zod@4.1.13", "", {}, "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig=="], + "zod": ["zod@4.1.12", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="], - "zod-to-json-schema": ["zod-to-json-schema@3.25.0", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ=="], + "zod-to-json-schema": ["zod-to-json-schema@3.24.6", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg=="], "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], - "@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.50", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.18" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-21PaHfoLmouOXXNINTsZJsMw+wE5oLR2He/1kq/sKokTVKyq7ObGT1LDk6ahwxaz/GoaNaGankMh+EgVcdv2Cw=="], - - "@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.17", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-TR3Gs4I3Tym4Ll+EPdzRdvo/rc8Js6c4nVhFLuvGLX/Y4V9ZcQMa/HTiYsHEgmYrf1zVi6Q145UEZUfleOwOjw=="], - - "@ai-sdk/gateway/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.17", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-TR3Gs4I3Tym4Ll+EPdzRdvo/rc8Js6c4nVhFLuvGLX/Y4V9ZcQMa/HTiYsHEgmYrf1zVi6Q145UEZUfleOwOjw=="], - - "@ai-sdk/google/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.17", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-TR3Gs4I3Tym4Ll+EPdzRdvo/rc8Js6c4nVhFLuvGLX/Y4V9ZcQMa/HTiYsHEgmYrf1zVi6Q145UEZUfleOwOjw=="], - - "@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.17", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-TR3Gs4I3Tym4Ll+EPdzRdvo/rc8Js6c4nVhFLuvGLX/Y4V9ZcQMa/HTiYsHEgmYrf1zVi6Q145UEZUfleOwOjw=="], - - "@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.17", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-TR3Gs4I3Tym4Ll+EPdzRdvo/rc8Js6c4nVhFLuvGLX/Y4V9ZcQMa/HTiYsHEgmYrf1zVi6Q145UEZUfleOwOjw=="], - - "@ai-sdk/xai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.17", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-TR3Gs4I3Tym4Ll+EPdzRdvo/rc8Js6c4nVhFLuvGLX/Y4V9ZcQMa/HTiYsHEgmYrf1zVi6Q145UEZUfleOwOjw=="], + "@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.49", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.17" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XedtHVHX6UOlR/aa8bDmlsDc/e+kjC+l6qBeqnZPF05np6Xs7YR8tfH7yARq0LDq3m+ysw7Qoy9M5KRL+1C8qA=="], "@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], "@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], "@electron/asar/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], "@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], + "@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@electron/notarize/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], "@electron/osx-sign/isbinaryfile": ["isbinaryfile@4.0.10", "", {}, "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw=="], "@electron/rebuild/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "@electron/rebuild/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], - "@electron/universal/@malept/cross-spawn-promise": ["@malept/cross-spawn-promise@1.1.1", "", { "dependencies": { "cross-spawn": "^7.0.1" } }, "sha512-RTBGWL5FWQcg9orDOCcp4LvItNzUPcyEU9bwaeJX0rJ1IQxzucC48Y0/sQLp/g6t99IQgAlGIaesJS+gTn7tVQ=="], "@electron/universal/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], @@ -3657,16 +3415,28 @@ "@istanbuljs/load-nyc-config/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], - "@istanbuljs/load-nyc-config/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], + "@istanbuljs/load-nyc-config/js-yaml": ["js-yaml@3.14.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g=="], + + "@jest/console/@types/node": ["@types/node@24.9.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA=="], "@jest/console/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "@jest/core/@types/node": ["@types/node@24.9.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA=="], + "@jest/core/ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="], "@jest/core/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "@jest/core/ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="], + "@jest/environment/@types/node": ["@types/node@24.9.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA=="], + + "@jest/fake-timers/@types/node": ["@types/node@24.9.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA=="], + + "@jest/pattern/@types/node": ["@types/node@24.9.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA=="], + + "@jest/reporters/@types/node": ["@types/node@24.9.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA=="], + "@jest/reporters/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "@jest/reporters/istanbul-lib-instrument": ["istanbul-lib-instrument@6.0.3", "", { "dependencies": { "@babel/core": "^7.23.9", "@babel/parser": "^7.23.9", "@istanbuljs/schema": "^0.1.3", "istanbul-lib-coverage": "^3.2.0", "semver": "^7.5.4" } }, "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q=="], @@ -3681,98 +3451,78 @@ "@jest/transform/write-file-atomic": ["write-file-atomic@5.0.1", "", { "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^4.0.1" } }, "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw=="], + "@jest/types/@types/node": ["@types/node@24.9.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA=="], + "@jest/types/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], + "@napi-rs/wasm-runtime/@emnapi/runtime": ["@emnapi/runtime@1.6.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-obtUmAHTMjll499P+D9A3axeJFlhdjOWdKUNs/U6QIGT7V5RjcUW1xToAzjvmgTSQhDbYn/NwfTRoJcQ2rNBxA=="], + "@npmcli/agent/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "@npmcli/agent/socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="], - "@npmcli/fs/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], - - "@orpc/shared/type-fest": ["type-fest@5.2.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-xxCJm+Bckc6kQBknN7i9fnP/xobQRsRQxR01CztFkp/h++yfVxUUcmMgfR2HttJx/dpWjS9ubVuyspJv24Q9DA=="], - - "@orpc/zod/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], - - "@radix-ui/react-arrow/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - - "@radix-ui/react-checkbox/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - - "@radix-ui/react-collection/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@radix-ui/react-label/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], - "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.6.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zq/ay+9fNIJJtJiZxdTnXS20PllcYMX3OE23ESc4HK/bdYu3cOWYVhsOhVnXALfU/uqJIxn5NBPd9z4v+SfoSg=="], - "@radix-ui/react-dialog/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.6.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-obtUmAHTMjll499P+D9A3axeJFlhdjOWdKUNs/U6QIGT7V5RjcUW1xToAzjvmgTSQhDbYn/NwfTRoJcQ2rNBxA=="], - "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - - "@radix-ui/react-dismissable-layer/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - - "@radix-ui/react-dropdown-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], - "@radix-ui/react-focus-scope/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.7", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw=="], - "@radix-ui/react-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], - "@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "@radix-ui/react-popper/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@testing-library/dom/pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], - "@radix-ui/react-portal/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@testing-library/jest-dom/aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], - "@radix-ui/react-roving-focus/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@testing-library/jest-dom/dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], - "@radix-ui/react-scroll-area/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@types/body-parser/@types/node": ["@types/node@24.9.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA=="], - "@radix-ui/react-select/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@types/cacheable-request/@types/node": ["@types/node@24.9.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA=="], - "@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@types/connect/@types/node": ["@types/node@24.9.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA=="], - "@radix-ui/react-tabs/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@types/cors/@types/node": ["@types/node@24.9.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA=="], - "@radix-ui/react-toggle/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@types/express-serve-static-core/@types/node": ["@types/node@24.9.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA=="], - "@radix-ui/react-toggle-group/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@types/fs-extra/@types/node": ["@types/node@24.9.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA=="], - "@radix-ui/react-tooltip/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@types/jsdom/@types/node": ["@types/node@24.9.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA=="], - "@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@types/keyv/@types/node": ["@types/node@24.9.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA=="], - "@radix-ui/react-visually-hidden/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@types/plist/@types/node": ["@types/node@24.9.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="], + "@types/responselike/@types/node": ["@types/node@24.9.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="], + "@types/send/@types/node": ["@types/node@24.9.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], + "@types/serve-static/@types/node": ["@types/node@24.9.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA=="], - "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.7", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw=="], + "@types/wait-on/@types/node": ["@types/node@24.9.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA=="], - "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + "@types/write-file-atomic/@types/node": ["@types/node@24.9.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA=="], - "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@types/ws/@types/node": ["@types/node@24.9.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA=="], - "@testing-library/dom/pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], - - "@testing-library/jest-dom/aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], - - "@testing-library/jest-dom/dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], + "@types/yauzl/@types/node": ["@types/node@24.9.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA=="], "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], - "@typescript-eslint/typescript-estree/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], - "@vitest/mocker/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], - "ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.17", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-TR3Gs4I3Tym4Ll+EPdzRdvo/rc8Js6c4nVhFLuvGLX/Y4V9ZcQMa/HTiYsHEgmYrf1zVi6Q145UEZUfleOwOjw=="], - "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "app-builder-lib/minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="], - "app-builder-lib/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], - "archiver-utils/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], "archiver-utils/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], @@ -3781,14 +3531,14 @@ "babel-plugin-istanbul/istanbul-lib-instrument": ["istanbul-lib-instrument@6.0.3", "", { "dependencies": { "@babel/core": "^7.23.9", "@babel/parser": "^7.23.9", "@istanbuljs/schema": "^0.1.3", "istanbul-lib-coverage": "^3.2.0", "semver": "^7.5.4" } }, "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q=="], - "body-parser/iconv-lite": ["iconv-lite@0.7.0", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ=="], - "builder-util/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "builder-util/http-proxy-agent": ["http-proxy-agent@5.0.0", "", { "dependencies": { "@tootallnate/once": "2", "agent-base": "6", "debug": "4" } }, "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w=="], "builder-util/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], + "bun-types/@types/node": ["@types/node@24.9.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA=="], + "cacache/fs-minipass": ["fs-minipass@3.0.3", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw=="], "cacache/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], @@ -3829,8 +3579,6 @@ "dom-serializer/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="], - "electron/@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="], - "electron-builder/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "electron-publish/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -3843,32 +3591,26 @@ "electron-rebuild/node-gyp": ["node-gyp@9.4.1", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "glob": "^7.1.4", "graceful-fs": "^4.2.6", "make-fetch-happen": "^10.0.3", "nopt": "^6.0.0", "npmlog": "^6.0.0", "rimraf": "^3.0.2", "semver": "^7.3.5", "tar": "^6.1.2", "which": "^2.0.2" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-OQkWKbjQKbGkMf/xqI1jjy3oCTgMKJac58G2+bjZb3fza6gW2YrCSdMQYaoTb70crvE//Gngr4f0AgVHmqHvBQ=="], - "electron-rebuild/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], - "electron-updater/builder-util-runtime": ["builder-util-runtime@9.3.1", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-2/egrNDDnRaxVwK3A+cJq6UOlqOdedGA7JPqCeJjN2Zjk1/QB/6QUi3b714ScIGS7HafFXTyzJEOr5b44I3kvQ=="], - "electron-updater/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], - "eslint/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "eslint-plugin-react/resolve": ["resolve@2.0.0-next.5", "", { "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA=="], + "eslint-plugin-react/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], "execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], - "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], - "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "filelist/minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="], "find-process/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "find-process/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], - "foreground-child/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], @@ -3883,8 +3625,6 @@ "glob/minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], - "global-agent/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], - "global-modules/is-windows": ["is-windows@0.2.0", "", {}, "sha512-n67eJYmXbniZB7RF4I/FTjK1s6RPOCTxhYrVYLRaCt3lF0mpWZPKr3T2LSZAqyjQsxR2qMmGYXXzK0YWwcPM1Q=="], "global-prefix/is-windows": ["is-windows@0.2.0", "", {}, "sha512-n67eJYmXbniZB7RF4I/FTjK1s6RPOCTxhYrVYLRaCt3lF0mpWZPKr3T2LSZAqyjQsxR2qMmGYXXzK0YWwcPM1Q=="], @@ -3905,14 +3645,20 @@ "htmlparser2/entities": ["entities@1.1.2", "", {}, "sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w=="], + "http-errors/statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="], + "iconv-corefoundation/node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="], "import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + "istanbul-lib-instrument/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "istanbul-lib-report/make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="], "istanbul-lib-report/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "jest-circus/@types/node": ["@types/node@24.9.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA=="], + "jest-circus/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "jest-cli/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -3925,29 +3671,39 @@ "jest-each/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "jest-environment-node/@types/node": ["@types/node@24.9.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA=="], + + "jest-haste-map/@types/node": ["@types/node@24.9.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA=="], + "jest-haste-map/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "jest-matcher-utils/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "jest-message-util/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "jest-mock/@types/node": ["@types/node@24.9.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA=="], + "jest-process-manager/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "jest-process-manager/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], "jest-resolve/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "jest-runner/@types/node": ["@types/node@24.9.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA=="], + "jest-runner/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "jest-runner/source-map-support": ["source-map-support@0.5.13", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w=="], + "jest-runtime/@types/node": ["@types/node@24.9.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA=="], + "jest-runtime/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "jest-runtime/strip-bom": ["strip-bom@4.0.0", "", {}, "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w=="], "jest-snapshot/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "jest-snapshot/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "jest-util/@types/node": ["@types/node@24.9.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA=="], "jest-util/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -3959,12 +3715,16 @@ "jest-watch-typeahead/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], + "jest-watcher/@types/node": ["@types/node@24.9.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA=="], + "jest-watcher/ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="], "jest-watcher/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "jest-watcher/string-length": ["string-length@4.0.2", "", { "dependencies": { "char-regex": "^1.0.2", "strip-ansi": "^6.0.0" } }, "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ=="], + "jest-worker/@types/node": ["@types/node@24.9.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA=="], + "jsdom/parse5": ["parse5@8.0.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA=="], "jsdom/whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], @@ -3979,6 +3739,8 @@ "lzma-native/node-addon-api": ["node-addon-api@3.2.1", "", {}, "sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A=="], + "make-dir/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "make-fetch-happen/minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], @@ -4005,16 +3767,8 @@ "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], - "node-abi/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], - - "node-api-version/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], - - "node-gyp/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], - "node-gyp/tar": ["tar@7.5.2", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg=="], - "nodemon/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], - "nodemon/supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="], "nyc/convert-source-map": ["convert-source-map@1.9.0", "", {}, "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="], @@ -4027,8 +3781,6 @@ "nyc/yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="], - "ollama-ai-provider-v2/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.17", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-TR3Gs4I3Tym4Ll+EPdzRdvo/rc8Js6c4nVhFLuvGLX/Y4V9ZcQMa/HTiYsHEgmYrf1zVi6Q145UEZUfleOwOjw=="], - "ora/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], @@ -4065,10 +3817,6 @@ "serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], - "sharp/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], - - "simple-update-notifier/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], - "socks-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], "spawn-wrap/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], @@ -4081,17 +3829,15 @@ "stack-utils/escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], - "storybook/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], - "streamdown/lucide-react": ["lucide-react@0.542.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-w3hD8/SQB7+lzU2r4VdFyzzOzKnUjTZIF/MQJGSSvni7Llewni4vuViRppfRAa2guOsY5k4jZyxw/i9DQHv+dw=="], "string-length/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], - "string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "string_decoder/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], "test-exclude/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], - "ts-jest/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "tsc-alias/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="], "unzip-crx-3/mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="], @@ -4127,72 +3873,90 @@ "@istanbuljs/load-nyc-config/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + "@jest/console/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "@jest/console/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "@jest/core/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "@jest/core/ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="], "@jest/core/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "@jest/reporters/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "@jest/environment/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "@jest/fake-timers/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "@jest/pattern/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - "@jest/reporters/istanbul-lib-instrument/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "@jest/reporters/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "@jest/reporters/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "@jest/snapshot-utils/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "@jest/transform/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "@jest/types/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "@jest/types/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "@radix-ui/react-arrow/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-label/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], - "@radix-ui/react-checkbox/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@testing-library/dom/pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], - "@radix-ui/react-dismissable-layer/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@testing-library/dom/pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], - "@radix-ui/react-dropdown-menu/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@types/body-parser/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - "@radix-ui/react-focus-scope/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@types/cacheable-request/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - "@radix-ui/react-popper/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@types/connect/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - "@radix-ui/react-portal/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@types/cors/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - "@radix-ui/react-roving-focus/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@types/express-serve-static-core/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - "@radix-ui/react-scroll-area/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@types/fs-extra/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - "@radix-ui/react-tabs/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@types/jsdom/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - "@radix-ui/react-toggle-group/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@types/keyv/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - "@radix-ui/react-toggle/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@types/plist/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - "@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@types/responselike/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - "@testing-library/dom/pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + "@types/send/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - "@testing-library/dom/pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + "@types/serve-static/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "@types/wait-on/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "@types/write-file-atomic/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "@types/ws/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "@types/yauzl/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], "app-builder-lib/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], - "archiver-utils/readable-stream/core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], - "archiver-utils/readable-stream/isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], - "archiver-utils/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + "archiver-utils/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], "babel-jest/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "babel-plugin-istanbul/istanbul-lib-instrument/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], - "builder-util/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "builder-util/http-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], "builder-util/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + "bun-types/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "cacache/tar/chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], "cacache/tar/minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], @@ -4207,6 +3971,8 @@ "cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="], + "d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], + "d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], "electron-builder/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], @@ -4223,8 +3989,6 @@ "electron-rebuild/node-gyp/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], - "electron/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - "eslint/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "filelist/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], @@ -4237,9 +4001,7 @@ "global-prefix/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - "happy-dom/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - - "istanbul-lib-report/make-dir/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "jest-circus/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "jest-circus/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], @@ -4251,43 +4013,55 @@ "jest-each/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "jest-environment-node/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "jest-haste-map/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "jest-matcher-utils/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "jest-message-util/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "jest-mock/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "jest-process-manager/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "jest-resolve/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "jest-runner/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "jest-runner/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "jest-runtime/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "jest-runtime/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "jest-snapshot/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "jest-util/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "jest-util/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "jest-validate/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "jest-watch-typeahead/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + "jest-watcher/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "jest-watcher/ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="], "jest-watcher/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "jsdom/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "jest-worker/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - "jszip/readable-stream/core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], + "jsdom/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], "jszip/readable-stream/isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], - "jszip/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], - - "lazystream/readable-stream/core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], + "jszip/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], "lazystream/readable-stream/isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], - "lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + "lazystream/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], "log-symbols/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 44c40ebf6..9289acd7e 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -84,9 +84,9 @@ Avoid mock-heavy tests that verify implementation details rather than behavior. ### Integration Testing - Use `bun x jest` (optionally `TEST_INTEGRATION=1`). Examples: - - `TEST_INTEGRATION=1 bun x jest tests/integration/sendMessage.test.ts -t "pattern"` + - `TEST_INTEGRATION=1 bun x jest tests/ipcMain/sendMessage.test.ts -t "pattern"` - `TEST_INTEGRATION=1 bun x jest tests` -- `tests/integration` is slow; filter with `-t` when possible. Tests use `test.concurrent()`. +- `tests/ipcMain` is slow; filter with `-t` when possible. Tests use `test.concurrent()`. - Never bypass IPC: do not call `env.config.saveConfig`, `env.historyService`, etc., directly. Use `env.mockIpcRenderer.invoke(IPC_CHANNELS.CONFIG_SAVE|HISTORY_GET|WORKSPACE_CREATE, ...)` instead. - Acceptable exceptions: reading config to craft IPC args, verifying filesystem after IPC completes, or loading existing data to avoid redundant API calls. diff --git a/docs/theme/copy-buttons.js b/docs/theme/copy-buttons.js index 35bbc87ce..12b5f7867 100644 --- a/docs/theme/copy-buttons.js +++ b/docs/theme/copy-buttons.js @@ -3,32 +3,29 @@ * Attaches click handlers to pre-rendered buttons */ -(function () { - "use strict"; +(function() { + 'use strict'; // Initialize copy buttons after DOM loads - if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", initCopyButtons); + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initCopyButtons); } else { initCopyButtons(); } function initCopyButtons() { - document.querySelectorAll(".code-copy-button").forEach(function (button) { - button.addEventListener("click", function () { - var wrapper = button.closest(".code-block-wrapper"); + document.querySelectorAll('.code-copy-button').forEach(function(button) { + button.addEventListener('click', function() { + var wrapper = button.closest('.code-block-wrapper'); var code = wrapper.dataset.code; - + if (navigator.clipboard && navigator.clipboard.writeText) { - navigator.clipboard - .writeText(code) - .then(function () { - showFeedback(button, true); - }) - .catch(function (err) { - console.warn("Failed to copy:", err); - showFeedback(button, false); - }); + navigator.clipboard.writeText(code).then(function() { + showFeedback(button, true); + }).catch(function(err) { + console.warn('Failed to copy:', err); + showFeedback(button, false); + }); } else { // Fallback for older browsers fallbackCopy(code); @@ -40,7 +37,7 @@ function showFeedback(button, success) { var originalContent = button.innerHTML; - + // Match the main app's CopyButton feedback - show "Copied!" text if (success) { button.innerHTML = 'Copied!'; @@ -48,21 +45,21 @@ button.innerHTML = 'Failed!'; } button.disabled = true; - - setTimeout(function () { + + setTimeout(function() { button.innerHTML = originalContent; button.disabled = false; }, 2000); } function fallbackCopy(text) { - var textarea = document.createElement("textarea"); + var textarea = document.createElement('textarea'); textarea.value = text; - textarea.style.position = "fixed"; - textarea.style.opacity = "0"; + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; document.body.appendChild(textarea); textarea.select(); - document.execCommand("copy"); + document.execCommand('copy'); document.body.removeChild(textarea); } })(); diff --git a/docs/theme/custom.css b/docs/theme/custom.css index dfe4ff72c..4280c1dd8 100644 --- a/docs/theme/custom.css +++ b/docs/theme/custom.css @@ -511,8 +511,9 @@ details[open] > summary::before { background-repeat: no-repeat; } + /* Page TOC (Table of Contents) overrides */ -@media only screen and (min-width: 1440px) { +@media only screen and (min-width:1440px) { .pagetoc a { /* Reduce vertical spacing for more compact TOC */ padding-top: 2px !important; @@ -546,6 +547,10 @@ details[open] > summary::before { } } + + + + /* Code block wrapper with line numbers and copy button (from mux app) */ .code-block-wrapper { position: relative; diff --git a/eslint.config.mjs b/eslint.config.mjs index 5eb19775c..74915cdd7 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -117,9 +117,12 @@ const localPlugin = { "browser/ cannot import from node/. Move shared code to common/ or use IPC.", nodeToDesktop: "node/ cannot import from desktop/. Move shared code to common/ or use dependency injection.", - nodeToCli: "node/ cannot import from cli/. Move shared code to common/.", - cliToBrowser: "cli/ cannot import from browser/. Move shared code to common/.", - desktopToBrowser: "desktop/ cannot import from browser/. Move shared code to common/.", + nodeToCli: + "node/ cannot import from cli/. Move shared code to common/.", + cliToBrowser: + "cli/ cannot import from browser/. Move shared code to common/.", + desktopToBrowser: + "desktop/ cannot import from browser/. Move shared code to common/.", }, }, create(context) { @@ -134,9 +137,7 @@ const localPlugin = { const importPath = node.source.value; // Extract folder from source file (browser, node, desktop, cli, common) - const sourceFolderMatch = sourceFile.match( - /\/src\/(browser|node|desktop|cli|common)\// - ); + const sourceFolderMatch = sourceFile.match(/\/src\/(browser|node|desktop|cli|common)\//); if (!sourceFolderMatch) return; const sourceFolder = sourceFolderMatch[1]; @@ -459,12 +460,7 @@ export default defineConfig([ // - Some utils are shared between main/renderer (e.g., utils/tools registry) // - Stores can import from utils/messages which is renderer-safe // - Type-only imports from services are safe (types live in src/common/types/) - files: [ - "src/browser/components/**", - "src/browser/contexts/**", - "src/browser/hooks/**", - "src/browser/App.tsx", - ], + files: ["src/browser/components/**", "src/browser/contexts/**", "src/browser/hooks/**", "src/browser/App.tsx"], rules: { "no-restricted-imports": [ "error", diff --git a/index.html b/index.html index 4a8e65c2e..464c26382 100644 --- a/index.html +++ b/index.html @@ -32,8 +32,7 @@ const prefersLight = window.matchMedia ? window.matchMedia("(prefers-color-scheme: light)").matches : false; - const theme = - parsed === "light" || parsed === "dark" ? parsed : prefersLight ? "light" : "dark"; + const theme = parsed === "light" || parsed === "dark" ? parsed : prefersLight ? "light" : "dark"; document.documentElement.dataset.theme = theme; document.documentElement.style.colorScheme = theme; diff --git a/jest.config.js b/jest.config.js index d6ea97b24..8649d8a52 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,4 +1,5 @@ module.exports = { + preset: "ts-jest", testEnvironment: "node", testMatch: ["/src/**/*.test.ts", "/tests/**/*.test.ts"], collectCoverageFrom: [ @@ -16,10 +17,22 @@ module.exports = { "^jsdom$": "/tests/__mocks__/jsdom.js", }, transform: { - "^.+\\.(ts|tsx|js|mjs)$": ["babel-jest"], + "^.+\\.tsx?$": [ + "ts-jest", + { + tsconfig: { + target: "ES2020", + module: "ESNext", + moduleResolution: "node", + lib: ["ES2023", "DOM", "ES2022.Intl"], + esModuleInterop: true, + allowSyntheticDefaultImports: true, + }, + }, + ], }, - // Transform ESM modules (like shiki, @orpc) to CommonJS for Jest - transformIgnorePatterns: ["node_modules/(?!(@orpc|shiki)/)"], + // Transform ESM modules (like shiki) to CommonJS for Jest + transformIgnorePatterns: ["node_modules/(?!(shiki)/)"], // Run tests in parallel (use 50% of available cores, or 4 minimum) maxWorkers: "50%", // Force exit after tests complete to avoid hanging on lingering handles diff --git a/mobile/README.md b/mobile/README.md index 74674a375..673a17a01 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -1,6 +1,6 @@ # mux Mobile App -Expo React Native app for mux - connects to mux server via ORPC over HTTP with SSE streaming. +Expo React Native app for mux - connects to mux server over HTTP/WebSocket. ## Requirements diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index 0f6fd906f..ea7ae8873 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -8,7 +8,6 @@ import { View } from "react-native"; import { ThemeProvider, useTheme } from "../src/theme"; import { WorkspaceChatProvider } from "../src/contexts/WorkspaceChatContext"; import { AppConfigProvider } from "../src/contexts/AppConfigContext"; -import { ORPCProvider } from "../src/orpc/react"; function AppFrame(): JSX.Element { const theme = useTheme(); @@ -75,11 +74,9 @@ export default function RootLayout(): JSX.Element { - - - - - + + + diff --git a/mobile/bun.lock b/mobile/bun.lock index 8e38f2514..40dffc2fa 100644 --- a/mobile/bun.lock +++ b/mobile/bun.lock @@ -5,7 +5,6 @@ "name": "@coder/mux-mobile", "dependencies": { "@gorhom/bottom-sheet": "^5.2.6", - "@orpc/client": "^1.11.3", "@react-native-async-storage/async-storage": "2.2.0", "@react-native-community/slider": "5.0.1", "@react-native-picker/picker": "2.11.1", @@ -318,16 +317,6 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - "@orpc/client": ["@orpc/client@1.11.3", "", { "dependencies": { "@orpc/shared": "1.11.3", "@orpc/standard-server": "1.11.3", "@orpc/standard-server-fetch": "1.11.3", "@orpc/standard-server-peer": "1.11.3" } }, "sha512-USuUOvG07odUzrn3/xGE0V+JbK6DV+eYqURa98kMelSoGRLP0ceqomu49s1+paKYgT1fefRDMaCKxo04hgRNhg=="], - - "@orpc/shared": ["@orpc/shared@1.11.3", "", { "dependencies": { "radash": "^12.1.1", "type-fest": "^5.2.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0" }, "optionalPeers": ["@opentelemetry/api"] }, "sha512-hOPZhNI0oIhw91NNu4ndrmpWLdZyXTGx7tzq/bG5LwtuHuUsl4FalRsUfSIuap/V1ESOnPqSzmmSOdRv+ITcRA=="], - - "@orpc/standard-server": ["@orpc/standard-server@1.11.3", "", { "dependencies": { "@orpc/shared": "1.11.3" } }, "sha512-j61f0TqITURN+5zft3vDjuyHjwTkusx91KrTGxfZ3E6B/dP2SLtoPCvTF8aecozxb5KvyhvAvbuDQMPeyqXvDg=="], - - "@orpc/standard-server-fetch": ["@orpc/standard-server-fetch@1.11.3", "", { "dependencies": { "@orpc/shared": "1.11.3", "@orpc/standard-server": "1.11.3" } }, "sha512-wiudo8W/NHaosygIpU/NJGZVBTueSHSRU4y0pIwvAhA0f9ZQ9/aCwnYxR7lnvCizzb2off8kxxKKqkS3xYRepA=="], - - "@orpc/standard-server-peer": ["@orpc/standard-server-peer@1.11.3", "", { "dependencies": { "@orpc/shared": "1.11.3", "@orpc/standard-server": "1.11.3" } }, "sha512-GkINRYjWRTOKQIsPWvqCvbjNjaLnhDAVJLrQNGTaqy7yLTDG8ome7hCrmH3bdjDY4nDlt8OoUaq9oABE/1rMew=="], - "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], @@ -1064,8 +1053,6 @@ "queue": ["queue@6.0.2", "", { "dependencies": { "inherits": "~2.0.3" } }, "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA=="], - "radash": ["radash@12.1.1", "", {}, "sha512-h36JMxKRqrAxVD8201FrCpyeNuUY9Y5zZwujr20fFO77tpUtGa6EZzfKw/3WaiBX95fq7+MpsuMLNdSnORAwSA=="], - "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], @@ -1230,8 +1217,6 @@ "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], - "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], - "tar": ["tar@7.5.2", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg=="], "temp-dir": ["temp-dir@2.0.0", "", {}, "sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg=="], @@ -1262,7 +1247,7 @@ "type-detect": ["type-detect@4.0.8", "", {}, "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g=="], - "type-fest": ["type-fest@5.2.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-xxCJm+Bckc6kQBknN7i9fnP/xobQRsRQxR01CztFkp/h++yfVxUUcmMgfR2HttJx/dpWjS9ubVuyspJv24Q9DA=="], + "type-fest": ["type-fest@0.7.1", "", {}, "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], @@ -1556,8 +1541,6 @@ "stack-utils/escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], - "stacktrace-parser/type-fest": ["type-fest@0.7.1", "", {}, "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg=="], - "string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], diff --git a/mobile/package.json b/mobile/package.json index 3ae69f1db..47f19e7c6 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -10,7 +10,6 @@ "ios": "expo run:ios" }, "dependencies": { - "@orpc/client": "^1.11.3", "@gorhom/bottom-sheet": "^5.2.6", "@react-native-async-storage/async-storage": "2.2.0", "@react-native-community/slider": "5.0.1", diff --git a/mobile/src/api/client.ts b/mobile/src/api/client.ts new file mode 100644 index 000000000..80c72197a --- /dev/null +++ b/mobile/src/api/client.ts @@ -0,0 +1,632 @@ +import Constants from "expo-constants"; +import { assert } from "../utils/assert"; +import { assertKnownModelId } from "../utils/modelCatalog"; +import type { ChatStats } from "@/common/types/chatStats.ts"; +import type { MuxMessage } from "@/common/types/message.ts"; +import type { + FrontendWorkspaceMetadata, + ProjectsListResponse, + WorkspaceChatEvent, + Secret, + WorkspaceActivitySnapshot, +} from "../types"; + +export type Result = { success: true; data: T } | { success: false; error: E }; + +export interface SendMessageOptions { + model: string; + editMessageId?: string; // When provided, truncates history after this message + [key: string]: unknown; +} + +export interface MuxMobileClientConfig { + baseUrl?: string; + authToken?: string; +} + +const IPC_CHANNELS = { + PROVIDERS_SET_CONFIG: "providers:setConfig", + PROVIDERS_LIST: "providers:list", + WORKSPACE_LIST: "workspace:list", + WORKSPACE_CREATE: "workspace:create", + WORKSPACE_REMOVE: "workspace:remove", + WORKSPACE_RENAME: "workspace:rename", + WORKSPACE_FORK: "workspace:fork", + WORKSPACE_SEND_MESSAGE: "workspace:sendMessage", + WORKSPACE_INTERRUPT_STREAM: "workspace:interruptStream", + WORKSPACE_TRUNCATE_HISTORY: "workspace:truncateHistory", + WORKSPACE_GET_INFO: "workspace:getInfo", + WORKSPACE_EXECUTE_BASH: "workspace:executeBash", + WORKSPACE_CHAT_PREFIX: "workspace:chat:", + WORKSPACE_CHAT_SUBSCRIBE: "workspace:chat", + WORKSPACE_CHAT_GET_HISTORY: "workspace:chat:getHistory", + WORKSPACE_CHAT_GET_FULL_REPLAY: "workspace:chat:getFullReplay", + PROJECT_LIST: "project:list", + PROJECT_LIST_BRANCHES: "project:listBranches", + PROJECT_SECRETS_GET: "project:secrets:get", + WORKSPACE_ACTIVITY: "workspace:activity", + WORKSPACE_ACTIVITY_SUBSCRIBE: "workspace:activity", + WORKSPACE_ACTIVITY_ACK: "workspace:activity:subscribe", + WORKSPACE_ACTIVITY_LIST: "workspace:activity:list", + PROJECT_SECRETS_UPDATE: "project:secrets:update", + WORKSPACE_METADATA: "workspace:metadata", + WORKSPACE_METADATA_SUBSCRIBE: "workspace:metadata", + WORKSPACE_METADATA_ACK: "workspace:metadata:subscribe", + TOKENIZER_CALCULATE_STATS: "tokenizer:calculateStats", + TOKENIZER_COUNT_TOKENS: "tokenizer:countTokens", + TOKENIZER_COUNT_TOKENS_BATCH: "tokenizer:countTokensBatch", +} as const; + +type InvokeResponse = { success: true; data: T } | { success: false; error: string }; + +type WebSocketSubscription = { ws: WebSocket; close: () => void }; + +type JsonRecord = Record; + +function readAppExtra(): JsonRecord | undefined { + const extra = Constants.expoConfig?.extra as JsonRecord | undefined; + const candidate = extra?.mux; + return isJsonRecord(candidate) ? candidate : undefined; +} + +function pickBaseUrl(): string { + const extra = readAppExtra(); + const configured = typeof extra?.baseUrl === "string" ? extra.baseUrl : undefined; + const normalized = (configured ?? "http://localhost:3000").replace(/\/$/, ""); + assert(normalized.length > 0, "baseUrl must not be empty"); + return normalized; +} + +function pickToken(): string | undefined { + const extra = readAppExtra(); + const rawToken = typeof extra?.authToken === "string" ? extra.authToken : undefined; + if (!rawToken) { + return undefined; + } + const trimmed = rawToken.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function isJsonRecord(value: unknown): value is JsonRecord { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function parseWorkspaceActivity(value: unknown): WorkspaceActivitySnapshot | null { + if (!isJsonRecord(value)) { + return null; + } + const recency = + typeof value.recency === "number" && Number.isFinite(value.recency) ? value.recency : null; + if (recency === null) { + return null; + } + const streaming = value.streaming === true; + const lastModel = typeof value.lastModel === "string" ? value.lastModel : null; + return { + recency, + streaming, + lastModel, + }; +} + +function ensureWorkspaceId(id: string): string { + assert(typeof id === "string", "workspaceId must be a string"); + const trimmed = id.trim(); + assert(trimmed.length > 0, "workspaceId must not be empty"); + return trimmed; +} + +export function createClient(cfg: MuxMobileClientConfig = {}) { + const baseUrl = (cfg.baseUrl ?? pickBaseUrl()).replace(/\/$/, ""); + const authToken = cfg.authToken ?? pickToken(); + + async function invoke(channel: string, args: unknown[] = []): Promise { + const response = await fetch(`${baseUrl}/ipc/${encodeURIComponent(channel)}`, { + method: "POST", + headers: { + "content-type": "application/json", + ...(authToken ? { Authorization: `Bearer ${authToken}` } : {}), + }, + body: JSON.stringify({ args }), + }); + + const payload = (await response.json()) as InvokeResponse | undefined; + if (!payload || typeof payload !== "object") { + throw new Error(`Unexpected response for channel ${channel}`); + } + + if (payload.success) { + return payload.data as T; + } + + const message = typeof payload.error === "string" ? payload.error : "Request failed"; + throw new Error(message); + } + + function makeWebSocketUrl(): string { + const url = new URL(baseUrl); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + url.pathname = "/ws"; + if (authToken) { + url.searchParams.set("token", authToken); + } + return url.toString(); + } + + function subscribe( + payload: JsonRecord, + handleMessage: (data: JsonRecord) => void + ): WebSocketSubscription { + const ws = new WebSocket(makeWebSocketUrl()); + + ws.onopen = () => { + ws.send(JSON.stringify(payload)); + }; + + ws.onmessage = (event) => { + try { + const data = JSON.parse(String(event.data)); + if (isJsonRecord(data)) { + handleMessage(data); + } + } catch (error) { + if (process.env.NODE_ENV !== "production") { + console.warn("Failed to parse WebSocket message", error); + } + } + }; + + return { + ws, + close: () => { + try { + ws.close(); + } catch { + // noop + } + }, + }; + } + + return { + providers: { + list: async (): Promise => invoke(IPC_CHANNELS.PROVIDERS_LIST), + setProviderConfig: async ( + provider: string, + keyPath: string[], + value: string + ): Promise> => { + try { + assert(typeof provider === "string" && provider.trim().length > 0, "provider required"); + assert(Array.isArray(keyPath) && keyPath.length > 0, "keyPath required"); + keyPath.forEach((segment, index) => { + assert( + typeof segment === "string" && segment.trim().length > 0, + `keyPath segment ${index} must be a non-empty string` + ); + }); + assert(typeof value === "string", "value must be a string"); + + const normalizedProvider = provider.trim(); + const normalizedPath = keyPath.map((segment) => segment.trim()); + await invoke(IPC_CHANNELS.PROVIDERS_SET_CONFIG, [ + normalizedProvider, + normalizedPath, + value, + ]); + return { success: true, data: undefined }; + } catch (error) { + const err = error instanceof Error ? error.message : String(error); + return { success: false, error: err }; + } + }, + }, + projects: { + list: async (): Promise => invoke(IPC_CHANNELS.PROJECT_LIST), + listBranches: async ( + projectPath: string + ): Promise<{ branches: string[]; recommendedTrunk: string }> => + invoke(IPC_CHANNELS.PROJECT_LIST_BRANCHES, [projectPath]), + secrets: { + get: async (projectPath: string): Promise => + invoke(IPC_CHANNELS.PROJECT_SECRETS_GET, [projectPath]), + update: async (projectPath: string, secrets: Secret[]): Promise> => { + try { + await invoke(IPC_CHANNELS.PROJECT_SECRETS_UPDATE, [projectPath, secrets]); + return { success: true, data: undefined }; + } catch (error) { + const err = error instanceof Error ? error.message : String(error); + return { success: false, error: err }; + } + }, + }, + }, + workspace: { + list: async (): Promise => invoke(IPC_CHANNELS.WORKSPACE_LIST), + create: async ( + projectPath: string, + branchName: string, + trunkBranch: string, + runtimeConfig?: Record + ): Promise< + { success: true; metadata: FrontendWorkspaceMetadata } | { success: false; error: string } + > => { + try { + const result = await invoke<{ success: true; metadata: FrontendWorkspaceMetadata }>( + IPC_CHANNELS.WORKSPACE_CREATE, + [projectPath, branchName, trunkBranch, runtimeConfig] + ); + return result; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + }, + getInfo: async (workspaceId: string): Promise => + invoke(IPC_CHANNELS.WORKSPACE_GET_INFO, [ensureWorkspaceId(workspaceId)]), + getHistory: async (workspaceId: string): Promise => + invoke(IPC_CHANNELS.WORKSPACE_CHAT_GET_HISTORY, [ensureWorkspaceId(workspaceId)]), + getFullReplay: async (workspaceId: string): Promise => + invoke(IPC_CHANNELS.WORKSPACE_CHAT_GET_FULL_REPLAY, [ensureWorkspaceId(workspaceId)]), + remove: async ( + workspaceId: string, + options?: { force?: boolean } + ): Promise> => { + try { + await invoke(IPC_CHANNELS.WORKSPACE_REMOVE, [ensureWorkspaceId(workspaceId), options]); + return { success: true, data: undefined }; + } catch (error) { + const err = error instanceof Error ? error.message : String(error); + return { success: false, error: err }; + } + }, + fork: async ( + workspaceId: string, + newName: string + ): Promise< + | { success: true; metadata: FrontendWorkspaceMetadata; projectPath: string } + | { success: false; error: string } + > => { + try { + assert(typeof newName === "string" && newName.trim().length > 0, "newName required"); + return await invoke(IPC_CHANNELS.WORKSPACE_FORK, [ + ensureWorkspaceId(workspaceId), + newName.trim(), + ]); + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + }, + rename: async ( + workspaceId: string, + newName: string + ): Promise> => { + try { + assert(typeof newName === "string" && newName.trim().length > 0, "newName required"); + const result = await invoke<{ newWorkspaceId: string }>(IPC_CHANNELS.WORKSPACE_RENAME, [ + ensureWorkspaceId(workspaceId), + newName.trim(), + ]); + return { success: true, data: result }; + } catch (error) { + const err = error instanceof Error ? error.message : String(error); + return { success: false, error: err }; + } + }, + interruptStream: async (workspaceId: string): Promise> => { + try { + await invoke(IPC_CHANNELS.WORKSPACE_INTERRUPT_STREAM, [ensureWorkspaceId(workspaceId)]); + return { success: true, data: undefined }; + } catch (error) { + const err = error instanceof Error ? error.message : String(error); + return { success: false, error: err }; + } + }, + truncateHistory: async ( + workspaceId: string, + percentage = 1.0 + ): Promise> => { + try { + assert( + typeof percentage === "number" && Number.isFinite(percentage), + "percentage must be a number" + ); + await invoke(IPC_CHANNELS.WORKSPACE_TRUNCATE_HISTORY, [ + ensureWorkspaceId(workspaceId), + percentage, + ]); + return { success: true, data: undefined }; + } catch (error) { + const err = error instanceof Error ? error.message : String(error); + return { success: false, error: err }; + } + }, + replaceChatHistory: async ( + workspaceId: string, + summaryMessage: { + id: string; + role: "assistant"; + parts: Array<{ type: "text"; text: string; state: "done" }>; + metadata: { + timestamp: number; + compacted: true; + }; + } + ): Promise> => { + try { + await invoke("workspace:replaceHistory", [ + ensureWorkspaceId(workspaceId), + summaryMessage, + ]); + return { success: true, data: undefined }; + } catch (error) { + const err = error instanceof Error ? error.message : String(error); + return { success: false, error: err }; + } + }, + sendMessage: async ( + workspaceId: string | null, + message: string, + options: SendMessageOptions & { + projectPath?: string; + trunkBranch?: string; + runtimeConfig?: Record; + } + ): Promise< + | Result + | { success: true; workspaceId: string; metadata: FrontendWorkspaceMetadata } + > => { + try { + assertKnownModelId(options.model); + assert(typeof message === "string" && message.trim().length > 0, "message required"); + + // If workspaceId is null, we're creating a new workspace + // In this case, we need to wait for the response to get the metadata + if (workspaceId === null) { + if (!options.projectPath) { + return { success: false, error: "projectPath is required when workspaceId is null" }; + } + + const result = await invoke< + | { success: true; workspaceId: string; metadata: FrontendWorkspaceMetadata } + | { success: false; error: string } + >(IPC_CHANNELS.WORKSPACE_SEND_MESSAGE, [null, message, options]); + + if (!result.success) { + return result; + } + + return result; + } + + // Normal path: workspace exists, fire and forget + // The stream-start event will arrive via WebSocket if successful + // Errors will come via stream-error WebSocket events, not HTTP response + void invoke(IPC_CHANNELS.WORKSPACE_SEND_MESSAGE, [ + ensureWorkspaceId(workspaceId), + message, + options, + ]).catch(() => { + // Silently ignore HTTP errors - stream-error events handle actual failures + // The server may return before stream completes, causing spurious errors + }); + + // Immediately return success - actual errors will come via stream-error events + return { success: true, data: undefined }; + } catch (error) { + const err = error instanceof Error ? error.message : String(error); + console.error("[sendMessage] Validation error:", err); + return { success: false, error: err }; + } + }, + executeBash: async ( + workspaceId: string, + command: string, + options?: { timeout_secs?: number; niceness?: number } + ): Promise< + Result< + | { success: true; output: string; truncated?: { reason: string } } + | { success: false; error: string } + > + > => { + try { + // Validate inputs before calling trim() + if (typeof workspaceId !== "string" || !workspaceId) { + return { success: false, error: "workspaceId is required" }; + } + if (typeof command !== "string" || !command) { + return { success: false, error: "command is required" }; + } + + const trimmedId = workspaceId.trim(); + const trimmedCommand = command.trim(); + + if (trimmedId.length === 0) { + return { success: false, error: "workspaceId must not be empty" }; + } + if (trimmedCommand.length === 0) { + return { success: false, error: "command must not be empty" }; + } + + const result = await invoke< + | { success: true; output: string; truncated?: { reason: string } } + | { success: false; error: string } + >(IPC_CHANNELS.WORKSPACE_EXECUTE_BASH, [trimmedId, trimmedCommand, options ?? {}]); + + return { success: true, data: result }; + } catch (error) { + const err = error instanceof Error ? error.message : String(error); + return { success: false, error: err }; + } + }, + subscribeChat: ( + workspaceId: string, + onEvent: (event: WorkspaceChatEvent) => void + ): WebSocketSubscription => { + const trimmedId = ensureWorkspaceId(workspaceId); + const subscription = subscribe( + { + type: "subscribe", + channel: IPC_CHANNELS.WORKSPACE_CHAT_SUBSCRIBE, + workspaceId: trimmedId, + }, + (data) => { + const channel = typeof data.channel === "string" ? data.channel : undefined; + const args = Array.isArray(data.args) ? data.args : []; + + if (!channel || !channel.startsWith(IPC_CHANNELS.WORKSPACE_CHAT_PREFIX)) { + return; + } + + const channelWorkspaceId = channel.replace(IPC_CHANNELS.WORKSPACE_CHAT_PREFIX, ""); + if (channelWorkspaceId !== trimmedId) { + return; + } + + const [firstArg] = args; + if (firstArg) { + onEvent(firstArg as WorkspaceChatEvent); + } + } + ); + + return subscription; + }, + subscribeMetadata: ( + onMetadata: (payload: { + workspaceId: string; + metadata: FrontendWorkspaceMetadata | null; + }) => void + ): WebSocketSubscription => + subscribe( + { type: "subscribe", channel: IPC_CHANNELS.WORKSPACE_METADATA_SUBSCRIBE }, + (data) => { + if (data.channel !== IPC_CHANNELS.WORKSPACE_METADATA) { + return; + } + const args = Array.isArray(data.args) ? data.args : []; + const [firstArg] = args; + if (!isJsonRecord(firstArg)) { + return; + } + const workspaceId = + typeof firstArg.workspaceId === "string" ? firstArg.workspaceId : null; + if (!workspaceId) { + return; + } + + // Handle deletion event (metadata is null) + if (firstArg.metadata === null) { + onMetadata({ workspaceId, metadata: null }); + return; + } + + const metadataRaw = isJsonRecord(firstArg.metadata) ? firstArg.metadata : null; + if (!metadataRaw) { + return; + } + const metadata: FrontendWorkspaceMetadata = { + id: typeof metadataRaw.id === "string" ? metadataRaw.id : workspaceId, + name: typeof metadataRaw.name === "string" ? metadataRaw.name : workspaceId, + projectName: + typeof metadataRaw.projectName === "string" ? metadataRaw.projectName : "", + projectPath: + typeof metadataRaw.projectPath === "string" ? metadataRaw.projectPath : "", + namedWorkspacePath: + typeof metadataRaw.namedWorkspacePath === "string" + ? metadataRaw.namedWorkspacePath + : typeof metadataRaw.workspacePath === "string" + ? metadataRaw.workspacePath + : "", + createdAt: + typeof metadataRaw.createdAt === "string" ? metadataRaw.createdAt : undefined, + runtimeConfig: isJsonRecord(metadataRaw.runtimeConfig) + ? (metadataRaw.runtimeConfig as Record) + : undefined, + }; + + if ( + metadata.projectName.length === 0 || + metadata.projectPath.length === 0 || + metadata.namedWorkspacePath.length === 0 + ) { + return; + } + + onMetadata({ workspaceId, metadata }); + } + ), + activity: { + list: async (): Promise> => { + const response = await invoke>( + IPC_CHANNELS.WORKSPACE_ACTIVITY_LIST + ); + const result: Record = {}; + if (response && typeof response === "object") { + for (const [workspaceId, value] of Object.entries(response)) { + if (typeof workspaceId !== "string") { + continue; + } + const parsed = parseWorkspaceActivity(value); + if (parsed) { + result[workspaceId] = parsed; + } + } + } + return result; + }, + subscribe: ( + onActivity: (payload: { + workspaceId: string; + activity: WorkspaceActivitySnapshot | null; + }) => void + ): WebSocketSubscription => + subscribe( + { type: "subscribe", channel: IPC_CHANNELS.WORKSPACE_ACTIVITY_SUBSCRIBE }, + (data) => { + if (data.channel !== IPC_CHANNELS.WORKSPACE_ACTIVITY) { + return; + } + const args = Array.isArray(data.args) ? data.args : []; + const [firstArg] = args; + if (!isJsonRecord(firstArg)) { + return; + } + const workspaceId = + typeof firstArg.workspaceId === "string" ? firstArg.workspaceId : null; + if (!workspaceId) { + return; + } + + if (firstArg.activity === null) { + onActivity({ workspaceId, activity: null }); + return; + } + + const activity = parseWorkspaceActivity(firstArg.activity); + if (!activity) { + return; + } + + onActivity({ workspaceId, activity }); + } + ), + }, + }, + tokenizer: { + calculateStats: async (messages: MuxMessage[], model: string): Promise => + invoke(IPC_CHANNELS.TOKENIZER_CALCULATE_STATS, [messages, model]), + countTokens: async (model: string, text: string): Promise => + invoke(IPC_CHANNELS.TOKENIZER_COUNT_TOKENS, [model, text]), + countTokensBatch: async (model: string, texts: string[]): Promise => + invoke(IPC_CHANNELS.TOKENIZER_COUNT_TOKENS_BATCH, [model, texts]), + }, + } as const; +} + +export type MuxMobileClient = ReturnType; diff --git a/mobile/src/contexts/WorkspaceCostContext.tsx b/mobile/src/contexts/WorkspaceCostContext.tsx index 80e712148..0c50eb075 100644 --- a/mobile/src/contexts/WorkspaceCostContext.tsx +++ b/mobile/src/contexts/WorkspaceCostContext.tsx @@ -14,12 +14,12 @@ import { sumUsageHistory } from "@/common/utils/tokens/usageAggregator"; import { createDisplayUsage } from "@/common/utils/tokens/displayUsage"; import type { ChatStats } from "@/common/types/chatStats.ts"; import type { MuxMessage } from "@/common/types/message.ts"; -import type { WorkspaceChatMessage } from "@/common/orpc/types"; -import { isMuxMessage, isStreamEnd } from "@/common/orpc/types"; +import type { WorkspaceChatMessage } from "@/common/types/ipc"; +import { isMuxMessage, isStreamEnd } from "@/common/types/ipc"; import type { StreamEndEvent, StreamAbortEvent } from "@/common/types/stream.ts"; import type { WorkspaceChatEvent } from "../types"; -import { useORPC } from "../orpc/react"; +import { useApiClient } from "../hooks/useApiClient"; interface UsageEntry { messageId: string; @@ -122,10 +122,10 @@ function sortEntries(entries: Iterable): ChatUsageDisplay[] { .map((entry) => entry.usage); } -function extractMessagesFromReplay(events: WorkspaceChatMessage[]): MuxMessage[] { +function extractMessagesFromReplay(events: WorkspaceChatEvent[]): MuxMessage[] { const messages: MuxMessage[] = []; for (const event of events) { - if (isMuxMessage(event)) { + if (isMuxMessage(event as unknown as WorkspaceChatMessage)) { messages.push(event as unknown as MuxMessage); } } @@ -149,7 +149,7 @@ export function WorkspaceCostProvider({ workspaceId?: string | null; children: ReactNode; }): JSX.Element { - const client = useORPC(); + const api = useApiClient(); const usageMapRef = useRef>(new Map()); const [usageHistory, setUsageHistory] = useState([]); const [isInitialized, setIsInitialized] = useState(false); @@ -173,7 +173,7 @@ export function WorkspaceCostProvider({ void (async () => { try { - const events = await client.workspace.getFullReplay({ workspaceId: workspaceId! }); + const events = await api.workspace.getFullReplay(workspaceId!); if (isCancelled) { return; } @@ -221,7 +221,7 @@ export function WorkspaceCostProvider({ return () => { isCancelled = true; }; - }, [client, workspaceId, isCreationMode]); + }, [api, workspaceId, isCreationMode]); const registerUsage = useCallback((entry: UsageEntry | null) => { if (!entry) { @@ -276,7 +276,7 @@ export function WorkspaceCostProvider({ }); try { - const events = await client.workspace.getFullReplay({ workspaceId: workspaceId! }); + const events = await api.workspace.getFullReplay(workspaceId!); const messages = extractMessagesFromReplay(events); if (messages.length === 0) { setConsumers({ @@ -293,13 +293,13 @@ export function WorkspaceCostProvider({ } const model = getLastModel(messages) ?? "unknown"; - const stats = await client.tokenizer.calculateStats({ messages, model }); + const stats = await api.tokenizer.calculateStats(messages, model); setConsumers({ status: "ready", stats }); } catch (error) { const message = error instanceof Error ? error.message : String(error); setConsumers({ status: "error", error: message }); } - }, [client, workspaceId, isCreationMode]); + }, [api, workspaceId, isCreationMode]); const lastUsage = usageHistory.length > 0 ? usageHistory[usageHistory.length - 1] : undefined; const sessionUsage = useMemo(() => sumUsageHistory(usageHistory), [usageHistory]); diff --git a/mobile/src/hooks/useApiClient.ts b/mobile/src/hooks/useApiClient.ts new file mode 100644 index 000000000..c9d0d789e --- /dev/null +++ b/mobile/src/hooks/useApiClient.ts @@ -0,0 +1,16 @@ +import { useMemo } from "react"; +import { createClient, type MuxMobileClientConfig } from "../api/client"; +import { useAppConfig } from "../contexts/AppConfigContext"; + +export function useApiClient(config?: MuxMobileClientConfig) { + const appConfig = useAppConfig(); + const mergedConfig = useMemo( + () => ({ + baseUrl: config?.baseUrl ?? appConfig.resolvedBaseUrl, + authToken: config?.authToken ?? appConfig.resolvedAuthToken, + }), + [appConfig.resolvedAuthToken, appConfig.resolvedBaseUrl, config?.authToken, config?.baseUrl] + ); + + return useMemo(() => createClient(mergedConfig), [mergedConfig.authToken, mergedConfig.baseUrl]); +} diff --git a/mobile/src/hooks/useProjectsData.ts b/mobile/src/hooks/useProjectsData.ts index b01f541e8..b94ad2f0c 100644 --- a/mobile/src/hooks/useProjectsData.ts +++ b/mobile/src/hooks/useProjectsData.ts @@ -1,6 +1,6 @@ import { useEffect } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useORPC } from "../orpc/react"; +import { useApiClient } from "./useApiClient"; import type { FrontendWorkspaceMetadata, WorkspaceActivitySnapshot } from "../types"; const WORKSPACES_QUERY_KEY = ["workspaces"] as const; @@ -8,119 +8,82 @@ const WORKSPACE_ACTIVITY_QUERY_KEY = ["workspace-activity"] as const; const PROJECTS_QUERY_KEY = ["projects"] as const; export function useProjectsData() { - const client = useORPC(); + const api = useApiClient(); const queryClient = useQueryClient(); const projectsQuery = useQuery({ queryKey: PROJECTS_QUERY_KEY, - queryFn: () => client.projects.list(), + queryFn: () => api.projects.list(), staleTime: 60_000, }); const workspacesQuery = useQuery({ queryKey: WORKSPACES_QUERY_KEY, - queryFn: () => client.workspace.list(), + queryFn: () => api.workspace.list(), staleTime: 15_000, }); - const activityQuery = useQuery({ queryKey: WORKSPACE_ACTIVITY_QUERY_KEY, - queryFn: () => client.workspace.activity.list(), + queryFn: () => api.workspace.activity.list(), staleTime: 15_000, }); - // Subscribe to workspace metadata changes via SSE useEffect(() => { - const controller = new AbortController(); - - (async () => { - try { - const iterator = await client.workspace.onMetadata(undefined, { - signal: controller.signal, - }); - for await (const event of iterator) { - if (controller.signal.aborted) break; - - const { workspaceId, metadata } = event; - queryClient.setQueryData( - WORKSPACES_QUERY_KEY, - (existing) => { - if (!existing || existing.length === 0) { - return existing; - } - - if (metadata === null) { - return existing.filter((w) => w.id !== workspaceId); - } - - const index = existing.findIndex((workspace) => workspace.id === workspaceId); - if (index === -1) { - return [...existing, metadata as FrontendWorkspaceMetadata]; - } - - const next = existing.slice(); - next[index] = { ...next[index], ...metadata }; - return next; - } - ); - } - } catch (error) { - // Stream ended or aborted - this is expected on cleanup - if (!controller.signal.aborted && process.env.NODE_ENV !== "production") { - console.warn("[useProjectsData] Metadata stream error:", error); + const subscription = api.workspace.subscribeMetadata(({ workspaceId, metadata }) => { + queryClient.setQueryData( + WORKSPACES_QUERY_KEY, + (existing) => { + if (!existing || existing.length === 0) { + return existing; + } + + if (metadata === null) { + return existing.filter((w) => w.id !== workspaceId); + } + + const index = existing.findIndex((workspace) => workspace.id === workspaceId); + if (index === -1) { + return [...existing, metadata]; + } + + const next = existing.slice(); + next[index] = { ...next[index], ...metadata }; + return next; } - } - })(); + ); + }); return () => { - controller.abort(); + subscription.close(); }; - }, [client, queryClient]); + }, [api, queryClient]); - // Subscribe to workspace activity changes via SSE useEffect(() => { - const controller = new AbortController(); - - (async () => { - try { - const iterator = await client.workspace.activity.subscribe(undefined, { - signal: controller.signal, - }); - for await (const event of iterator) { - if (controller.signal.aborted) break; - - const { workspaceId, activity } = event; - queryClient.setQueryData | undefined>( - WORKSPACE_ACTIVITY_QUERY_KEY, - (existing) => { - const current = existing ?? {}; - if (activity === null) { - if (!current[workspaceId]) { - return existing; - } - const next = { ...current }; - delete next[workspaceId]; - return next; - } - return { ...current, [workspaceId]: activity }; + const subscription = api.workspace.activity.subscribe(({ workspaceId, activity }) => { + queryClient.setQueryData | undefined>( + WORKSPACE_ACTIVITY_QUERY_KEY, + (existing) => { + const current = existing ?? {}; + if (activity === null) { + if (!current[workspaceId]) { + return existing; } - ); - } - } catch (error) { - // Stream ended or aborted - this is expected on cleanup - if (!controller.signal.aborted && process.env.NODE_ENV !== "production") { - console.warn("[useProjectsData] Activity stream error:", error); + const next = { ...current }; + delete next[workspaceId]; + return next; + } + return { ...current, [workspaceId]: activity }; } - } - })(); + ); + }); return () => { - controller.abort(); + subscription.close(); }; - }, [client, queryClient]); + }, [api, queryClient]); return { - client, + api, projectsQuery, workspacesQuery, activityQuery, diff --git a/mobile/src/hooks/useSlashCommandSuggestions.ts b/mobile/src/hooks/useSlashCommandSuggestions.ts index c873ea437..af40a83e2 100644 --- a/mobile/src/hooks/useSlashCommandSuggestions.ts +++ b/mobile/src/hooks/useSlashCommandSuggestions.ts @@ -1,12 +1,12 @@ import { useEffect, useMemo, useState } from "react"; import type { SlashSuggestion } from "@/browser/utils/slashCommands/types"; import { getSlashCommandSuggestions } from "@/browser/utils/slashCommands/suggestions"; -import type { ORPCClient } from "../orpc/client"; +import type { MuxMobileClient } from "../api/client"; import { filterSuggestionsForMobile, MOBILE_HIDDEN_COMMANDS } from "../utils/slashCommandHelpers"; interface UseSlashCommandSuggestionsOptions { input: string; - client: Pick; + api: MuxMobileClient; hiddenCommands?: ReadonlySet; enabled?: boolean; } @@ -18,7 +18,7 @@ interface UseSlashCommandSuggestionsResult { export function useSlashCommandSuggestions( options: UseSlashCommandSuggestionsOptions ): UseSlashCommandSuggestionsResult { - const { input, client, hiddenCommands = MOBILE_HIDDEN_COMMANDS, enabled = true } = options; + const { input, api, hiddenCommands = MOBILE_HIDDEN_COMMANDS, enabled = true } = options; const [providerNames, setProviderNames] = useState([]); useEffect(() => { @@ -30,7 +30,7 @@ export function useSlashCommandSuggestions( let cancelled = false; const loadProviders = async () => { try { - const names = await client.providers.list(); + const names = await api.providers.list(); if (!cancelled && Array.isArray(names)) { setProviderNames(names); } @@ -45,7 +45,7 @@ export function useSlashCommandSuggestions( return () => { cancelled = true; }; - }, [client, enabled]); + }, [api, enabled]); const suggestions = useMemo(() => { if (!enabled) { diff --git a/mobile/src/messages/normalizeChatEvent.ts b/mobile/src/messages/normalizeChatEvent.ts index 4fc4296a5..e1128765c 100644 --- a/mobile/src/messages/normalizeChatEvent.ts +++ b/mobile/src/messages/normalizeChatEvent.ts @@ -6,24 +6,10 @@ import type { MuxReasoningPart, } from "@/common/types/message"; import type { DynamicToolPart } from "@/common/types/toolParts"; -import type { WorkspaceChatMessage } from "@/common/orpc/types"; -import { isMuxMessage } from "@/common/orpc/types"; +import type { WorkspaceChatMessage } from "@/common/types/ipc"; +import { isMuxMessage } from "@/common/types/ipc"; import { createChatEventProcessor } from "@/browser/utils/messages/ChatEventProcessor"; -/** - * All possible event types that have a `type` discriminant field. - * This is derived from WorkspaceChatMessage excluding MuxMessage (which uses `role`). - * - * IMPORTANT: When adding new event types to the schema, TypeScript will error - * here if the handler map doesn't handle them - preventing runtime surprises. - */ -type TypedEventType = - Exclude extends infer T - ? T extends { type: infer U } - ? U - : never - : never; - type IncomingEvent = WorkspaceChatEvent | DisplayedMessage | string | number | null | undefined; export interface ChatEventExpander { @@ -62,6 +48,8 @@ function debugLog(message: string, context?: Record): void { console.debug(`${DEBUG_TAG} ${message}`); } } +const PASS_THROUGH_TYPES = new Set(["delete", "status", "error", "stream-error", "caught-up"]); + const INIT_MESSAGE_ID = "workspace-init"; function isObject(value: unknown): value is Record { @@ -337,108 +325,77 @@ export function createChatEventExpander(): ChatEventExpander { return [payload as DisplayedMessage]; } - const type = payload.type as TypedEventType; - // Cast once - we've verified payload is an object with a type field - const event = payload as Record; - const getMessageId = () => (typeof event.messageId === "string" ? event.messageId : ""); - - // Handler map for all typed events - TypeScript enforces exhaustiveness - // If a new event type is added to the schema, this will error until handled - const handlers: Record WorkspaceChatEvent[]> = { - // Init events: emit workspace init message - "init-start": () => { - processor.handleEvent(payload as unknown as WorkspaceChatMessage); - return emitInitMessage(); - }, - "init-output": () => { - processor.handleEvent(payload as unknown as WorkspaceChatMessage); - return emitInitMessage(); - }, - "init-end": () => { - processor.handleEvent(payload as unknown as WorkspaceChatMessage); - return emitInitMessage(); - }, - - // Stream lifecycle: manage active streams and emit displayed messages - "stream-start": () => { - processor.handleEvent(payload as unknown as WorkspaceChatMessage); - const messageId = getMessageId(); - if (!messageId) return []; - activeStreams.add(messageId); - return emitDisplayedMessages(messageId, { isStreaming: true }); - }, - "stream-delta": () => { - processor.handleEvent(payload as unknown as WorkspaceChatMessage); - const messageId = getMessageId(); - if (!messageId) return []; - return emitDisplayedMessages(messageId, { isStreaming: true }); - }, - "stream-end": () => { - processor.handleEvent(payload as unknown as WorkspaceChatMessage); - const messageId = getMessageId(); - if (!messageId) return []; - activeStreams.delete(messageId); - return emitDisplayedMessages(messageId, { isStreaming: false }); - }, - "stream-abort": () => { - processor.handleEvent(payload as unknown as WorkspaceChatMessage); - const messageId = getMessageId(); - if (!messageId) return []; - activeStreams.delete(messageId); - return emitDisplayedMessages(messageId, { isStreaming: false }); - }, - - // Tool call events: emit partial messages to show tool progress - "tool-call-start": () => { - processor.handleEvent(payload as unknown as WorkspaceChatMessage); - const messageId = getMessageId(); - if (!messageId) return []; - return emitDisplayedMessages(messageId, { isStreaming: true }); - }, - "tool-call-delta": () => { - processor.handleEvent(payload as unknown as WorkspaceChatMessage); - const messageId = getMessageId(); - if (!messageId) return []; - return emitDisplayedMessages(messageId, { isStreaming: true }); - }, - "tool-call-end": () => { - processor.handleEvent(payload as unknown as WorkspaceChatMessage); - const messageId = getMessageId(); - if (!messageId) return []; - return emitDisplayedMessages(messageId, { isStreaming: true }); - }, - - // Reasoning events - "reasoning-delta": () => { - processor.handleEvent(payload as unknown as WorkspaceChatMessage); - const messageId = getMessageId(); - if (!messageId) return []; - return emitDisplayedMessages(messageId, { isStreaming: true }); - }, - "reasoning-end": () => { - processor.handleEvent(payload as unknown as WorkspaceChatMessage); - return []; - }, + const type = payload.type; + + // Emit init message updates + if (type === "init-start" || type === "init-output" || type === "init-end") { + processor.handleEvent(payload as unknown as WorkspaceChatMessage); + return emitInitMessage(); + } + + // Stream start: mark as active and emit initial partial message + if (type === "stream-start") { + processor.handleEvent(payload as unknown as WorkspaceChatMessage); + const messageId = typeof payload.messageId === "string" ? payload.messageId : ""; + if (!messageId) return []; + activeStreams.add(messageId); + return emitDisplayedMessages(messageId, { isStreaming: true }); + } - // Usage delta: mobile app doesn't display usage, silently ignore - "usage-delta": () => [], + // Stream delta: emit partial message with accumulated content + if (type === "stream-delta") { + processor.handleEvent(payload as unknown as WorkspaceChatMessage); + const messageId = typeof payload.messageId === "string" ? payload.messageId : ""; + if (!messageId) return []; + return emitDisplayedMessages(messageId, { isStreaming: true }); + } - // Pass-through events: return unchanged - "caught-up": () => [payload as WorkspaceChatEvent], - "stream-error": () => [payload as WorkspaceChatEvent], - delete: () => [payload as WorkspaceChatEvent], + // Reasoning delta: emit partial reasoning message + if (type === "reasoning-delta") { + processor.handleEvent(payload as unknown as WorkspaceChatMessage); + const messageId = typeof payload.messageId === "string" ? payload.messageId : ""; + if (!messageId) return []; + return emitDisplayedMessages(messageId, { isStreaming: true }); + } - // Queue/restore events: pass through (mobile may use these later) - "queued-message-changed": () => [payload as WorkspaceChatEvent], - "restore-to-input": () => [payload as WorkspaceChatEvent], - }; + // Tool call events: emit partial messages to show tool progress + if (type === "tool-call-start" || type === "tool-call-delta" || type === "tool-call-end") { + processor.handleEvent(payload as unknown as WorkspaceChatMessage); + const messageId = typeof payload.messageId === "string" ? payload.messageId : ""; + if (!messageId) return []; + return emitDisplayedMessages(messageId, { isStreaming: true }); + } + + // Reasoning end: just process, next delta will emit + if (type === "reasoning-end") { + processor.handleEvent(payload as unknown as WorkspaceChatMessage); + return []; + } + + // Stream end: emit final complete message and clear streaming state + if (type === "stream-end") { + processor.handleEvent(payload as unknown as WorkspaceChatMessage); + const messageId = typeof payload.messageId === "string" ? payload.messageId : ""; + if (!messageId) return []; + activeStreams.delete(messageId); + return emitDisplayedMessages(messageId, { isStreaming: false }); + } + + // Stream abort: emit partial message marked as interrupted + if (type === "stream-abort") { + processor.handleEvent(payload as unknown as WorkspaceChatMessage); + const messageId = typeof payload.messageId === "string" ? payload.messageId : ""; + if (!messageId) return []; + activeStreams.delete(messageId); + return emitDisplayedMessages(messageId, { isStreaming: false }); + } - const handler = handlers[type]; - if (handler) { - return handler(); + // Pass through certain event types unchanged + if (PASS_THROUGH_TYPES.has(type)) { + return [payload as WorkspaceChatEvent]; } - // Fallback for truly unknown types (e.g., from newer backend) + // Log unsupported types once if (!unsupportedTypesLogged.has(type)) { console.warn(`Unhandled workspace chat event type: ${type}`, payload); unsupportedTypesLogged.add(type); diff --git a/mobile/src/orpc/client.ts b/mobile/src/orpc/client.ts deleted file mode 100644 index d6af150ae..000000000 --- a/mobile/src/orpc/client.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { RPCLink } from "@orpc/client/fetch"; -import { createClient } from "@/common/orpc/client"; -import type { RouterClient } from "@orpc/server"; -import type { AppRouter } from "@/node/orpc/router"; - -export type ORPCClient = RouterClient; - -export interface MobileClientConfig { - baseUrl: string; - authToken?: string | null; -} - -export function createMobileORPCClient(config: MobileClientConfig): ORPCClient { - const link = new RPCLink({ - url: `${config.baseUrl}/orpc`, - async fetch(request, init, _options, _path, _input) { - // Use expo/fetch for Event Iterator (SSE) support - const { fetch } = await import("expo/fetch"); - - // Inject auth token via Authorization header - const headers = new Headers(request.headers); - if (config.authToken) { - headers.set("Authorization", `Bearer ${config.authToken}`); - } - - const resp = await fetch(request.url, { - body: await request.blob(), - headers, - method: request.method, - signal: request.signal, - ...init, - }); - - return resp; - }, - }); - - return createClient(link); -} diff --git a/mobile/src/orpc/react.tsx b/mobile/src/orpc/react.tsx deleted file mode 100644 index 3c8debe40..000000000 --- a/mobile/src/orpc/react.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { createContext, useContext, useMemo } from "react"; -import { createMobileORPCClient, type ORPCClient } from "./client"; -import { useAppConfig } from "../contexts/AppConfigContext"; - -const ORPCContext = createContext(null); - -interface ORPCProviderProps { - children: React.ReactNode; -} - -export function ORPCProvider(props: ORPCProviderProps): JSX.Element { - const appConfig = useAppConfig(); - - const client = useMemo(() => { - return createMobileORPCClient({ - baseUrl: appConfig.resolvedBaseUrl, - authToken: appConfig.resolvedAuthToken ?? null, - }); - }, [appConfig.resolvedBaseUrl, appConfig.resolvedAuthToken]); - - return {props.children}; -} - -export function useORPC(): ORPCClient { - const ctx = useContext(ORPCContext); - if (!ctx) { - throw new Error("useORPC must be used within ORPCProvider"); - } - return ctx; -} diff --git a/mobile/src/screens/GitReviewScreen.tsx b/mobile/src/screens/GitReviewScreen.tsx index 1c2531de0..55aa4f496 100644 --- a/mobile/src/screens/GitReviewScreen.tsx +++ b/mobile/src/screens/GitReviewScreen.tsx @@ -4,7 +4,7 @@ import { ActivityIndicator, FlatList, RefreshControl, StyleSheet, Text, View } f import { useLocalSearchParams } from "expo-router"; import { Ionicons } from "@expo/vector-icons"; import { useTheme } from "../theme"; -import { useORPC } from "../orpc/react"; +import { useApiClient } from "../hooks/useApiClient"; import { parseDiff, extractAllHunks } from "../utils/git/diffParser"; import { parseNumstat, buildFileTree } from "../utils/git/numstatParser"; import { buildGitDiffCommand } from "../utils/git/gitCommands"; @@ -17,7 +17,7 @@ export default function GitReviewScreen(): JSX.Element { const theme = useTheme(); const params = useLocalSearchParams<{ id?: string }>(); const workspaceId = params.id ? String(params.id) : ""; - const client = useORPC(); + const api = useApiClient(); const [hunks, setHunks] = useState([]); const [fileTree, setFileTree] = useState(null); @@ -44,23 +44,28 @@ export default function GitReviewScreen(): JSX.Element { // Fetch file tree (numstat) const numstatCommand = buildGitDiffCommand(diffBase, includeUncommitted, "", "numstat"); - const numstatResult = await client.workspace.executeBash({ - workspaceId, - script: numstatCommand, - options: { timeout_secs: 30 }, + const numstatResult = await api.workspace.executeBash(workspaceId, numstatCommand, { + timeout_secs: 30, }); - // executeBash returns Result where BashToolResult is { success, output/error } if (!numstatResult.success) { throw new Error(numstatResult.error); } const numstatData = numstatResult.data; if (!numstatData.success) { - throw new Error(numstatData.error || "Failed to execute numstat command"); + throw new Error(numstatData.error || "Failed to fetch file stats"); } - const numstatOutput = numstatData.output ?? ""; + // Access nested data.data structure (executeBash returns Result>) + const numstatBashResult = (numstatData as any).data; + if (!numstatBashResult || !numstatBashResult.success) { + const error = numstatBashResult?.error || "Failed to execute numstat command"; + throw new Error(error); + } + + // Ensure output exists and is a string + const numstatOutput = numstatBashResult.output ?? ""; const fileStats = parseNumstat(numstatOutput); const tree = buildFileTree(fileStats); setFileTree(tree); @@ -68,10 +73,8 @@ export default function GitReviewScreen(): JSX.Element { // Fetch diff hunks (with optional path filter for truncation workaround) const pathFilter = selectedFilePath ? ` -- "${selectedFilePath}"` : ""; const diffCommand = buildGitDiffCommand(diffBase, includeUncommitted, pathFilter, "diff"); - const diffResult = await client.workspace.executeBash({ - workspaceId, - script: diffCommand, - options: { timeout_secs: 30 }, + const diffResult = await api.workspace.executeBash(workspaceId, diffCommand, { + timeout_secs: 30, }); if (!diffResult.success) { @@ -80,11 +83,19 @@ export default function GitReviewScreen(): JSX.Element { const diffData = diffResult.data; if (!diffData.success) { - throw new Error(diffData.error || "Failed to execute diff command"); + throw new Error(diffData.error || "Failed to fetch diff"); + } + + // Access nested data.data structure (executeBash returns Result>) + const diffBashResult = (diffData as any).data; + if (!diffBashResult || !diffBashResult.success) { + const error = diffBashResult?.error || "Failed to execute diff command"; + throw new Error(error); } - const diffOutput = diffData.output ?? ""; - const truncationInfo = diffData.truncated; + // Ensure output exists and is a string + const diffOutput = diffBashResult.output ?? ""; + const truncationInfo = diffBashResult.truncated; const fileDiffs = parseDiff(diffOutput); const allHunks = extractAllHunks(fileDiffs); @@ -104,7 +115,7 @@ export default function GitReviewScreen(): JSX.Element { setIsLoading(false); setIsRefreshing(false); } - }, [workspaceId, diffBase, includeUncommitted, selectedFilePath, client]); + }, [workspaceId, diffBase, includeUncommitted, selectedFilePath, api]); useEffect(() => { void loadGitData(); diff --git a/mobile/src/screens/ProjectsScreen.tsx b/mobile/src/screens/ProjectsScreen.tsx index 3f6f9da6e..8a8af211e 100644 --- a/mobile/src/screens/ProjectsScreen.tsx +++ b/mobile/src/screens/ProjectsScreen.tsx @@ -20,6 +20,7 @@ import { IconButton } from "../components/IconButton"; import { SecretsModal } from "../components/SecretsModal"; import { RenameWorkspaceModal } from "../components/RenameWorkspaceModal"; import { WorkspaceActivityIndicator } from "../components/WorkspaceActivityIndicator"; +import { createClient } from "../api/client"; import type { FrontendWorkspaceMetadata, Secret, WorkspaceActivitySnapshot } from "../types"; interface WorkspaceListItem { @@ -89,7 +90,7 @@ export function ProjectsScreen(): JSX.Element { const theme = useTheme(); const spacing = theme.spacing; const router = useRouter(); - const { client, projectsQuery, workspacesQuery, activityQuery } = useProjectsData(); + const { api, projectsQuery, workspacesQuery, activityQuery } = useProjectsData(); const [search, setSearch] = useState(""); const [secretsModalState, setSecretsModalState] = useState<{ visible: boolean; @@ -106,6 +107,8 @@ export function ProjectsScreen(): JSX.Element { projectName: string; } | null>(null); + const client = createClient(); + const groupedProjects = useMemo((): ProjectGroup[] => { const projects = projectsQuery.data ?? []; const workspaces = workspacesQuery.data ?? []; @@ -209,7 +212,7 @@ export function ProjectsScreen(): JSX.Element { const handleOpenSecrets = async (projectPath: string, projectName: string) => { try { - const secrets = await client.projects.secrets.get({ projectPath }); + const secrets = await client.projects.secrets.get(projectPath); setSecretsModalState({ visible: true, projectPath, @@ -226,13 +229,10 @@ export function ProjectsScreen(): JSX.Element { if (!secretsModalState) return; try { - const result = await client.projects.secrets.update({ - projectPath: secretsModalState.projectPath, - secrets, - }); + const result = await client.projects.secrets.update(secretsModalState.projectPath, secrets); if (!result.success) { - Alert.alert("Error", result.error ?? "Failed to save secrets"); + Alert.alert("Error", result.error); return; } @@ -267,32 +267,30 @@ export function ProjectsScreen(): JSX.Element { text: "Delete", style: "destructive", onPress: async () => { - const result = await client.workspace.remove({ workspaceId: metadata.id }); + const result = await api.workspace.remove(metadata.id); if (!result.success) { - const errorMsg = result.error ?? "Failed to delete workspace"; // Check if it's a "dirty workspace" error const isDirtyError = - errorMsg.toLowerCase().includes("uncommitted") || - errorMsg.toLowerCase().includes("unpushed"); + result.error.toLowerCase().includes("uncommitted") || + result.error.toLowerCase().includes("unpushed"); if (isDirtyError) { // Show force delete option Alert.alert( "Workspace Has Changes", - `${errorMsg}\n\nForce delete will discard these changes permanently.`, + `${result.error}\n\nForce delete will discard these changes permanently.`, [ { text: "Cancel", style: "cancel" }, { text: "Force Delete", style: "destructive", onPress: async () => { - const forceResult = await client.workspace.remove({ - workspaceId: metadata.id, - options: { force: true }, + const forceResult = await api.workspace.remove(metadata.id, { + force: true, }); if (!forceResult.success) { - Alert.alert("Error", forceResult.error ?? "Failed to force delete"); + Alert.alert("Error", forceResult.error); } else { await workspacesQuery.refetch(); } @@ -302,7 +300,7 @@ export function ProjectsScreen(): JSX.Element { ); } else { // Generic error - Alert.alert("Error", errorMsg); + Alert.alert("Error", result.error); } } else { // Success - refetch to update UI @@ -313,7 +311,7 @@ export function ProjectsScreen(): JSX.Element { ] ); }, - [client, workspacesQuery] + [api, workspacesQuery] ); const handleRenameWorkspace = useCallback((metadata: FrontendWorkspaceMetadata) => { @@ -327,17 +325,17 @@ export function ProjectsScreen(): JSX.Element { const executeRename = useCallback( async (workspaceId: string, newName: string): Promise => { - const result = await client.workspace.rename({ workspaceId, newName }); + const result = await api.workspace.rename(workspaceId, newName); if (!result.success) { // Show error - modal will display it - throw new Error(result.error ?? "Failed to rename workspace"); + throw new Error(result.error); } // Success - refetch workspace list await workspacesQuery.refetch(); }, - [client, workspacesQuery] + [api, workspacesQuery] ); const renderWorkspaceRow = (item: WorkspaceListItem) => { diff --git a/mobile/src/screens/WorkspaceScreen.tsx b/mobile/src/screens/WorkspaceScreen.tsx index cfdde3b62..c658e8869 100644 --- a/mobile/src/screens/WorkspaceScreen.tsx +++ b/mobile/src/screens/WorkspaceScreen.tsx @@ -22,7 +22,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Picker } from "@react-native-picker/picker"; import { useTheme } from "../theme"; import { ThemedText } from "../components/ThemedText"; -import { useORPC } from "../orpc/react"; +import { useApiClient } from "../hooks/useApiClient"; import { useWorkspaceCost } from "../contexts/WorkspaceCostContext"; import type { StreamAbortEvent, StreamEndEvent } from "@/common/types/stream.ts"; import { MessageRenderer } from "../messages/MessageRenderer"; @@ -30,7 +30,7 @@ import { useWorkspaceSettings } from "../hooks/useWorkspaceSettings"; import type { ThinkingLevel, WorkspaceMode } from "../types/settings"; import { FloatingTodoCard } from "../components/FloatingTodoCard"; import type { TodoItem } from "../components/TodoItemView"; -import type { DisplayedMessage, WorkspaceChatEvent } from "../types"; +import type { DisplayedMessage, FrontendWorkspaceMetadata, WorkspaceChatEvent } from "../types"; import { useWorkspaceChat } from "../contexts/WorkspaceChatContext"; import { applyChatEvent, TimelineEntry } from "./chatTimelineReducer"; import type { SlashSuggestion } from "@/browser/utils/slashCommands/types"; @@ -67,6 +67,13 @@ if (__DEV__) { type ThemeSpacing = ReturnType["spacing"]; +function formatProjectBreadcrumb(metadata: FrontendWorkspaceMetadata | null): string { + if (!metadata) { + return "Workspace"; + } + return `${metadata.projectName} › ${metadata.name}`; +} + function RawEventCard({ payload, onDismiss, @@ -179,7 +186,7 @@ function WorkspaceScreenInner({ const spacing = theme.spacing; const insets = useSafeAreaInsets(); const { getExpander } = useWorkspaceChat(); - const client = useORPC(); + const api = useApiClient(); const { mode, thinkingLevel, @@ -244,7 +251,7 @@ function WorkspaceScreenInner({ ); const { suggestions: commandSuggestions } = useSlashCommandSuggestions({ input, - client, + api, enabled: !isCreationMode, }); useEffect(() => { @@ -410,9 +417,7 @@ function WorkspaceScreenInner({ async function loadBranches() { try { - const result = await client.projects.listBranches({ - projectPath: creationContext!.projectPath, - }); + const result = await api.projects.listBranches(creationContext!.projectPath); const sanitized = result?.branches ?? []; setBranches(sanitized); const trunk = result?.recommendedTrunk ?? sanitized[0] ?? "main"; @@ -423,7 +428,7 @@ function WorkspaceScreenInner({ } } void loadBranches(); - }, [isCreationMode, client, creationContext]); + }, [isCreationMode, api, creationContext]); // Load runtime preference in creation mode useEffect(() => { @@ -453,7 +458,7 @@ function WorkspaceScreenInner({ const metadataQuery = useQuery({ queryKey: ["workspace", workspaceId], - queryFn: () => client.workspace.getInfo({ workspaceId: workspaceId! }), + queryFn: () => api.workspace.getInfo(workspaceId!), staleTime: 15_000, enabled: !isCreationMode && !!workspaceId, }); @@ -461,22 +466,20 @@ function WorkspaceScreenInner({ const metadata = metadataQuery.data ?? null; useEffect(() => { - // Skip SSE subscription in creation mode (no workspace yet) + // Skip WebSocket subscription in creation mode (no workspace yet) if (isCreationMode) return; isStreamActiveRef.current = false; hasCaughtUpRef.current = false; pendingTodosRef.current = null; - const controller = new AbortController(); - // Get persistent expander for this workspace (survives navigation) const expander = getExpander(workspaceId!); - - const handlePayload = (payload: WorkspaceChatEvent) => { + const subscription = api.workspace.subscribeChat(workspaceId!, (payload) => { // Track streaming state and tokens (60s trailing window like desktop) if (payload && typeof payload === "object" && "type" in payload) { if (payload.type === "caught-up") { + const alreadyCaughtUp = hasCaughtUpRef.current; hasCaughtUpRef.current = true; if ( @@ -492,6 +495,9 @@ function WorkspaceScreenInner({ pendingTodosRef.current = null; + if (__DEV__ && !alreadyCaughtUp) { + console.debug(`[WorkspaceScreen] caught up for workspace ${workspaceId}`); + } return; } @@ -594,33 +600,13 @@ function WorkspaceScreenInner({ // Only return new array if actually changed (prevents FlatList re-render) return changed ? next : current; }); - }; - - // Subscribe via SSE async generator - (async () => { - try { - const iterator = await client.workspace.onChat( - { workspaceId: workspaceId! }, - { signal: controller.signal } - ); - for await (const event of iterator) { - if (controller.signal.aborted) break; - handlePayload(event as unknown as WorkspaceChatEvent); - } - } catch (error) { - // Stream ended or aborted - expected on cleanup - if (!controller.signal.aborted && process.env.NODE_ENV !== "production") { - console.warn("[WorkspaceScreen] Chat stream error:", error); - } - } - })(); - - wsRef.current = { close: () => controller.abort() }; + }); + wsRef.current = subscription; return () => { - controller.abort(); + subscription.close(); wsRef.current = null; }; - }, [client, workspaceId, isCreationMode, recordStreamUsage, getExpander]); + }, [api, workspaceId, isCreationMode, recordStreamUsage, getExpander]); // Reset timeline, todos, and editing state when workspace changes useEffect(() => { @@ -700,7 +686,7 @@ function WorkspaceScreenInner({ if (!isCreationMode && parsedCommand) { const handled = await executeSlashCommand(parsedCommand, { - client, + api, workspaceId, metadata, sendMessageOptions, @@ -742,28 +728,17 @@ function WorkspaceScreenInner({ ? { type: "ssh" as const, host: sshHost, srcBaseDir: "~/mux" } : undefined; - const result = await client.workspace.sendMessage({ - workspaceId: null, - message: trimmed, - options: { - ...sendMessageOptions, - projectPath: creationContext!.projectPath, - trunkBranch, - runtimeConfig, - }, + const result = await api.workspace.sendMessage(null, trimmed, { + ...sendMessageOptions, + projectPath: creationContext!.projectPath, + trunkBranch, + runtimeConfig, }); if (!result.success) { - const err = result.error; - const errorMsg = - typeof err === "string" - ? err - : err?.type === "unknown" - ? err.raw - : (err?.type ?? "Unknown error"); - console.error("[createWorkspace] Failed:", errorMsg); + console.error("[createWorkspace] Failed:", result.error); setTimeline((current) => - applyChatEvent(current, { type: "error", error: errorMsg } as WorkspaceChatEvent) + applyChatEvent(current, { type: "error", error: result.error } as WorkspaceChatEvent) ); setInputWithSuggestionGuard(originalContent); setIsSending(false); @@ -785,26 +760,15 @@ function WorkspaceScreenInner({ return true; } - const result = await client.workspace.sendMessage({ - workspaceId: workspaceId!, - message: trimmed, - options: { - ...sendMessageOptions, - editMessageId: editingMessage?.id, - }, + const result = await api.workspace.sendMessage(workspaceId!, trimmed, { + ...sendMessageOptions, + editMessageId: editingMessage?.id, }); if (!result.success) { - const err = result.error; - const errorMsg = - typeof err === "string" - ? err - : err?.type === "unknown" - ? err.raw - : (err?.type ?? "Unknown error"); - console.error("[sendMessage] Validation failed:", errorMsg); + console.error("[sendMessage] Validation failed:", result.error); setTimeline((current) => - applyChatEvent(current, { type: "error", error: errorMsg } as WorkspaceChatEvent) + applyChatEvent(current, { type: "error", error: result.error } as WorkspaceChatEvent) ); if (wasEditing) { @@ -823,7 +787,7 @@ function WorkspaceScreenInner({ setIsSending(false); return true; }, [ - client, + api, creationContext, editingMessage, handleCancelEdit, @@ -860,26 +824,23 @@ function WorkspaceScreenInner({ const onCancelStream = useCallback(async () => { if (!workspaceId) return; - await client.workspace.interruptStream({ workspaceId }); - }, [client, workspaceId]); + await api.workspace.interruptStream(workspaceId); + }, [api, workspaceId]); const handleStartHere = useCallback( async (content: string) => { if (!workspaceId) return; const message = createCompactedMessage(content); - const result = await client.workspace.replaceChatHistory({ - workspaceId, - summaryMessage: message, - }); + const result = await api.workspace.replaceChatHistory(workspaceId, message); if (!result.success) { console.error("Failed to start here:", result.error); // Consider adding toast notification in future } - // Success case: backend will send delete + new message via SSE + // Success case: backend will send delete + new message via WebSocket // UI will update automatically via subscription }, - [client, workspaceId] + [api, workspaceId] ); // Edit message handlers diff --git a/mobile/src/utils/modelCatalog.ts b/mobile/src/utils/modelCatalog.ts index 6890c4773..b101128b7 100644 --- a/mobile/src/utils/modelCatalog.ts +++ b/mobile/src/utils/modelCatalog.ts @@ -17,8 +17,6 @@ const MODEL_MAP: Record = MODEL_LIST.reduce( export const MODEL_PROVIDER_LABELS: Record = { anthropic: "Anthropic (Claude)", openai: "OpenAI", - google: "Google", - xai: "xAI (Grok)", }; export const DEFAULT_MODEL_ID = WORKSPACE_DEFAULTS.model; diff --git a/mobile/src/utils/slashCommandHelpers.test.ts b/mobile/src/utils/slashCommandHelpers.test.ts index 8593b2364..d556086db 100644 --- a/mobile/src/utils/slashCommandHelpers.test.ts +++ b/mobile/src/utils/slashCommandHelpers.test.ts @@ -1,11 +1,6 @@ import type { SlashSuggestion } from "@/browser/utils/slashCommands/types"; -import type { InferClientInputs } from "@orpc/client"; -import type { ORPCClient } from "../orpc/client"; import { buildMobileCompactionPayload, filterSuggestionsForMobile } from "./slashCommandHelpers"; - -type SendMessageOptions = NonNullable< - InferClientInputs["workspace"]["sendMessage"]["options"] ->; +import type { SendMessageOptions } from "../api/client"; describe("filterSuggestionsForMobile", () => { it("filters out hidden commands by root key", () => { diff --git a/mobile/src/utils/slashCommandHelpers.ts b/mobile/src/utils/slashCommandHelpers.ts index ce9ad9df1..ea67bd061 100644 --- a/mobile/src/utils/slashCommandHelpers.ts +++ b/mobile/src/utils/slashCommandHelpers.ts @@ -1,11 +1,6 @@ import type { MuxFrontendMetadata } from "@/common/types/message"; import type { ParsedCommand, SlashSuggestion } from "@/browser/utils/slashCommands/types"; -import type { InferClientInputs } from "@orpc/client"; -import type { ORPCClient } from "../orpc/client"; - -type SendMessageOptions = NonNullable< - InferClientInputs["workspace"]["sendMessage"]["options"] ->; +import type { SendMessageOptions } from "../api/client"; export const MOBILE_HIDDEN_COMMANDS = new Set(["telemetry", "vim"]); const WORDS_PER_TOKEN = 1.3; diff --git a/mobile/src/utils/slashCommandRunner.test.ts b/mobile/src/utils/slashCommandRunner.test.ts index 56f350935..6ed8a92e7 100644 --- a/mobile/src/utils/slashCommandRunner.test.ts +++ b/mobile/src/utils/slashCommandRunner.test.ts @@ -1,8 +1,9 @@ import { executeSlashCommand, parseRuntimeStringForMobile } from "./slashCommandRunner"; import type { SlashCommandRunnerContext } from "./slashCommandRunner"; -function createMockClient(): SlashCommandRunnerContext["client"] { - const client = { +function createMockApi(): SlashCommandRunnerContext["api"] { + const noopSubscription = { close: jest.fn() }; + const api = { workspace: { list: jest.fn(), create: jest.fn().mockResolvedValue({ success: false, error: "not implemented" }), @@ -13,15 +14,15 @@ function createMockClient(): SlashCommandRunnerContext["client"] { fork: jest.fn().mockResolvedValue({ success: false, error: "not implemented" }), rename: jest.fn(), interruptStream: jest.fn(), - truncateHistory: jest.fn().mockResolvedValue({ success: true }), + truncateHistory: jest.fn().mockResolvedValue({ success: true, data: undefined }), replaceChatHistory: jest.fn(), - sendMessage: jest.fn().mockResolvedValue(undefined), + sendMessage: jest.fn().mockResolvedValue({ success: true, data: undefined }), executeBash: jest.fn(), - onChat: jest.fn(), + subscribeChat: jest.fn().mockReturnValue(noopSubscription), }, providers: { list: jest.fn().mockResolvedValue(["anthropic"]), - setProviderConfig: jest.fn().mockResolvedValue(undefined), + setProviderConfig: jest.fn().mockResolvedValue({ success: true, data: undefined }), }, projects: { list: jest.fn(), @@ -31,16 +32,16 @@ function createMockClient(): SlashCommandRunnerContext["client"] { update: jest.fn(), }, }, - } satisfies SlashCommandRunnerContext["client"]; - return client; + } satisfies SlashCommandRunnerContext["api"]; + return api; } function createContext( overrides: Partial = {} ): SlashCommandRunnerContext { - const client = createMockClient(); + const api = createMockApi(); return { - client, + api, workspaceId: "ws-1", metadata: null, sendMessageOptions: { @@ -79,10 +80,7 @@ describe("executeSlashCommand", () => { const ctx = createContext(); const handled = await executeSlashCommand({ type: "clear" }, ctx); expect(handled).toBe(true); - expect(ctx.client.workspace.truncateHistory).toHaveBeenCalledWith({ - workspaceId: "ws-1", - percentage: 1, - }); + expect(ctx.api.workspace.truncateHistory).toHaveBeenCalledWith("ws-1", 1); expect(ctx.onClearTimeline).toHaveBeenCalled(); }); diff --git a/mobile/src/utils/slashCommandRunner.ts b/mobile/src/utils/slashCommandRunner.ts index da23ef0a2..762179cf5 100644 --- a/mobile/src/utils/slashCommandRunner.ts +++ b/mobile/src/utils/slashCommandRunner.ts @@ -2,16 +2,11 @@ import type { ParsedCommand } from "@/browser/utils/slashCommands/types"; import type { RuntimeConfig } from "@/common/types/runtime"; import { RUNTIME_MODE, SSH_RUNTIME_PREFIX } from "@/common/types/runtime"; import type { FrontendWorkspaceMetadata } from "../types"; -import type { ORPCClient } from "../orpc/client"; +import type { MuxMobileClient, SendMessageOptions } from "../api/client"; import { buildMobileCompactionPayload } from "./slashCommandHelpers"; -import type { InferClientInputs } from "@orpc/client"; - -type SendMessageOptions = NonNullable< - InferClientInputs["workspace"]["sendMessage"]["options"] ->; export interface SlashCommandRunnerContext { - client: Pick; + api: Pick; workspaceId?: string | null; metadata?: FrontendWorkspaceMetadata | null; sendMessageOptions: SendMessageOptions; @@ -96,7 +91,7 @@ async function handleTruncate( ): Promise { try { const workspaceId = ensureWorkspaceId(ctx); - const result = await ctx.client.workspace.truncateHistory({ workspaceId, percentage }); + const result = await ctx.api.workspace.truncateHistory(workspaceId, percentage); if (!result.success) { ctx.showError("History", result.error ?? "Failed to truncate history"); return true; @@ -125,25 +120,14 @@ async function handleCompaction( ctx.sendMessageOptions ); - const result = await ctx.client.workspace.sendMessage({ - workspaceId, - message: messageText, - options: { - ...sendOptions, - muxMetadata: metadata, - editMessageId: ctx.editingMessageId, - }, - }); + const result = (await ctx.api.workspace.sendMessage(workspaceId, messageText, { + ...sendOptions, + muxMetadata: metadata, + editMessageId: ctx.editingMessageId, + })) as { success: boolean; error?: string }; if (!result.success) { - const err = result.error; - const errorMsg = - typeof err === "string" - ? err - : err?.type === "unknown" - ? err.raw - : (err?.type ?? "Failed to start compaction"); - ctx.showError("Compaction", errorMsg); + ctx.showError("Compaction", result.error ?? "Failed to start compaction"); return true; } @@ -164,11 +148,11 @@ async function handleProviderSet( parsed: Extract ): Promise { try { - const result = await ctx.client.providers.setProviderConfig({ - provider: parsed.provider, - keyPath: parsed.keyPath, - value: parsed.value, - }); + const result = await ctx.api.providers.setProviderConfig( + parsed.provider, + parsed.keyPath, + parsed.value + ); if (!result.success) { ctx.showError("Providers", result.error ?? "Failed to update provider"); return true; @@ -187,10 +171,7 @@ async function handleFork( ): Promise { try { const workspaceId = ensureWorkspaceId(ctx); - const result = await ctx.client.workspace.fork({ - sourceWorkspaceId: workspaceId, - newName: parsed.newName, - }); + const result = await ctx.api.workspace.fork(workspaceId, parsed.newName); if (!result.success) { ctx.showError("Fork", result.error ?? "Failed to fork workspace"); return true; @@ -200,11 +181,11 @@ async function handleFork( ctx.showInfo("Fork", `Switched to ${result.metadata.name}`); if (parsed.startMessage) { - await ctx.client.workspace.sendMessage({ - workspaceId: result.metadata.id, - message: parsed.startMessage, - options: ctx.sendMessageOptions, - }); + await ctx.api.workspace.sendMessage( + result.metadata.id, + parsed.startMessage, + ctx.sendMessageOptions + ); } return true; } catch (error) { @@ -231,12 +212,12 @@ async function handleNew( try { const trunkBranch = await resolveTrunkBranch(ctx, projectPath, parsed.trunkBranch); const runtimeConfig = parseRuntimeStringForMobile(parsed.runtime); - const result = await ctx.client.workspace.create({ + const result = await ctx.api.workspace.create( projectPath, - branchName: parsed.workspaceName, + parsed.workspaceName, trunkBranch, - runtimeConfig, - }); + runtimeConfig + ); if (!result.success) { ctx.showError("New workspace", result.error ?? "Failed to create workspace"); return true; @@ -246,11 +227,11 @@ async function handleNew( ctx.showInfo("New workspace", `Created ${result.metadata.name}`); if (parsed.startMessage) { - await ctx.client.workspace.sendMessage({ - workspaceId: result.metadata.id, - message: parsed.startMessage, - options: ctx.sendMessageOptions, - }); + await ctx.api.workspace.sendMessage( + result.metadata.id, + parsed.startMessage, + ctx.sendMessageOptions + ); } return true; @@ -269,7 +250,7 @@ async function resolveTrunkBranch( return explicit; } try { - const { recommendedTrunk, branches } = await ctx.client.projects.listBranches({ projectPath }); + const { recommendedTrunk, branches } = await ctx.api.projects.listBranches(projectPath); return recommendedTrunk ?? branches?.[0] ?? "main"; } catch (error) { ctx.showInfo( diff --git a/mobile/tsconfig.json b/mobile/tsconfig.json index 1bee05a36..c9dd6886f 100644 --- a/mobile/tsconfig.json +++ b/mobile/tsconfig.json @@ -23,21 +23,14 @@ ".expo/types/**/*.ts", "expo-env.d.ts", "../src/types/**/*.ts", - "../src/browser/utils/messages/**/*.ts", - "../src/browser/utils/slashCommands/**/*.ts" + "../src/utils/messages/**/*.ts" ], "exclude": [ "node_modules", "**/*.test.ts", "**/*.test.tsx", "../src/**/*.test.ts", - "../src/**/*.test.tsx", - "../src/desktop/**", - "../src/browser/**", - "../src/node/**", - "../src/cli/**", - "../src/main.ts", - "../src/preload.ts" + "../src/**/*.test.tsx" ], "extends": "expo/tsconfig.base" } diff --git a/package.json b/package.json index 0e28dbcfd..239a51e26 100644 --- a/package.json +++ b/package.json @@ -54,9 +54,6 @@ "@lydell/node-pty": "1.1.0", "@mozilla/readability": "^0.6.0", "@openrouter/ai-sdk-provider": "^1.2.5", - "@orpc/client": "^1.11.3", - "@orpc/server": "^1.11.3", - "@orpc/zod": "^1.11.3", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -98,9 +95,6 @@ "zod-to-json-schema": "^3.24.6" }, "devDependencies": { - "@babel/core": "^7.28.5", - "@babel/preset-env": "^7.28.5", - "@babel/preset-typescript": "^7.28.5", "@electron/rebuild": "^4.0.1", "@eslint/js": "^9.36.0", "@playwright/test": "^1.56.0", @@ -131,7 +125,6 @@ "@typescript/native-preview": "^7.0.0-dev.20251014.1", "@vitejs/plugin-react": "^4.0.0", "autoprefixer": "^10.4.21", - "babel-jest": "^30.2.0", "babel-plugin-react-compiler": "^1.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/playwright.config.ts b/playwright.config.ts index 3eb726f3c..7459d4f88 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -25,9 +25,6 @@ export default defineConfig({ { name: "electron", testDir: "./tests/e2e", - // Electron tests are resource-intensive (each spawns a full browser). - // Limit parallelism to avoid timing issues with transient UI elements like toasts. - fullyParallel: false, }, ], }); diff --git a/scripts/build-main-watch.js b/scripts/build-main-watch.js index 6709782c5..3b57fb8bd 100644 --- a/scripts/build-main-watch.js +++ b/scripts/build-main-watch.js @@ -4,32 +4,33 @@ * Used by nodemon - ignores file arguments passed by nodemon */ -const { execSync } = require("child_process"); -const path = require("path"); +const { execSync } = require('child_process'); +const path = require('path'); -const rootDir = path.join(__dirname, ".."); -const tsgoPath = path.join(rootDir, "node_modules/@typescript/native-preview/bin/tsgo.js"); -const tscAliasPath = path.join(rootDir, "node_modules/tsc-alias/dist/bin/index.js"); +const rootDir = path.join(__dirname, '..'); +const tsgoPath = path.join(rootDir, 'node_modules/@typescript/native-preview/bin/tsgo.js'); +const tscAliasPath = path.join(rootDir, 'node_modules/tsc-alias/dist/bin/index.js'); try { - console.log("Building main process..."); - + console.log('Building main process...'); + // Run tsgo execSync(`node "${tsgoPath}" -p tsconfig.main.json`, { cwd: rootDir, - stdio: "inherit", - env: { ...process.env, NODE_ENV: "development" }, + stdio: 'inherit', + env: { ...process.env, NODE_ENV: 'development' } }); - + // Run tsc-alias execSync(`node "${tscAliasPath}" -p tsconfig.main.json`, { cwd: rootDir, - stdio: "inherit", - env: { ...process.env, NODE_ENV: "development" }, + stdio: 'inherit', + env: { ...process.env, NODE_ENV: 'development' } }); - - console.log("✓ Main process build complete"); + + console.log('✓ Main process build complete'); } catch (error) { - console.error("Build failed:", error.message); + console.error('Build failed:', error.message); process.exit(1); } + diff --git a/scripts/generate-icons.ts b/scripts/generate-icons.ts index d74609a59..eac90da4c 100644 --- a/scripts/generate-icons.ts +++ b/scripts/generate-icons.ts @@ -47,7 +47,9 @@ async function generateIconsetPngs() { } return outputs.map(({ file, dimension }) => - sharp(SOURCE).resize(dimension, dimension, { fit: "cover" }).toFile(file) + sharp(SOURCE) + .resize(dimension, dimension, { fit: "cover" }) + .toFile(file), ); }); @@ -59,7 +61,14 @@ async function generateIcns() { throw new Error("ICNS generation requires macOS (iconutil)"); } - const proc = Bun.spawn(["iconutil", "-c", "icns", ICONSET_DIR, "-o", ICNS_OUTPUT]); + const proc = Bun.spawn([ + "iconutil", + "-c", + "icns", + ICONSET_DIR, + "-o", + ICNS_OUTPUT, + ]); const status = await proc.exited; if (status !== 0) { throw new Error("iconutil failed to generate .icns file"); diff --git a/scripts/mdbook-shiki.ts b/scripts/mdbook-shiki.ts index e45f6b823..e51190ac7 100755 --- a/scripts/mdbook-shiki.ts +++ b/scripts/mdbook-shiki.ts @@ -6,11 +6,7 @@ */ import { createHighlighter } from "shiki"; -import { - SHIKI_THEME, - mapToShikiLang, - extractShikiLines, -} from "../src/utils/highlighting/shiki-shared"; +import { SHIKI_THEME, mapToShikiLang, extractShikiLines } from "../src/utils/highlighting/shiki-shared"; import { renderToStaticMarkup } from "react-dom/server"; import { CodeBlockSSR } from "../src/browser/components/Messages/CodeBlockSSR"; @@ -48,34 +44,33 @@ type PreprocessorInput = [Context, Book]; */ function generateGridHtml(shikiHtml: string, originalCode: string): string { const lines = extractShikiLines(shikiHtml); - + // Render the React component to static HTML - const html = renderToStaticMarkup(CodeBlockSSR({ code: originalCode, highlightedLines: lines })); - + const html = renderToStaticMarkup( + CodeBlockSSR({ code: originalCode, highlightedLines: lines }) + ); + return html; } /** * Process markdown content to replace code blocks with highlighted HTML */ -async function processMarkdown( - content: string, - highlighter: Awaited> -): Promise { +async function processMarkdown(content: string, highlighter: Awaited>): Promise { // Match ```lang\ncode\n``` blocks (lang is optional) const codeBlockRegex = /```(\w*)\n([\s\S]*?)```/g; - + let result = content; const matches = Array.from(content.matchAll(codeBlockRegex)); - + for (const match of matches) { const [fullMatch, lang, code] = match; // Default to plaintext if no language specified const shikiLang = mapToShikiLang(lang || "plaintext"); - + // Remove trailing newlines from code (markdown often has extra newline before closing ```) const trimmedCode = code.replace(/\n+$/, ""); - + try { // Load language if needed const loadedLangs = highlighter.getLoadedLanguages(); @@ -89,39 +84,36 @@ async function processMarkdown( continue; } } - + const html = highlighter.codeToHtml(trimmedCode, { lang: shikiLang, theme: SHIKI_DARK_THEME, }); - + const gridHtml = generateGridHtml(html, trimmedCode); - + // Remove newlines from HTML to prevent mdBook from treating it as markdown // mdBook only parses multi-line content as markdown; single-line HTML is passed through - const singleLineHtml = gridHtml.replace(/\n/g, ""); - + const singleLineHtml = gridHtml.replace(/\n/g, ''); + result = result.replace(fullMatch, singleLineHtml); } catch (error) { console.warn(`[mdbook-shiki] Failed to highlight code block (${lang}):`, error); // Keep original code block on error } } - + return result; } /** * Recursively process all chapters in a section */ -async function processChapter( - chapter: Chapter, - highlighter: Awaited> -): Promise { +async function processChapter(chapter: Chapter, highlighter: Awaited>): Promise { if (chapter.content) { chapter.content = await processMarkdown(chapter.content, highlighter); } - + if (chapter.sub_items) { for (const subItem of chapter.sub_items) { if (subItem.Chapter) { @@ -137,7 +129,7 @@ async function processChapter( async function main() { // Read input from stdin const stdinText = await Bun.stdin.text(); - + // Check for "supports" query const trimmed = stdinText.trim(); if (trimmed.startsWith('["supports"')) { @@ -145,28 +137,28 @@ async function main() { console.log("true"); process.exit(0); } - + // Empty input - exit cleanly if (!trimmed) { process.exit(0); } - + // Parse the preprocessor input const [context, book]: PreprocessorInput = JSON.parse(trimmed); - + // Initialize Shiki highlighter const highlighter = await createHighlighter({ themes: [SHIKI_DARK_THEME], langs: [], // Load on-demand }); - + // Process all sections for (const section of book.sections) { if (section.Chapter) { await processChapter(section.Chapter, highlighter); } } - + // Output the modified book console.log(JSON.stringify(book)); } diff --git a/scripts/wait_pr_checks.sh b/scripts/wait_pr_checks.sh index b691d1c8c..77ec30c9b 100755 --- a/scripts/wait_pr_checks.sh +++ b/scripts/wait_pr_checks.sh @@ -134,7 +134,7 @@ while true; do echo " ./scripts/extract_pr_logs.sh $PR_NUMBER # e.g., Integration" echo "" echo "💡 To re-run a subset of integration tests faster with workflow_dispatch:" - echo " gh workflow run ci.yml --ref $(git rev-parse --abbrev-ref HEAD) -f test_filter=\"tests/integration/specificTest.test.ts\"" + echo " gh workflow run ci.yml --ref $(git rev-parse --abbrev-ref HEAD) -f test_filter=\"tests/ipcMain/specificTest.test.ts\"" echo " gh workflow run ci.yml --ref $(git rev-parse --abbrev-ref HEAD) -f test_filter=\"-t 'specific test name'\"" exit 1 fi diff --git a/src/browser/App.stories.tsx b/src/browser/App.stories.tsx index 6ce451e90..ff4c30db0 100644 --- a/src/browser/App.stories.tsx +++ b/src/browser/App.stories.tsx @@ -1,17 +1,142 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { useMemo } from "react"; +import { useRef } from "react"; import { AppLoader } from "./components/AppLoader"; import type { ProjectConfig } from "@/node/config"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; -import type { WorkspaceChatMessage } from "@/common/orpc/types"; +import type { IPCApi } from "@/common/types/ipc"; +import type { ChatStats } from "@/common/types/chatStats"; import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace"; -import { createMockORPCClient, type MockORPCClientOptions } from "../../.storybook/mocks/orpc"; // Stable timestamp for testing active states (use fixed time minus small offsets) // This ensures workspaces don't show as "Older than 1 day" and keeps stories deterministic const NOW = 1700000000000; // Fixed timestamp: Nov 14, 2023 const STABLE_TIMESTAMP = NOW - 60000; // 1 minute ago +// Mock window.api for App component +function setupMockAPI(options: { + projects?: Map; + workspaces?: FrontendWorkspaceMetadata[]; + selectedWorkspaceId?: string; + apiOverrides?: Partial; +}) { + const mockProjects = options.projects ?? new Map(); + const mockWorkspaces = options.workspaces ?? []; + const mockStats: ChatStats = { + consumers: [], + totalTokens: 0, + model: "mock-model", + tokenizerName: "mock-tokenizer", + usageHistory: [], + }; + + const mockApi: IPCApi = { + tokenizer: { + countTokens: () => Promise.resolve(0), + countTokensBatch: (_model, texts) => Promise.resolve(texts.map(() => 0)), + calculateStats: () => Promise.resolve(mockStats), + }, + providers: { + setProviderConfig: () => Promise.resolve({ success: true, data: undefined }), + setModels: () => Promise.resolve({ success: true, data: undefined }), + getConfig: () => + Promise.resolve( + {} as Record + ), + list: () => Promise.resolve([]), + }, + workspace: { + create: (projectPath: string, branchName: string) => + Promise.resolve({ + success: true, + metadata: { + // Mock stable ID (production uses crypto.randomBytes(5).toString('hex')) + id: Math.random().toString(36).substring(2, 12), + name: branchName, + projectPath, + projectName: projectPath.split("/").pop() ?? "project", + namedWorkspacePath: `/mock/workspace/${branchName}`, + runtimeConfig: DEFAULT_RUNTIME_CONFIG, + }, + }), + list: () => Promise.resolve(mockWorkspaces), + rename: (workspaceId: string) => + Promise.resolve({ + success: true, + data: { newWorkspaceId: workspaceId }, + }), + remove: () => Promise.resolve({ success: true }), + fork: () => Promise.resolve({ success: false, error: "Not implemented in mock" }), + openTerminal: () => Promise.resolve(undefined), + onChat: () => () => undefined, + onMetadata: () => () => undefined, + sendMessage: () => Promise.resolve({ success: true, data: undefined }), + resumeStream: () => Promise.resolve({ success: true, data: undefined }), + interruptStream: () => Promise.resolve({ success: true, data: undefined }), + clearQueue: () => Promise.resolve({ success: true, data: undefined }), + truncateHistory: () => Promise.resolve({ success: true, data: undefined }), + activity: { + list: () => Promise.resolve({}), + subscribe: () => () => undefined, + }, + replaceChatHistory: () => Promise.resolve({ success: true, data: undefined }), + getInfo: () => Promise.resolve(null), + executeBash: () => + Promise.resolve({ + success: true, + data: { success: true, output: "", exitCode: 0, wall_duration_ms: 0 }, + }), + }, + projects: { + list: () => Promise.resolve(Array.from(mockProjects.entries())), + create: () => + Promise.resolve({ + success: true, + data: { projectConfig: { workspaces: [] }, normalizedPath: "/mock/project/path" }, + }), + remove: () => Promise.resolve({ success: true, data: undefined }), + pickDirectory: () => Promise.resolve(null), + listBranches: () => + Promise.resolve({ + branches: ["main", "develop", "feature/new-feature"], + recommendedTrunk: "main", + }), + secrets: { + get: () => Promise.resolve([]), + update: () => Promise.resolve({ success: true, data: undefined }), + }, + }, + window: { + setTitle: () => Promise.resolve(undefined), + }, + terminal: { + create: () => + Promise.resolve({ + sessionId: "mock-session", + workspaceId: "mock-workspace", + cols: 80, + rows: 24, + }), + close: () => Promise.resolve(undefined), + resize: () => Promise.resolve(undefined), + sendInput: () => undefined, + onOutput: () => () => undefined, + onExit: () => () => undefined, + openWindow: () => Promise.resolve(undefined), + closeWindow: () => Promise.resolve(undefined), + }, + update: { + check: () => Promise.resolve(undefined), + download: () => Promise.resolve(undefined), + install: () => undefined, + onStatus: () => () => undefined, + }, + ...options.apiOverrides, + }; + + // @ts-expect-error - Assigning mock API to window for Storybook + window.api = mockApi; +} + const meta = { title: "App/Full Application", component: AppLoader, @@ -28,14 +153,21 @@ const meta = { export default meta; type Story = StoryObj; -// Story wrapper that creates ORPC client and passes to AppLoader -const AppWithMocks: React.FC = (props) => { - const client = useMemo( - () => createMockORPCClient(props), - // eslint-disable-next-line react-hooks/exhaustive-deps -- props are stable per story render - [] - ); - return ; +// Story wrapper that sets up mocks synchronously before rendering +const AppWithMocks: React.FC<{ + projects?: Map; + workspaces?: FrontendWorkspaceMetadata[]; + selectedWorkspaceId?: string; +}> = ({ projects, workspaces, selectedWorkspaceId }) => { + // Set up mock API only once per component instance (not on every render) + // Use useRef to ensure it runs synchronously before first render + const initialized = useRef(false); + if (!initialized.current) { + setupMockAPI({ projects, workspaces, selectedWorkspaceId }); + initialized.current = true; + } + + return ; }; export const WelcomeScreen: Story = { @@ -406,25 +538,628 @@ export const ActiveWorkspaceWithChat: Story = { }, ]; - // Set initial workspace selection - localStorage.setItem( - "selectedWorkspace", - JSON.stringify({ - workspaceId: workspaceId, - projectPath: "/home/user/projects/my-app", - projectName: "my-app", - namedWorkspacePath: "/home/user/.mux/src/my-app/feature", - }) - ); - localStorage.setItem( - `input:${workspaceId}`, - "Add OAuth2 support with Google and GitHub providers" - ); - localStorage.setItem(`model:${workspaceId}`, "anthropic:claude-sonnet-4-5"); - - // Git status mocks for each workspace - const gitStatusMocks: Record = { - [workspaceId]: `---PRIMARY--- + const AppWithChatMocks: React.FC = () => { + // Set up mock API only once per component instance (not on every render) + const initialized = useRef(false); + if (!initialized.current) { + setupMockAPI({ + projects, + workspaces, + apiOverrides: { + tokenizer: { + countTokens: () => Promise.resolve(42), + countTokensBatch: (_model, texts) => Promise.resolve(texts.map(() => 42)), + calculateStats: () => + Promise.resolve({ + consumers: [], + totalTokens: 0, + model: "mock-model", + tokenizerName: "mock-tokenizer", + usageHistory: [], + }), + }, + providers: { + setProviderConfig: () => Promise.resolve({ success: true, data: undefined }), + setModels: () => Promise.resolve({ success: true, data: undefined }), + getConfig: () => + Promise.resolve( + {} as Record + ), + list: () => Promise.resolve(["anthropic", "openai", "xai"]), + }, + workspace: { + create: (projectPath: string, branchName: string) => + Promise.resolve({ + success: true, + metadata: { + // Mock stable ID (production uses crypto.randomBytes(5).toString('hex')) + id: Math.random().toString(36).substring(2, 12), + name: branchName, + projectPath, + projectName: projectPath.split("/").pop() ?? "project", + namedWorkspacePath: `/mock/workspace/${branchName}`, + runtimeConfig: DEFAULT_RUNTIME_CONFIG, + }, + }), + list: () => Promise.resolve(workspaces), + rename: (workspaceId: string) => + Promise.resolve({ + success: true, + data: { newWorkspaceId: workspaceId }, + }), + remove: () => Promise.resolve({ success: true }), + fork: () => Promise.resolve({ success: false, error: "Not implemented in mock" }), + openTerminal: () => Promise.resolve(undefined), + onChat: (wsId, callback) => { + // Active workspace with complete chat history + if (wsId === workspaceId) { + setTimeout(() => { + // User message + callback({ + id: "msg-1", + role: "user", + parts: [ + { type: "text", text: "Add authentication to the user API endpoint" }, + ], + metadata: { + historySequence: 1, + timestamp: STABLE_TIMESTAMP - 300000, + }, + }); + + // Assistant message with tool calls + callback({ + id: "msg-2", + role: "assistant", + parts: [ + { + type: "text", + text: "I'll help you add authentication to the user API endpoint. Let me first check the current implementation.", + }, + { + type: "dynamic-tool", + toolCallId: "call-1", + toolName: "read_file", + state: "output-available", + input: { target_file: "src/api/users.ts" }, + output: { + success: true, + content: + "export function getUser(req, res) {\n const user = db.users.find(req.params.id);\n res.json(user);\n}", + }, + }, + ], + metadata: { + historySequence: 2, + timestamp: STABLE_TIMESTAMP - 290000, + model: "anthropic:claude-sonnet-4-5", + usage: { + inputTokens: 1250, + outputTokens: 450, + totalTokens: 1700, + }, + duration: 3500, + }, + }); + + // User response + callback({ + id: "msg-3", + role: "user", + parts: [{ type: "text", text: "Yes, add JWT token validation" }], + metadata: { + historySequence: 3, + timestamp: STABLE_TIMESTAMP - 280000, + }, + }); + + // Assistant message with file edit (large diff) + callback({ + id: "msg-4", + role: "assistant", + parts: [ + { + type: "text", + text: "I'll add JWT token validation to the endpoint. Let me update the file with proper authentication middleware and error handling.", + }, + { + type: "dynamic-tool", + toolCallId: "call-2", + toolName: "file_edit_replace_string", + state: "output-available", + input: { + file_path: "src/api/users.ts", + old_string: + "import express from 'express';\nimport { db } from '../db';\n\nexport function getUser(req, res) {\n const user = db.users.find(req.params.id);\n res.json(user);\n}", + new_string: + "import express from 'express';\nimport { db } from '../db';\nimport { verifyToken } from '../auth/jwt';\nimport { logger } from '../utils/logger';\n\nexport async function getUser(req, res) {\n try {\n const token = req.headers.authorization?.split(' ')[1];\n if (!token) {\n logger.warn('Missing authorization token');\n return res.status(401).json({ error: 'Unauthorized' });\n }\n const decoded = await verifyToken(token);\n const user = await db.users.find(req.params.id);\n res.json(user);\n } catch (err) {\n logger.error('Auth error:', err);\n return res.status(401).json({ error: 'Invalid token' });\n }\n}", + }, + output: { + success: true, + diff: [ + "--- src/api/users.ts", + "+++ src/api/users.ts", + "@@ -2,0 +3,2 @@", + "+import { verifyToken } from '../auth/jwt';", + "+import { logger } from '../utils/logger';", + "@@ -4,28 +6,14 @@", + "-// TODO: Add authentication middleware", + "-// Current implementation is insecure and allows unauthorized access", + "-// Need to validate JWT tokens before processing requests", + "-// Also need to add rate limiting to prevent abuse", + "-// Consider adding request logging for audit trail", + "-// Add input validation for user IDs", + "-// Handle edge cases for deleted/suspended users", + "-", + "-/**", + "- * Get user by ID", + "- * @param {Object} req - Express request object", + "- * @param {Object} res - Express response object", + "- */", + "-export function getUser(req, res) {", + "- // FIXME: No authentication check", + "- // FIXME: No error handling", + "- // FIXME: Synchronous database call blocks event loop", + "- // FIXME: No input validation", + "- // FIXME: Direct database access without repository pattern", + "- // FIXME: No logging", + "-", + "- const user = db.users.find(req.params.id);", + "-", + "- // TODO: Check if user exists", + "- // TODO: Filter sensitive fields (password hash, etc)", + "- // TODO: Check permissions - user should only access their own data", + "-", + "- res.json(user);", + "+export async function getUser(req, res) {", + "+ try {", + "+ const token = req.headers.authorization?.split(' ')[1];", + "+ if (!token) {", + "+ logger.warn('Missing authorization token');", + "+ return res.status(401).json({ error: 'Unauthorized' });", + "+ }", + "+ const decoded = await verifyToken(token);", + "+ const user = await db.users.find(req.params.id);", + "+ res.json(user);", + "+ } catch (err) {", + "+ logger.error('Auth error:', err);", + "+ return res.status(401).json({ error: 'Invalid token' });", + "+ }", + "@@ -34,3 +22,2 @@", + "-// TODO: Add updateUser function", + "-// TODO: Add deleteUser function", + "-// TODO: Add listUsers function with pagination", + "+// Note: updateUser, deleteUser, and listUsers endpoints will be added in separate PR", + "+// to keep changes focused and reviewable", + "@@ -41,0 +29,11 @@", + "+", + "+export async function rotateApiKey(req, res) {", + "+ const admin = await db.admins.find(req.user.id);", + "+ if (!admin) {", + "+ return res.status(403).json({ error: 'Forbidden' });", + "+ }", + "+", + "+ const apiKey = await db.tokens.rotate(admin.orgId);", + "+ logger.info('Rotated API key', { orgId: admin.orgId });", + "+ res.json({ apiKey });", + "+}", + ].join("\n"), + edits_applied: 1, + }, + }, + ], + metadata: { + historySequence: 4, + timestamp: STABLE_TIMESTAMP - 270000, + model: "anthropic:claude-sonnet-4-5", + usage: { + inputTokens: 2100, + outputTokens: 680, + totalTokens: 2780, + }, + duration: 4200, + }, + }); + + // Assistant with code block example + callback({ + id: "msg-5", + role: "assistant", + parts: [ + { + type: "text", + text: "Perfect! I've added JWT authentication. Here's what the updated endpoint looks like:\n\n```typescript\nimport { verifyToken } from '../auth/jwt';\n\nexport function getUser(req, res) {\n const token = req.headers.authorization?.split(' ')[1];\n if (!token || !verifyToken(token)) {\n return res.status(401).json({ error: 'Unauthorized' });\n }\n const user = db.users.find(req.params.id);\n res.json(user);\n}\n```\n\nThe endpoint now requires a valid JWT token in the Authorization header. Let me run the tests to verify everything works.", + }, + ], + metadata: { + historySequence: 5, + timestamp: STABLE_TIMESTAMP - 260000, + model: "anthropic:claude-sonnet-4-5", + usage: { + inputTokens: 1800, + outputTokens: 520, + totalTokens: 2320, + }, + duration: 3200, + }, + }); + + // User asking to run tests + callback({ + id: "msg-6", + role: "user", + parts: [ + { type: "text", text: "Can you run the tests to make sure it works?" }, + ], + metadata: { + historySequence: 6, + timestamp: STABLE_TIMESTAMP - 240000, + }, + }); + + // Assistant running tests + callback({ + id: "msg-7", + role: "assistant", + parts: [ + { + type: "text", + text: "I'll run the tests to verify the authentication is working correctly.", + }, + { + type: "dynamic-tool", + toolCallId: "call-3", + toolName: "run_terminal_cmd", + state: "output-available", + input: { + command: "npm test src/api/users.test.ts", + explanation: "Running tests for the users API endpoint", + }, + output: { + success: true, + stdout: + "PASS src/api/users.test.ts\n ✓ should return user when authenticated (24ms)\n ✓ should return 401 when no token (18ms)\n ✓ should return 401 when invalid token (15ms)\n\nTest Suites: 1 passed, 1 total\nTests: 3 passed, 3 total", + exitCode: 0, + }, + }, + ], + metadata: { + historySequence: 7, + timestamp: STABLE_TIMESTAMP - 230000, + model: "anthropic:claude-sonnet-4-5", + usage: { + inputTokens: 2800, + outputTokens: 420, + totalTokens: 3220, + }, + duration: 5100, + }, + }); + + // User follow-up about error handling + callback({ + id: "msg-8", + role: "user", + parts: [ + { + type: "text", + text: "Great! What about error handling if the JWT library throws?", + }, + ], + metadata: { + historySequence: 8, + timestamp: STABLE_TIMESTAMP - 180000, + }, + }); + + // Assistant response with thinking (reasoning) + callback({ + id: "msg-9", + role: "assistant", + parts: [ + { + type: "reasoning", + text: "The user is asking about error handling for JWT verification. The verifyToken function could throw if the token is malformed or if there's an issue with the secret. I should wrap it in a try-catch block and return a proper error response.", + }, + { + type: "text", + text: "Good catch! We should add try-catch error handling around the JWT verification. Let me update that.", + }, + { + type: "dynamic-tool", + toolCallId: "call-4", + toolName: "search_replace", + state: "output-available", + input: { + file_path: "src/api/users.ts", + old_string: + " const token = req.headers.authorization?.split(' ')[1];\n if (!token || !verifyToken(token)) {\n return res.status(401).json({ error: 'Unauthorized' });\n }", + new_string: + " try {\n const token = req.headers.authorization?.split(' ')[1];\n if (!token || !verifyToken(token)) {\n return res.status(401).json({ error: 'Unauthorized' });\n }\n } catch (err) {\n console.error('Token verification failed:', err);\n return res.status(401).json({ error: 'Invalid token' });\n }", + }, + output: { + success: true, + message: "File updated successfully", + }, + }, + ], + metadata: { + historySequence: 9, + timestamp: STABLE_TIMESTAMP - 170000, + model: "anthropic:claude-sonnet-4-5", + usage: { + inputTokens: 3500, + outputTokens: 520, + totalTokens: 4020, + reasoningTokens: 150, + }, + duration: 6200, + }, + }); + + // Assistant quick update with a single-line reasoning trace to exercise inline display + callback({ + id: "msg-9a", + role: "assistant", + parts: [ + { + type: "reasoning", + text: "Cache is warm already; rerunning the full suite would be redundant.", + }, + { + type: "text", + text: "Cache is warm from the last test run, so I'll shift focus to documentation next.", + }, + ], + metadata: { + historySequence: 10, + timestamp: STABLE_TIMESTAMP - 165000, + model: "anthropic:claude-sonnet-4-5", + usage: { + inputTokens: 1200, + outputTokens: 180, + totalTokens: 1380, + reasoningTokens: 20, + }, + duration: 900, + }, + }); + + // Assistant message with status_set tool to show agent status + callback({ + id: "msg-10", + role: "assistant", + parts: [ + { + type: "text", + text: "I've created PR #1234 with the authentication changes. The CI pipeline is running tests now.", + }, + { + type: "dynamic-tool", + toolCallId: "call-5", + toolName: "status_set", + state: "output-available", + input: { + emoji: "🚀", + message: "PR #1234 waiting for CI", + url: "https://github.com/example/repo/pull/1234", + }, + output: { + success: true, + emoji: "🚀", + message: "PR #1234 waiting for CI", + url: "https://github.com/example/repo/pull/1234", + }, + }, + ], + metadata: { + historySequence: 11, + timestamp: STABLE_TIMESTAMP - 160000, + model: "anthropic:claude-sonnet-4-5", + usage: { + inputTokens: 800, + outputTokens: 150, + totalTokens: 950, + }, + duration: 1200, + }, + }); + + // User follow-up asking about documentation + callback({ + id: "msg-11", + role: "user", + parts: [ + { + type: "text", + text: "Should we add documentation for the authentication changes?", + }, + ], + metadata: { + historySequence: 12, + timestamp: STABLE_TIMESTAMP - 150000, + }, + }); + + // Mark as caught up + callback({ type: "caught-up" }); + + // Now start streaming assistant response with reasoning + callback({ + type: "stream-start", + workspaceId: workspaceId, + messageId: "msg-12", + model: "anthropic:claude-sonnet-4-5", + historySequence: 13, + }); + + // Send reasoning delta + callback({ + type: "reasoning-delta", + workspaceId: workspaceId, + messageId: "msg-12", + delta: + "The user is asking about documentation. This is important because the authentication changes introduce a breaking change for API clients. They'll need to know how to include JWT tokens in their requests. I should suggest adding both inline code comments and updating the API documentation to explain the new authentication requirements, including examples of how to obtain and use tokens.", + tokens: 65, + timestamp: STABLE_TIMESTAMP - 140000, + }); + }, 100); + + // Keep sending reasoning deltas to maintain streaming state + // tokens: 0 to avoid flaky token counts in visual tests + const intervalId = setInterval(() => { + callback({ + type: "reasoning-delta", + workspaceId: workspaceId, + messageId: "msg-12", + delta: ".", + tokens: 0, + timestamp: NOW, + }); + }, 2000); + + return () => { + clearInterval(intervalId); + }; + } else if (wsId === streamingWorkspaceId) { + // Streaming workspace - show active work in progress + setTimeout(() => { + const now = NOW; // Use stable timestamp + + // Previous completed message with status_set (MUST be sent BEFORE caught-up) + callback({ + id: "stream-msg-0", + role: "assistant", + parts: [ + { + type: "text", + text: "I'm working on the database refactoring.", + }, + { + type: "dynamic-tool", + toolCallId: "status-call-0", + toolName: "status_set", + state: "output-available", + input: { + emoji: "⚙️", + message: "Refactoring in progress", + }, + output: { + success: true, + emoji: "⚙️", + message: "Refactoring in progress", + }, + }, + ], + metadata: { + historySequence: 0, + timestamp: now - 5000, // 5 seconds ago + model: "anthropic:claude-sonnet-4-5", + usage: { + inputTokens: 200, + outputTokens: 50, + totalTokens: 250, + }, + duration: 800, + }, + }); + + // User message (recent) + callback({ + id: "stream-msg-1", + role: "user", + parts: [ + { + type: "text", + text: "Refactor the database connection to use connection pooling", + }, + ], + metadata: { + historySequence: 1, + timestamp: now - 3000, // 3 seconds ago + }, + }); + + // CRITICAL: Send caught-up AFTER historical messages so they get processed! + // Streaming state is maintained by continuous stream-delta events, not by withholding caught-up + callback({ type: "caught-up" }); + + // Now send stream events - they'll be processed immediately + // Stream start event (very recent - just started) + callback({ + type: "stream-start", + workspaceId: streamingWorkspaceId, + messageId: "stream-msg-2", + model: "anthropic:claude-sonnet-4-5", + historySequence: 2, + }); + + // Stream delta event - shows text being typed out (just happened) + callback({ + type: "stream-delta", + workspaceId: streamingWorkspaceId, + messageId: "stream-msg-2", + delta: + "I'll help you refactor the database connection to use connection pooling.", + tokens: 15, + timestamp: now - 1000, // 1 second ago + }); + + // Tool call start event - shows tool being invoked (happening now) + callback({ + type: "tool-call-start", + workspaceId: streamingWorkspaceId, + messageId: "stream-msg-2", + toolCallId: "stream-call-1", + toolName: "read_file", + args: { target_file: "src/db/connection.ts" }, + tokens: 8, + timestamp: now - 500, // 0.5 seconds ago + }); + }, 100); + + // Keep sending deltas to maintain streaming state + // tokens: 0 to avoid flaky token counts in visual tests + const intervalId = setInterval(() => { + callback({ + type: "stream-delta", + workspaceId: streamingWorkspaceId, + messageId: "stream-msg-2", + delta: ".", + tokens: 0, + timestamp: NOW, + }); + }, 2000); + + // Return cleanup function that stops the interval + return () => clearInterval(intervalId); + } else { + // Other workspaces - send caught-up immediately + setTimeout(() => { + callback({ type: "caught-up" }); + }, 100); + + return () => { + // Cleanup + }; + } + }, + onMetadata: () => () => undefined, + activity: { + list: () => Promise.resolve({}), + subscribe: () => () => undefined, + }, + sendMessage: () => Promise.resolve({ success: true, data: undefined }), + resumeStream: () => Promise.resolve({ success: true, data: undefined }), + interruptStream: () => Promise.resolve({ success: true, data: undefined }), + clearQueue: () => Promise.resolve({ success: true, data: undefined }), + truncateHistory: () => Promise.resolve({ success: true, data: undefined }), + replaceChatHistory: () => Promise.resolve({ success: true, data: undefined }), + getInfo: () => Promise.resolve(null), + executeBash: (wsId: string, command: string) => { + // Mock git status script responses for each workspace + const gitStatusMocks: Record = { + [workspaceId]: `---PRIMARY--- main ---SHOW_BRANCH--- ! [HEAD] WIP: Add JWT authentication @@ -435,7 +1170,7 @@ main - [i7j8k9l] Add tests ---DIRTY--- 3`, - [streamingWorkspaceId]: `---PRIMARY--- + [streamingWorkspaceId]: `---PRIMARY--- main ---SHOW_BRANCH--- ! [HEAD] Refactoring database connection @@ -445,7 +1180,7 @@ main - [f5g6h7i] Add retry logic ---DIRTY--- 1`, - "ws-clean": `---PRIMARY--- + "ws-clean": `---PRIMARY--- main ---SHOW_BRANCH--- ! [HEAD] Latest commit @@ -454,7 +1189,7 @@ main ++ [m1n2o3p] Latest commit ---DIRTY--- 0`, - "ws-ahead": `---PRIMARY--- + "ws-ahead": `---PRIMARY--- main ---SHOW_BRANCH--- ! [HEAD] Add new dashboard design @@ -464,7 +1199,7 @@ main - [g6h7i8j] Update styles ---DIRTY--- 0`, - "ws-behind": `---PRIMARY--- + "ws-behind": `---PRIMARY--- main ---SHOW_BRANCH--- ! [origin/main] Latest API changes @@ -474,7 +1209,7 @@ main + [h7i8j9k] Fix API bug ---DIRTY--- 0`, - "ws-dirty": `---PRIMARY--- + "ws-dirty": `---PRIMARY--- main ---SHOW_BRANCH--- ! [HEAD] Fix null pointer @@ -483,7 +1218,7 @@ main - [e5f6g7h] Fix null pointer ---DIRTY--- 7`, - "ws-diverged": `---PRIMARY--- + "ws-diverged": `---PRIMARY--- main ---SHOW_BRANCH--- ! [HEAD] Database migration @@ -494,7 +1229,7 @@ main + [l2m3n4o] Hotfix on main ---DIRTY--- 5`, - "ws-ssh": `---PRIMARY--- + "ws-ssh": `---PRIMARY--- main ---SHOW_BRANCH--- ! [HEAD] Production deployment @@ -503,581 +1238,52 @@ main - [g7h8i9j] Production deployment ---DIRTY--- 0`, - }; - - const executeBash = (wsId: string, script: string) => { - if (script.includes("git status") || script.includes("git show-branch")) { - const output = gitStatusMocks[wsId] || ""; - return Promise.resolve({ - success: true as const, - output, - exitCode: 0, - wall_duration_ms: 50, - }); - } - return Promise.resolve({ - success: true as const, - output: "", - exitCode: 0, - wall_duration_ms: 0, - }); - }; - - const onChat = (wsId: string, callback: (msg: WorkspaceChatMessage) => void) => { - // Active workspace with complete chat history - if (wsId === workspaceId) { - setTimeout(() => { - // User message - callback({ - id: "msg-1", - role: "user", - parts: [{ type: "text", text: "Add authentication to the user API endpoint" }], - createdAt: new Date(STABLE_TIMESTAMP - 300000), - }); - - // Assistant message with tool calls - callback({ - id: "msg-2", - role: "assistant", - parts: [ - { - type: "text", - text: "I'll help you add authentication to the user API endpoint. Let me first check the current implementation.", - }, - { - type: "dynamic-tool", - toolCallId: "call-1", - toolName: "read_file", - state: "output-available", - input: { target_file: "src/api/users.ts" }, - output: { - success: true, - content: - "export function getUser(req, res) {\n const user = db.users.find(req.params.id);\n res.json(user);\n}", - }, - }, - ], - metadata: { - historySequence: 2, - timestamp: STABLE_TIMESTAMP - 290000, - model: "anthropic:claude-sonnet-4-5", - usage: { - inputTokens: 1250, - outputTokens: 450, - totalTokens: 1700, - }, - duration: 3500, - }, - }); - - // User response - callback({ - id: "msg-3", - role: "user", - parts: [{ type: "text", text: "Yes, add JWT token validation" }], - metadata: { - historySequence: 3, - timestamp: STABLE_TIMESTAMP - 280000, - }, - }); - - // Assistant message with file edit (large diff) - callback({ - id: "msg-4", - role: "assistant", - parts: [ - { - type: "text", - text: "I'll add JWT token validation to the endpoint. Let me update the file with proper authentication middleware and error handling.", - }, - { - type: "dynamic-tool", - toolCallId: "call-2", - toolName: "file_edit_replace_string", - state: "output-available", - input: { - file_path: "src/api/users.ts", - old_string: - "import express from 'express';\nimport { db } from '../db';\n\nexport function getUser(req, res) {\n const user = db.users.find(req.params.id);\n res.json(user);\n}", - new_string: - "import express from 'express';\nimport { db } from '../db';\nimport { verifyToken } from '../auth/jwt';\nimport { logger } from '../utils/logger';\n\nexport async function getUser(req, res) {\n try {\n const token = req.headers.authorization?.split(' ')[1];\n if (!token) {\n logger.warn('Missing authorization token');\n return res.status(401).json({ error: 'Unauthorized' });\n }\n const decoded = await verifyToken(token);\n const user = await db.users.find(req.params.id);\n res.json(user);\n } catch (err) {\n logger.error('Auth error:', err);\n return res.status(401).json({ error: 'Invalid token' });\n }\n}", - }, - output: { - success: true, - diff: [ - "--- src/api/users.ts", - "+++ src/api/users.ts", - "@@ -2,0 +3,2 @@", - "+import { verifyToken } from '../auth/jwt';", - "+import { logger } from '../utils/logger';", - "@@ -4,28 +6,14 @@", - "-// TODO: Add authentication middleware", - "-// Current implementation is insecure and allows unauthorized access", - "-// Need to validate JWT tokens before processing requests", - "-// Also need to add rate limiting to prevent abuse", - "-// Consider adding request logging for audit trail", - "-// Add input validation for user IDs", - "-// Handle edge cases for deleted/suspended users", - "-", - "-/**", - "- * Get user by ID", - "- * @param {Object} req - Express request object", - "- * @param {Object} res - Express response object", - "- */", - "-export function getUser(req, res) {", - "- // FIXME: No authentication check", - "- // FIXME: No error handling", - "- // FIXME: Synchronous database call blocks event loop", - "- // FIXME: No input validation", - "- // FIXME: Direct database access without repository pattern", - "- // FIXME: No logging", - "-", - "- const user = db.users.find(req.params.id);", - "-", - "- // TODO: Check if user exists", - "- // TODO: Filter sensitive fields (password hash, etc)", - "- // TODO: Check permissions - user should only access their own data", - "-", - "- res.json(user);", - "+export async function getUser(req, res) {", - "+ try {", - "+ const token = req.headers.authorization?.split(' ')[1];", - "+ if (!token) {", - "+ logger.warn('Missing authorization token');", - "+ return res.status(401).json({ error: 'Unauthorized' });", - "+ }", - "+ const decoded = await verifyToken(token);", - "+ const user = await db.users.find(req.params.id);", - "+ res.json(user);", - "+ } catch (err) {", - "+ logger.error('Auth error:', err);", - "+ return res.status(401).json({ error: 'Invalid token' });", - "+ }", - "@@ -34,3 +22,2 @@", - "-// TODO: Add updateUser function", - "-// TODO: Add deleteUser function", - "-// TODO: Add listUsers function with pagination", - "+// Note: updateUser, deleteUser, and listUsers endpoints will be added in separate PR", - "+// to keep changes focused and reviewable", - "@@ -41,0 +29,11 @@", - "+", - "+export async function rotateApiKey(req, res) {", - "+ const admin = await db.admins.find(req.user.id);", - "+ if (!admin) {", - "+ return res.status(403).json({ error: 'Forbidden' });", - "+ }", - "+", - "+ const apiKey = await db.tokens.rotate(admin.orgId);", - "+ logger.info('Rotated API key', { orgId: admin.orgId });", - "+ res.json({ apiKey });", - "+}", - ].join("\n"), - edits_applied: 1, - }, - }, - ], - metadata: { - historySequence: 4, - timestamp: STABLE_TIMESTAMP - 270000, - model: "anthropic:claude-sonnet-4-5", - usage: { - inputTokens: 2100, - outputTokens: 680, - totalTokens: 2780, - }, - duration: 4200, - }, - }); - - // Assistant with code block example - callback({ - id: "msg-5", - role: "assistant", - parts: [ - { - type: "text", - text: "Perfect! I've added JWT authentication. Here's what the updated endpoint looks like:\n\n```typescript\nimport { verifyToken } from '../auth/jwt';\n\nexport function getUser(req, res) {\n const token = req.headers.authorization?.split(' ')[1];\n if (!token || !verifyToken(token)) {\n return res.status(401).json({ error: 'Unauthorized' });\n }\n const user = db.users.find(req.params.id);\n res.json(user);\n}\n```\n\nThe endpoint now requires a valid JWT token in the Authorization header. Let me run the tests to verify everything works.", - }, - ], - metadata: { - historySequence: 5, - timestamp: STABLE_TIMESTAMP - 260000, - model: "anthropic:claude-sonnet-4-5", - usage: { - inputTokens: 1800, - outputTokens: 520, - totalTokens: 2320, - }, - duration: 3200, - }, - }); - - // User asking to run tests - callback({ - id: "msg-6", - role: "user", - parts: [{ type: "text", text: "Can you run the tests to make sure it works?" }], - metadata: { - historySequence: 6, - timestamp: STABLE_TIMESTAMP - 240000, - }, - }); - - // Assistant running tests - callback({ - id: "msg-7", - role: "assistant", - parts: [ - { - type: "text", - text: "I'll run the tests to verify the authentication is working correctly.", - }, - { - type: "dynamic-tool", - toolCallId: "call-3", - toolName: "run_terminal_cmd", - state: "output-available", - input: { - command: "npm test src/api/users.test.ts", - explanation: "Running tests for the users API endpoint", - }, - output: { - success: true, - stdout: - "PASS src/api/users.test.ts\n ✓ should return user when authenticated (24ms)\n ✓ should return 401 when no token (18ms)\n ✓ should return 401 when invalid token (15ms)\n\nTest Suites: 1 passed, 1 total\nTests: 3 passed, 3 total", - exitCode: 0, - }, - }, - ], - metadata: { - historySequence: 7, - timestamp: STABLE_TIMESTAMP - 230000, - model: "anthropic:claude-sonnet-4-5", - usage: { - inputTokens: 2800, - outputTokens: 420, - totalTokens: 3220, - }, - duration: 5100, - }, - }); - - // User follow-up about error handling - callback({ - id: "msg-8", - role: "user", - parts: [ - { - type: "text", - text: "Great! What about error handling if the JWT library throws?", - }, - ], - metadata: { - historySequence: 8, - timestamp: STABLE_TIMESTAMP - 180000, - }, - }); - - // Assistant response with thinking (reasoning) - callback({ - id: "msg-9", - role: "assistant", - parts: [ - { - type: "reasoning", - text: "The user is asking about error handling for JWT verification. The verifyToken function could throw if the token is malformed or if there's an issue with the secret. I should wrap it in a try-catch block and return a proper error response.", - }, - { - type: "text", - text: "Good catch! We should add try-catch error handling around the JWT verification. Let me update that.", - }, - { - type: "dynamic-tool", - toolCallId: "call-4", - toolName: "search_replace", - state: "output-available", - input: { - file_path: "src/api/users.ts", - old_string: - " const token = req.headers.authorization?.split(' ')[1];\n if (!token || !verifyToken(token)) {\n return res.status(401).json({ error: 'Unauthorized' });\n }", - new_string: - " try {\n const token = req.headers.authorization?.split(' ')[1];\n if (!token || !verifyToken(token)) {\n return res.status(401).json({ error: 'Unauthorized' });\n }\n } catch (err) {\n console.error('Token verification failed:', err);\n return res.status(401).json({ error: 'Invalid token' });\n }", - }, - output: { - success: true, - message: "File updated successfully", - }, - }, - ], - metadata: { - historySequence: 9, - timestamp: STABLE_TIMESTAMP - 170000, - model: "anthropic:claude-sonnet-4-5", - usage: { - inputTokens: 3500, - outputTokens: 520, - totalTokens: 4020, - reasoningTokens: 150, - }, - duration: 6200, - }, - }); - - // Assistant quick update with a single-line reasoning trace to exercise inline display - callback({ - id: "msg-9a", - role: "assistant", - parts: [ - { - type: "reasoning", - text: "Cache is warm already; rerunning the full suite would be redundant.", - }, - { - type: "text", - text: "Cache is warm from the last test run, so I'll shift focus to documentation next.", - }, - ], - metadata: { - historySequence: 10, - timestamp: STABLE_TIMESTAMP - 165000, - model: "anthropic:claude-sonnet-4-5", - usage: { - inputTokens: 1200, - outputTokens: 180, - totalTokens: 1380, - reasoningTokens: 20, - }, - duration: 900, - }, - }); - - // Assistant message with status_set tool to show agent status - callback({ - id: "msg-10", - role: "assistant", - parts: [ - { - type: "text", - text: "I've created PR #1234 with the authentication changes. The CI pipeline is running tests now.", - }, - { - type: "dynamic-tool", - toolCallId: "call-5", - toolName: "status_set", - state: "output-available", - input: { - emoji: "🚀", - message: "PR #1234 waiting for CI", - url: "https://github.com/example/repo/pull/1234", - }, - output: { + }; + + // Return mock git status if this is the git status script + if (command.includes("git status") || command.includes("git show-branch")) { + const output = gitStatusMocks[wsId] || ""; + return Promise.resolve({ + success: true, + data: { success: true, output, exitCode: 0, wall_duration_ms: 50 }, + }); + } + + // Default response for other commands + return Promise.resolve({ success: true, - emoji: "🚀", - message: "PR #1234 waiting for CI", - url: "https://github.com/example/repo/pull/1234", - }, - }, - ], - metadata: { - historySequence: 11, - timestamp: STABLE_TIMESTAMP - 160000, - model: "anthropic:claude-sonnet-4-5", - usage: { - inputTokens: 800, - outputTokens: 150, - totalTokens: 950, - }, - duration: 1200, - }, - }); - - // User follow-up asking about documentation - callback({ - id: "msg-11", - role: "user", - parts: [ - { - type: "text", - text: "Should we add documentation for the authentication changes?", + data: { success: true, output: "", exitCode: 0, wall_duration_ms: 0 }, + }); }, - ], - metadata: { - historySequence: 12, - timestamp: STABLE_TIMESTAMP - 150000, }, - }); - - // Mark as caught up - callback({ type: "caught-up" }); + }, + }); - // Now start streaming assistant response with reasoning - callback({ - type: "stream-start", - workspaceId: workspaceId, - messageId: "msg-12", - model: "anthropic:claude-sonnet-4-5", - historySequence: 13, - }); - - // Send reasoning delta - callback({ - type: "reasoning-delta", - workspaceId: workspaceId, - messageId: "msg-12", - delta: - "The user is asking about documentation. This is important because the authentication changes introduce a breaking change for API clients. They'll need to know how to include JWT tokens in their requests. I should suggest adding both inline code comments and updating the API documentation to explain the new authentication requirements, including examples of how to obtain and use tokens.", - tokens: 65, - timestamp: STABLE_TIMESTAMP - 140000, - }); - }, 100); - - // Keep sending reasoning deltas to maintain streaming state - // tokens: 0 to avoid flaky token counts in visual tests - const intervalId = setInterval(() => { - callback({ - type: "reasoning-delta", + // Set initial workspace selection + localStorage.setItem( + "selectedWorkspace", + JSON.stringify({ workspaceId: workspaceId, - messageId: "msg-12", - delta: ".", - tokens: 0, - timestamp: NOW, - }); - }, 2000); - - return () => { - clearInterval(intervalId); - }; - } else if (wsId === streamingWorkspaceId) { - // Streaming workspace - show active work in progress - setTimeout(() => { - const now = NOW; // Use stable timestamp - - // Previous completed message with status_set (MUST be sent BEFORE caught-up) - callback({ - id: "stream-msg-0", - role: "assistant", - parts: [ - { - type: "text", - text: "I'm working on the database refactoring.", - }, - { - type: "dynamic-tool", - toolCallId: "status-call-0", - toolName: "status_set", - state: "output-available", - input: { - emoji: "⚙️", - message: "Refactoring in progress", - }, - output: { - success: true, - emoji: "⚙️", - message: "Refactoring in progress", - }, - }, - ], - metadata: { - historySequence: 0, - timestamp: now - 5000, // 5 seconds ago - model: "anthropic:claude-sonnet-4-5", - usage: { - inputTokens: 200, - outputTokens: 50, - totalTokens: 250, - }, - duration: 800, - }, - }); - - // User message (recent) - callback({ - id: "stream-msg-1", - role: "user", - parts: [ - { - type: "text", - text: "Refactor the database connection to use connection pooling", - }, - ], - metadata: { - historySequence: 1, - timestamp: now - 3000, // 3 seconds ago - }, - }); - - // CRITICAL: Send caught-up AFTER historical messages so they get processed! - // Streaming state is maintained by continuous stream-delta events, not by withholding caught-up - callback({ type: "caught-up" }); - - // Now send stream events - they'll be processed immediately - // Stream start event (very recent - just started) - callback({ - type: "stream-start", - workspaceId: streamingWorkspaceId, - messageId: "stream-msg-2", - model: "anthropic:claude-sonnet-4-5", - historySequence: 2, - }); - - // Stream delta event - shows text being typed out (just happened) - callback({ - type: "stream-delta", - workspaceId: streamingWorkspaceId, - messageId: "stream-msg-2", - delta: "I'll help you refactor the database connection to use connection pooling.", - tokens: 15, - timestamp: now - 1000, // 1 second ago - }); - - // Tool call start event - shows tool being invoked (happening now) - callback({ - type: "tool-call-start", - workspaceId: streamingWorkspaceId, - messageId: "stream-msg-2", - toolCallId: "stream-call-1", - toolName: "read_file", - args: { target_file: "src/db/connection.ts" }, - tokens: 8, - timestamp: now - 500, // 0.5 seconds ago - }); - }, 100); - - // Keep sending deltas to maintain streaming state - // tokens: 0 to avoid flaky token counts in visual tests - const intervalId = setInterval(() => { - callback({ - type: "stream-delta", - workspaceId: streamingWorkspaceId, - messageId: "stream-msg-2", - delta: ".", - tokens: 0, - timestamp: NOW, - }); - }, 2000); - - // Return cleanup function that stops the interval - return () => clearInterval(intervalId); - } else { - // Other workspaces - send caught-up immediately - setTimeout(() => { - callback({ type: "caught-up" }); - }, 100); - - return () => { - // Cleanup - }; + projectPath: "/home/user/projects/my-app", + projectName: "my-app", + namedWorkspacePath: "/home/user/.mux/src/my-app/feature", + }) + ); + + // Pre-fill input with text so token count is visible + localStorage.setItem( + `input:${workspaceId}`, + "Add OAuth2 support with Google and GitHub providers" + ); + localStorage.setItem(`model:${workspaceId}`, "anthropic:claude-sonnet-4-5"); + + initialized.current = true; } + + return ; }; - return ( - - ); + return ; }, }; @@ -1087,62 +1293,80 @@ main */ export const MarkdownTables: Story = { render: () => { - const workspaceId = "my-app-feature"; - - const workspaces: FrontendWorkspaceMetadata[] = [ - { - id: workspaceId, - name: "feature", - projectPath: "/home/user/projects/my-app", - projectName: "my-app", - namedWorkspacePath: "/home/user/.mux/src/my-app/feature", - runtimeConfig: DEFAULT_RUNTIME_CONFIG, - }, - ]; - - const projects = new Map([ - [ - "/home/user/projects/my-app", - { - workspaces: [ - { path: "/home/user/.mux/src/my-app/feature", id: workspaceId, name: "feature" }, - ], - }, - ], - ]); - - // Set initial workspace selection - localStorage.setItem( - "selectedWorkspace", - JSON.stringify({ - workspaceId: workspaceId, - projectPath: "/home/user/projects/my-app", - projectName: "my-app", - namedWorkspacePath: "/home/user/.mux/src/my-app/feature", - }) - ); - - const onChat = (_wsId: string, emit: (msg: WorkspaceChatMessage) => void) => { - setTimeout(() => { - // User message - emit({ - id: "msg-1", - role: "user", - parts: [{ type: "text", text: "Show me some table examples" }], - metadata: { - historySequence: 1, - timestamp: STABLE_TIMESTAMP, + const AppWithTableMocks = () => { + const initialized = useRef(false); + + if (!initialized.current) { + const workspaceId = "my-app-feature"; + + const workspaces: FrontendWorkspaceMetadata[] = [ + { + id: workspaceId, + name: "feature", + projectPath: "/home/user/projects/my-app", + projectName: "my-app", + namedWorkspacePath: "/home/user/.mux/src/my-app/feature", + runtimeConfig: DEFAULT_RUNTIME_CONFIG, }, - } as WorkspaceChatMessage); + ]; - // Assistant message with tables - emit({ - id: "msg-2", - role: "assistant", - parts: [ - { - type: "text", - text: `Here are various markdown table examples: + setupMockAPI({ + projects: new Map([ + [ + "/home/user/projects/my-app", + { + workspaces: [ + { path: "/home/user/.mux/src/my-app/feature", id: workspaceId, name: "feature" }, + ], + }, + ], + ]), + workspaces, + selectedWorkspaceId: workspaceId, + apiOverrides: { + workspace: { + create: (projectPath: string, branchName: string) => + Promise.resolve({ + success: true, + metadata: { + id: Math.random().toString(36).substring(2, 12), + name: branchName, + projectPath, + projectName: projectPath.split("/").pop() ?? "project", + namedWorkspacePath: `/mock/workspace/${branchName}`, + runtimeConfig: DEFAULT_RUNTIME_CONFIG, + }, + }), + list: () => Promise.resolve(workspaces), + rename: (workspaceId: string) => + Promise.resolve({ + success: true, + data: { newWorkspaceId: workspaceId }, + }), + remove: () => Promise.resolve({ success: true }), + fork: () => Promise.resolve({ success: false, error: "Not implemented in mock" }), + openTerminal: () => Promise.resolve(undefined), + onChat: (workspaceId, callback) => { + setTimeout(() => { + // User message + callback({ + id: "msg-1", + role: "user", + parts: [{ type: "text", text: "Show me some table examples" }], + metadata: { + historySequence: 1, + timestamp: STABLE_TIMESTAMP, + }, + }); + + // Assistant message with tables + callback({ + id: "msg-2", + role: "assistant", + parts: [ + { + type: "text", + text: `Here are various markdown table examples: ## Simple Table @@ -1199,26 +1423,67 @@ export const MarkdownTables: Story = { | \`server.port\` | 3000 | Port number for HTTP server | \`PORT\` | These tables should render cleanly without any disruptive copy or download actions.`, + }, + ], + metadata: { + historySequence: 2, + timestamp: STABLE_TIMESTAMP + 1000, + model: "anthropic:claude-sonnet-4-5", + usage: { + inputTokens: 100, + outputTokens: 500, + totalTokens: 600, + }, + duration: 2000, + }, + }); + + // Mark as caught up + callback({ type: "caught-up" }); + }, 100); + + return () => { + // Cleanup + }; + }, + onMetadata: () => () => undefined, + activity: { + list: () => Promise.resolve({}), + subscribe: () => () => undefined, + }, + sendMessage: () => Promise.resolve({ success: true, data: undefined }), + resumeStream: () => Promise.resolve({ success: true, data: undefined }), + interruptStream: () => Promise.resolve({ success: true, data: undefined }), + clearQueue: () => Promise.resolve({ success: true, data: undefined }), + truncateHistory: () => Promise.resolve({ success: true, data: undefined }), + replaceChatHistory: () => Promise.resolve({ success: true, data: undefined }), + getInfo: () => Promise.resolve(null), + executeBash: () => + Promise.resolve({ + success: true, + data: { success: true, output: "", exitCode: 0, wall_duration_ms: 0 }, + }), }, - ], - metadata: { - historySequence: 2, - timestamp: STABLE_TIMESTAMP + 1000, - model: "anthropic:claude-sonnet-4-5", - usage: { - inputTokens: 100, - outputTokens: 500, - totalTokens: 600, - }, - duration: 2000, }, - } as WorkspaceChatMessage); + }); + + // Set initial workspace selection + localStorage.setItem( + "selectedWorkspace", + JSON.stringify({ + workspaceId: workspaceId, + projectPath: "/home/user/projects/my-app", + projectName: "my-app", + namedWorkspacePath: "/home/user/.mux/src/my-app/feature", + }) + ); + + initialized.current = true; + } - // Mark as caught up - emit({ type: "caught-up" } as WorkspaceChatMessage); - }, 100); + return ; }; - return ; + return ; }, }; diff --git a/src/browser/App.tsx b/src/browser/App.tsx index 4867dc743..7de592e5e 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -18,7 +18,6 @@ import type { ChatInputAPI } from "./components/ChatInput/types"; import { useStableReference, compareMaps } from "./hooks/useStableReference"; import { CommandRegistryProvider, useCommandRegistry } from "./contexts/CommandRegistryContext"; -import { useOpenTerminal } from "./hooks/useOpenTerminal"; import type { CommandAction } from "./contexts/CommandRegistryContext"; import { ModeProvider } from "./contexts/ModeContext"; import { ProviderOptionsProvider } from "./contexts/ProviderOptionsContext"; @@ -31,10 +30,9 @@ import type { ThinkingLevel } from "@/common/types/thinking"; import { CUSTOM_EVENTS } from "@/common/constants/events"; import { isWorkspaceForkSwitchEvent } from "./utils/workspaceEvents"; import { getThinkingLevelKey } from "@/common/constants/storage"; -import type { BranchListResult } from "@/common/orpc/types"; +import type { BranchListResult } from "@/common/types/ipc"; import { useTelemetry } from "./hooks/useTelemetry"; import { useStartWorkspaceCreation, getFirstProjectPath } from "./hooks/useStartWorkspaceCreation"; -import { useORPC } from "@/browser/orpc/react"; import { SettingsProvider, useSettings } from "./contexts/SettingsContext"; import { SettingsModal } from "./components/Settings/SettingsModal"; @@ -62,7 +60,6 @@ function AppInner() { }, [setTheme] ); - const client = useORPC(); const { projects, removeProject, @@ -144,19 +141,15 @@ function AppInner() { const metadata = workspaceMetadata.get(selectedWorkspace.workspaceId); const workspaceName = metadata?.name ?? selectedWorkspace.workspaceId; const title = `${workspaceName} - ${selectedWorkspace.projectName} - mux`; - // Set document.title locally for browser mode, call backend for Electron - document.title = title; - void client.window.setTitle({ title }); + void window.api.window.setTitle(title); } else { // Clear hash when no workspace selected if (window.location.hash) { window.history.replaceState(null, "", window.location.pathname); } - // Set document.title locally for browser mode, call backend for Electron - document.title = "mux"; - void client.window.setTitle({ title: "mux" }); + void window.api.window.setTitle("mux"); } - }, [selectedWorkspace, workspaceMetadata, client]); + }, [selectedWorkspace, workspaceMetadata]); // Validate selected workspace exists and has all required fields useEffect(() => { if (selectedWorkspace) { @@ -184,7 +177,9 @@ function AppInner() { } }, [selectedWorkspace, workspaceMetadata, setSelectedWorkspace]); - const openWorkspaceInTerminal = useOpenTerminal(); + const openWorkspaceInTerminal = useCallback((workspaceId: string) => { + void window.api.terminal.openWindow(workspaceId); + }, []); const handleRemoveProject = useCallback( async (path: string) => { @@ -344,21 +339,23 @@ function AppInner() { const getBranchesForProject = useCallback( async (projectPath: string): Promise => { - const branchResult = await client.projects.listBranches({ projectPath }); - const sanitizedBranches = branchResult.branches.filter( - (branch): branch is string => typeof branch === "string" - ); + const branchResult = await window.api.projects.listBranches(projectPath); + const sanitizedBranches = Array.isArray(branchResult?.branches) + ? branchResult.branches.filter((branch): branch is string => typeof branch === "string") + : []; - const recommended = sanitizedBranches.includes(branchResult.recommendedTrunk) - ? branchResult.recommendedTrunk - : (sanitizedBranches[0] ?? ""); + const recommended = + typeof branchResult?.recommendedTrunk === "string" && + sanitizedBranches.includes(branchResult.recommendedTrunk) + ? branchResult.recommendedTrunk + : (sanitizedBranches[0] ?? ""); return { branches: sanitizedBranches, recommendedTrunk: recommended, }; }, - [client] + [] ); const selectWorkspaceFromPalette = useCallback( @@ -420,7 +417,6 @@ function AppInner() { onToggleTheme: toggleTheme, onSetTheme: setThemePreference, onOpenSettings: openSettings, - client, }; useEffect(() => { @@ -532,12 +528,12 @@ function AppInner() { const handleProviderConfig = useCallback( async (provider: string, keyPath: string[], value: string) => { - const result = await client.providers.setProviderConfig({ provider, keyPath, value }); + const result = await window.api.providers.setProviderConfig(provider, keyPath, value); if (!result.success) { throw new Error(result.error); } }, - [client] + [] ); return ( diff --git a/src/browser/api.test.ts b/src/browser/api.test.ts new file mode 100644 index 000000000..9be68459a --- /dev/null +++ b/src/browser/api.test.ts @@ -0,0 +1,156 @@ +/** + * Tests for browser API client + * Tests the invokeIPC function to ensure it behaves consistently with Electron's ipcRenderer.invoke() + */ + +import { describe, test, expect } from "bun:test"; + +// Helper to create a mock fetch that returns a specific response +function createMockFetch(responseData: unknown) { + return () => { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(responseData), + } as Response); + }; +} + +interface InvokeResponse { + success: boolean; + data?: T; + error?: unknown; +} + +// Helper to create invokeIPC function with mocked fetch +function createInvokeIPC( + mockFetch: (url: string, init?: RequestInit) => Promise +): (channel: string, ...args: unknown[]) => Promise { + const API_BASE = "http://localhost:3000"; + + async function invokeIPC(channel: string, ...args: unknown[]): Promise { + const response = await mockFetch(`${API_BASE}/ipc/${encodeURIComponent(channel)}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ args }), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const result = (await response.json()) as InvokeResponse; + + // Return the result as-is - let the caller handle success/failure + // This matches the behavior of Electron's ipcRenderer.invoke() which doesn't throw on error + if (!result.success) { + return result as T; + } + + // Success - unwrap and return the data + return result.data as T; + } + + return invokeIPC; +} + +describe("Browser API invokeIPC", () => { + test("should return error object on failure (matches Electron behavior)", async () => { + const mockFetch = createMockFetch({ + success: false, + error: "fatal: contains modified or untracked files", + }); + + const invokeIPC = createInvokeIPC(mockFetch); + + // Fixed behavior: invokeIPC returns error object instead of throwing + // This matches Electron's ipcRenderer.invoke() which never throws on error + const result = await invokeIPC<{ success: boolean; error?: string }>( + "WORKSPACE_REMOVE", + "test-workspace", + { force: false } + ); + + expect(result).toEqual({ + success: false, + error: "fatal: contains modified or untracked files", + }); + }); + + test("should return success data on success", async () => { + const mockFetch = createMockFetch({ + success: true, + data: { someData: "value" }, + }); + + const invokeIPC = createInvokeIPC(mockFetch); + + const result = await invokeIPC("WORKSPACE_REMOVE", "test-workspace", { force: true }); + + expect(result).toEqual({ someData: "value" }); + }); + + test("should throw on HTTP errors", async () => { + const mockFetch = () => { + return Promise.resolve({ + ok: false, + status: 500, + } as Response); + }; + + const invokeIPC = createInvokeIPC(mockFetch); + + // eslint-disable-next-line @typescript-eslint/await-thenable + await expect(invokeIPC("WORKSPACE_REMOVE", "test-workspace", { force: false })).rejects.toThrow( + "HTTP error! status: 500" + ); + }); + + test("should return structured error objects as-is", async () => { + const structuredError = { + type: "STREAMING_IN_PROGRESS", + message: "Cannot send message while streaming", + workspaceId: "test-workspace", + }; + + const mockFetch = createMockFetch({ + success: false, + error: structuredError, + }); + + const invokeIPC = createInvokeIPC(mockFetch); + + const result = await invokeIPC("WORKSPACE_SEND_MESSAGE", "test-workspace", { + role: "user", + content: [{ type: "text", text: "test" }], + }); + + // Structured errors should be returned as-is + expect(result).toEqual({ + success: false, + error: structuredError, + }); + }); + + test("should handle failed Result without error property", async () => { + // This tests the fix for the force-deletion bug where results like + // { success: false } (without error property) weren't being passed through correctly + const mockFetch = createMockFetch({ + success: false, + }); + + const invokeIPC = createInvokeIPC(mockFetch); + + const result = await invokeIPC<{ success: boolean; error?: string }>( + "WORKSPACE_REMOVE", + "test-workspace", + { force: false } + ); + + // Should return the failure result as-is, even without error property + expect(result).toEqual({ + success: false, + }); + }); +}); diff --git a/src/browser/api.ts b/src/browser/api.ts new file mode 100644 index 000000000..33b9ad37a --- /dev/null +++ b/src/browser/api.ts @@ -0,0 +1,390 @@ +/** + * Browser API client. Used when running mux in server mode. + */ +import { IPC_CHANNELS, getChatChannel } from "@/common/constants/ipc-constants"; +import type { IPCApi } from "@/common/types/ipc"; +import type { WorkspaceActivitySnapshot } from "@/common/types/workspace"; + +// Backend URL - defaults to same origin, but can be overridden via VITE_BACKEND_URL +// This allows frontend (Vite :8080) to connect to backend (:3000) in dev mode +const API_BASE = import.meta.env.VITE_BACKEND_URL ?? window.location.origin; +const WS_BASE = API_BASE.replace("http://", "ws://").replace("https://", "wss://"); + +interface InvokeResponse { + success: boolean; + data?: T; + error?: unknown; // Can be string or structured error object +} + +// Helper function to invoke IPC handlers via HTTP +async function invokeIPC(channel: string, ...args: unknown[]): Promise { + const response = await fetch(`${API_BASE}/ipc/${encodeURIComponent(channel)}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ args }), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const result = (await response.json()) as InvokeResponse; + + // Return the result as-is - let the caller handle success/failure + // This matches the behavior of Electron's ipcRenderer.invoke() which doesn't throw on error + if (!result.success) { + return result as T; + } + + // Success - unwrap and return the data + return result.data as T; +} + +function parseWorkspaceActivity(value: unknown): WorkspaceActivitySnapshot | null { + if (!value || typeof value !== "object") { + return null; + } + const record = value as Record; + const recency = + typeof record.recency === "number" && Number.isFinite(record.recency) ? record.recency : null; + if (recency === null) { + return null; + } + const streaming = record.streaming === true; + const lastModel = typeof record.lastModel === "string" ? record.lastModel : null; + return { + recency, + streaming, + lastModel, + }; +} + +// WebSocket connection manager +class WebSocketManager { + private ws: WebSocket | null = null; + private reconnectTimer: ReturnType | null = null; + private messageHandlers = new Map void>>(); + private channelWorkspaceIds = new Map(); // Track workspaceId for each channel + private isConnecting = false; + private shouldReconnect = true; + + connect(): void { + if (this.ws?.readyState === WebSocket.OPEN || this.isConnecting) { + return; + } + + this.isConnecting = true; + this.ws = new WebSocket(`${WS_BASE}/ws`); + + this.ws.onopen = () => { + console.log("WebSocket connected"); + this.isConnecting = false; + + // Resubscribe to all channels with their workspace IDs + for (const channel of this.messageHandlers.keys()) { + const workspaceId = this.channelWorkspaceIds.get(channel); + this.subscribe(channel, workspaceId); + } + }; + + this.ws.onmessage = (event) => { + try { + const parsed = JSON.parse(event.data as string) as { channel: string; args: unknown[] }; + const { channel, args } = parsed; + const handlers = this.messageHandlers.get(channel); + if (handlers && args.length > 0) { + handlers.forEach((handler) => handler(args[0])); + } + } catch (error) { + console.error("Error handling WebSocket message:", error); + } + }; + + this.ws.onerror = (error) => { + console.error("WebSocket error:", error); + this.isConnecting = false; + }; + + this.ws.onclose = () => { + console.log("WebSocket disconnected"); + this.isConnecting = false; + this.ws = null; + + // Attempt to reconnect after a delay + if (this.shouldReconnect) { + this.reconnectTimer = setTimeout(() => this.connect(), 2000); + } + }; + } + + subscribe(channel: string, workspaceId?: string): void { + if (this.ws?.readyState === WebSocket.OPEN) { + if (channel.startsWith(IPC_CHANNELS.WORKSPACE_CHAT_PREFIX)) { + console.log( + `[WebSocketManager] Subscribing to workspace chat for workspaceId: ${workspaceId ?? "undefined"}` + ); + this.ws.send( + JSON.stringify({ + type: "subscribe", + channel: "workspace:chat", + workspaceId, + }) + ); + } else if (channel === IPC_CHANNELS.WORKSPACE_METADATA) { + this.ws.send( + JSON.stringify({ + type: "subscribe", + channel: "workspace:metadata", + }) + ); + } else if (channel === IPC_CHANNELS.WORKSPACE_ACTIVITY) { + this.ws.send( + JSON.stringify({ + type: "subscribe", + channel: "workspace:activity", + }) + ); + } + } + } + + unsubscribe(channel: string, workspaceId?: string): void { + if (this.ws?.readyState === WebSocket.OPEN) { + if (channel.startsWith(IPC_CHANNELS.WORKSPACE_CHAT_PREFIX)) { + this.ws.send( + JSON.stringify({ + type: "unsubscribe", + channel: "workspace:chat", + workspaceId, + }) + ); + } else if (channel === IPC_CHANNELS.WORKSPACE_METADATA) { + this.ws.send( + JSON.stringify({ + type: "unsubscribe", + channel: "workspace:metadata", + }) + ); + } else if (channel === IPC_CHANNELS.WORKSPACE_ACTIVITY) { + this.ws.send( + JSON.stringify({ + type: "unsubscribe", + channel: "workspace:activity", + }) + ); + } + } + } + + on(channel: string, handler: (data: unknown) => void, workspaceId?: string): () => void { + if (!this.messageHandlers.has(channel)) { + this.messageHandlers.set(channel, new Set()); + // Store workspaceId for this channel (needed for reconnection) + if (workspaceId) { + this.channelWorkspaceIds.set(channel, workspaceId); + } + this.connect(); + this.subscribe(channel, workspaceId); + } + + const handlers = this.messageHandlers.get(channel)!; + handlers.add(handler); + + // Return unsubscribe function + return () => { + handlers.delete(handler); + if (handlers.size === 0) { + this.messageHandlers.delete(channel); + this.channelWorkspaceIds.delete(channel); + this.unsubscribe(channel, workspaceId); + } + }; + } + + disconnect(): void { + this.shouldReconnect = false; + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + if (this.ws) { + this.ws.close(); + this.ws = null; + } + } +} + +const wsManager = new WebSocketManager(); + +// Create the Web API implementation +const webApi: IPCApi = { + tokenizer: { + countTokens: (model, text) => invokeIPC(IPC_CHANNELS.TOKENIZER_COUNT_TOKENS, model, text), + countTokensBatch: (model, texts) => + invokeIPC(IPC_CHANNELS.TOKENIZER_COUNT_TOKENS_BATCH, model, texts), + calculateStats: (messages, model) => + invokeIPC(IPC_CHANNELS.TOKENIZER_CALCULATE_STATS, messages, model), + }, + fs: { + listDirectory: (root) => invokeIPC(IPC_CHANNELS.FS_LIST_DIRECTORY, root), + }, + providers: { + setProviderConfig: (provider, keyPath, value) => + invokeIPC(IPC_CHANNELS.PROVIDERS_SET_CONFIG, provider, keyPath, value), + setModels: (provider, models) => invokeIPC(IPC_CHANNELS.PROVIDERS_SET_MODELS, provider, models), + getConfig: () => invokeIPC(IPC_CHANNELS.PROVIDERS_GET_CONFIG), + list: () => invokeIPC(IPC_CHANNELS.PROVIDERS_LIST), + }, + projects: { + create: (projectPath) => invokeIPC(IPC_CHANNELS.PROJECT_CREATE, projectPath), + pickDirectory: () => Promise.resolve(null), + remove: (projectPath) => invokeIPC(IPC_CHANNELS.PROJECT_REMOVE, projectPath), + list: () => invokeIPC(IPC_CHANNELS.PROJECT_LIST), + listBranches: (projectPath) => invokeIPC(IPC_CHANNELS.PROJECT_LIST_BRANCHES, projectPath), + secrets: { + get: (projectPath) => invokeIPC(IPC_CHANNELS.PROJECT_SECRETS_GET, projectPath), + update: (projectPath, secrets) => + invokeIPC(IPC_CHANNELS.PROJECT_SECRETS_UPDATE, projectPath, secrets), + }, + }, + workspace: { + list: () => invokeIPC(IPC_CHANNELS.WORKSPACE_LIST), + create: (projectPath, branchName, trunkBranch) => + invokeIPC(IPC_CHANNELS.WORKSPACE_CREATE, projectPath, branchName, trunkBranch), + remove: (workspaceId, options) => + invokeIPC(IPC_CHANNELS.WORKSPACE_REMOVE, workspaceId, options), + rename: (workspaceId, newName) => + invokeIPC(IPC_CHANNELS.WORKSPACE_RENAME, workspaceId, newName), + fork: (sourceWorkspaceId, newName) => + invokeIPC(IPC_CHANNELS.WORKSPACE_FORK, sourceWorkspaceId, newName), + sendMessage: (workspaceId, message, options) => + invokeIPC(IPC_CHANNELS.WORKSPACE_SEND_MESSAGE, workspaceId, message, options), + resumeStream: (workspaceId, options) => + invokeIPC(IPC_CHANNELS.WORKSPACE_RESUME_STREAM, workspaceId, options), + interruptStream: (workspaceId, options) => + invokeIPC(IPC_CHANNELS.WORKSPACE_INTERRUPT_STREAM, workspaceId, options), + clearQueue: (workspaceId) => invokeIPC(IPC_CHANNELS.WORKSPACE_CLEAR_QUEUE, workspaceId), + truncateHistory: (workspaceId, percentage) => + invokeIPC(IPC_CHANNELS.WORKSPACE_TRUNCATE_HISTORY, workspaceId, percentage), + replaceChatHistory: (workspaceId, summaryMessage) => + invokeIPC(IPC_CHANNELS.WORKSPACE_REPLACE_HISTORY, workspaceId, summaryMessage), + getInfo: (workspaceId) => invokeIPC(IPC_CHANNELS.WORKSPACE_GET_INFO, workspaceId), + executeBash: (workspaceId, script, options) => + invokeIPC(IPC_CHANNELS.WORKSPACE_EXECUTE_BASH, workspaceId, script, options), + openTerminal: (workspaceId) => invokeIPC(IPC_CHANNELS.WORKSPACE_OPEN_TERMINAL, workspaceId), + activity: { + list: async (): Promise> => { + const response = await invokeIPC>( + IPC_CHANNELS.WORKSPACE_ACTIVITY_LIST + ); + const result: Record = {}; + if (response && typeof response === "object") { + for (const [workspaceId, value] of Object.entries(response)) { + if (typeof workspaceId !== "string") { + continue; + } + const parsed = parseWorkspaceActivity(value); + if (parsed) { + result[workspaceId] = parsed; + } + } + } + return result; + }, + subscribe: (callback) => + wsManager.on(IPC_CHANNELS.WORKSPACE_ACTIVITY, (data) => { + if (!data || typeof data !== "object") { + return; + } + const record = data as { workspaceId?: string; activity?: unknown }; + if (typeof record.workspaceId !== "string") { + return; + } + if (record.activity === null) { + callback({ workspaceId: record.workspaceId, activity: null }); + return; + } + const activity = parseWorkspaceActivity(record.activity); + if (!activity) { + return; + } + callback({ workspaceId: record.workspaceId, activity }); + }), + }, + + onChat: (workspaceId, callback) => { + const channel = getChatChannel(workspaceId); + return wsManager.on(channel, callback as (data: unknown) => void, workspaceId); + }, + + onMetadata: (callback) => { + const unsubscribe = wsManager.on(IPC_CHANNELS.WORKSPACE_METADATA, (data: unknown) => { + callback(data as Parameters[0]); + }); + return unsubscribe; + }, + }, + window: { + setTitle: (title) => { + document.title = title; + return Promise.resolve(); + }, + }, + terminal: { + create: (params) => invokeIPC(IPC_CHANNELS.TERMINAL_CREATE, params), + close: (sessionId) => invokeIPC(IPC_CHANNELS.TERMINAL_CLOSE, sessionId), + resize: (params) => invokeIPC(IPC_CHANNELS.TERMINAL_RESIZE, params), + sendInput: (sessionId: string, data: string) => { + // Send via IPC - in browser mode this becomes an HTTP POST + void invokeIPC(IPC_CHANNELS.TERMINAL_INPUT, sessionId, data); + }, + onOutput: (sessionId: string, callback: (data: string) => void) => { + // Subscribe to terminal output events via WebSocket + const channel = `terminal:output:${sessionId}`; + return wsManager.on(channel, callback as (data: unknown) => void); + }, + onExit: (sessionId: string, callback: (exitCode: number) => void) => { + // Subscribe to terminal exit events via WebSocket + const channel = `terminal:exit:${sessionId}`; + return wsManager.on(channel, callback as (data: unknown) => void); + }, + openWindow: (workspaceId) => { + // In browser mode, always open terminal in a new browser window (for both local and SSH workspaces) + // This must be synchronous to avoid popup blocker during user gesture + const url = `/terminal.html?workspaceId=${encodeURIComponent(workspaceId)}`; + window.open(url, `terminal-${workspaceId}-${Date.now()}`, "width=1000,height=600,popup=yes"); + + // Also invoke IPC to let backend know (desktop mode will handle native/ghostty-web routing) + return invokeIPC(IPC_CHANNELS.TERMINAL_WINDOW_OPEN, workspaceId); + }, + closeWindow: (workspaceId) => invokeIPC(IPC_CHANNELS.TERMINAL_WINDOW_CLOSE, workspaceId), + }, + update: { + check: () => invokeIPC(IPC_CHANNELS.UPDATE_CHECK), + download: () => invokeIPC(IPC_CHANNELS.UPDATE_DOWNLOAD), + install: () => { + // Install is a one-way call that doesn't wait for response + void invokeIPC(IPC_CHANNELS.UPDATE_INSTALL); + }, + onStatus: (callback) => { + return wsManager.on(IPC_CHANNELS.UPDATE_STATUS, callback as (data: unknown) => void); + }, + }, + server: { + getLaunchProject: () => invokeIPC("server:getLaunchProject"), + }, + // In browser mode, set platform to "browser" to differentiate from Electron + platform: "browser" as const, + versions: {}, +}; + +if (typeof window.api === "undefined") { + // @ts-expect-error - Assigning to window.api which is not in TypeScript types + window.api = webApi; +} + +window.addEventListener("beforeunload", () => { + wsManager.disconnect(); +}); diff --git a/src/browser/components/AIView.tsx b/src/browser/components/AIView.tsx index de7cca94b..759c49a2b 100644 --- a/src/browser/components/AIView.tsx +++ b/src/browser/components/AIView.tsx @@ -21,7 +21,6 @@ import { ProviderOptionsProvider } from "@/browser/contexts/ProviderOptionsConte import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds"; import { useAutoScroll } from "@/browser/hooks/useAutoScroll"; -import { useOpenTerminal } from "@/browser/hooks/useOpenTerminal"; import { usePersistedState } from "@/browser/hooks/usePersistedState"; import { useThinking } from "@/browser/contexts/ThinkingContext"; import { @@ -41,7 +40,6 @@ import { checkAutoCompaction } from "@/browser/utils/compaction/autoCompactionCh import { useProviderOptions } from "@/browser/hooks/useProviderOptions"; import { useAutoCompactionSettings } from "../hooks/useAutoCompactionSettings"; import { useSendMessageOptions } from "@/browser/hooks/useSendMessageOptions"; -import { useORPC } from "@/browser/orpc/react"; interface AIViewProps { workspaceId: string; @@ -60,7 +58,6 @@ const AIViewInner: React.FC = ({ runtimeConfig, className, }) => { - const client = useORPC(); const chatAreaRef = useRef(null); // Track active tab to conditionally enable resize functionality @@ -173,14 +170,14 @@ const AIViewInner: React.FC = ({ const queuedMessage = workspaceState?.queuedMessage; if (!queuedMessage) return; - await client.workspace.clearQueue({ workspaceId }); + await window.api.workspace.clearQueue(workspaceId); chatInputAPI.current?.restoreText(queuedMessage.content); // Restore images if present if (queuedMessage.imageParts && queuedMessage.imageParts.length > 0) { chatInputAPI.current?.restoreImages(queuedMessage.imageParts); } - }, [client, workspaceId, workspaceState?.queuedMessage, chatInputAPI]); + }, [workspaceId, workspaceState?.queuedMessage, chatInputAPI]); const handleEditLastUserMessage = useCallback(async () => { if (!workspaceState) return; @@ -228,25 +225,24 @@ const AIViewInner: React.FC = ({ setAutoScroll(true); // Truncate history in backend - await client.workspace.truncateHistory({ workspaceId, percentage }); + await window.api.workspace.truncateHistory(workspaceId, percentage); }, - [workspaceId, setAutoScroll, client] + [workspaceId, setAutoScroll] ); const handleProviderConfig = useCallback( async (provider: string, keyPath: string[], value: string) => { - const result = await client.providers.setProviderConfig({ provider, keyPath, value }); + const result = await window.api.providers.setProviderConfig(provider, keyPath, value); if (!result.success) { throw new Error(result.error); } }, - [client] + [] ); - const openTerminal = useOpenTerminal(); const handleOpenTerminal = useCallback(() => { - openTerminal(workspaceId); - }, [workspaceId, openTerminal]); + void window.api.terminal.openWindow(workspaceId); + }, [workspaceId]); // Auto-scroll when messages or todos update (during streaming) useEffect(() => { @@ -337,7 +333,7 @@ const AIViewInner: React.FC = ({ const { messages, canInterrupt, isCompacting, loading, currentModel } = workspaceState; // Get active stream message ID for token counting - const activeStreamMessageId = aggregator?.getActiveStreamMessageId(); + const activeStreamMessageId = aggregator.getActiveStreamMessageId(); // Use pending send model for auto-compaction check, not the last stream's model. // This ensures the threshold is based on the model the user will actually send with, @@ -508,12 +504,12 @@ const AIViewInner: React.FC = ({ cancelText={`hit ${formatKeybind(vimEnabled ? KEYBINDS.INTERRUPT_STREAM_VIM : KEYBINDS.INTERRUPT_STREAM_NORMAL)} to cancel`} tokenCount={ activeStreamMessageId - ? aggregator?.getStreamingTokenCount(activeStreamMessageId) + ? aggregator.getStreamingTokenCount(activeStreamMessageId) : undefined } tps={ activeStreamMessageId - ? aggregator?.getStreamingTPS(activeStreamMessageId) + ? aggregator.getStreamingTPS(activeStreamMessageId) : undefined } /> diff --git a/src/browser/components/AppLoader.tsx b/src/browser/components/AppLoader.tsx index 3f7e403df..5b80783ee 100644 --- a/src/browser/components/AppLoader.tsx +++ b/src/browser/components/AppLoader.tsx @@ -4,14 +4,8 @@ import { LoadingScreen } from "./LoadingScreen"; import { useWorkspaceStoreRaw } from "../stores/WorkspaceStore"; import { useGitStatusStoreRaw } from "../stores/GitStatusStore"; import { ProjectProvider } from "../contexts/ProjectContext"; -import { ORPCProvider, useORPC, type ORPCClient } from "@/browser/orpc/react"; import { WorkspaceProvider, useWorkspaceContext } from "../contexts/WorkspaceContext"; -interface AppLoaderProps { - /** Optional pre-created ORPC client. If provided, skips internal connection setup. */ - client?: ORPCClient; -} - /** * AppLoader handles all initialization before rendering the main App: * 1. Load workspace metadata and projects (via contexts) @@ -23,15 +17,13 @@ interface AppLoaderProps { * This ensures App.tsx can assume stores are always synced and removes * the need for conditional guards in effects. */ -export function AppLoader(props: AppLoaderProps) { +export function AppLoader() { return ( - - - - - - - + + + + + ); } @@ -41,7 +33,6 @@ export function AppLoader(props: AppLoaderProps) { */ function AppLoaderInner() { const workspaceContext = useWorkspaceContext(); - const client = useORPC(); // Get store instances const workspaceStore = useWorkspaceStoreRaw(); @@ -52,9 +43,6 @@ function AppLoaderInner() { // Sync stores when metadata finishes loading useEffect(() => { - workspaceStore.setClient(client); - gitStatusStore.setClient(client); - if (!workspaceContext.loading) { workspaceStore.syncWorkspaces(workspaceContext.workspaceMetadata); gitStatusStore.syncWorkspaces(workspaceContext.workspaceMetadata); @@ -67,7 +55,6 @@ function AppLoaderInner() { workspaceContext.workspaceMetadata, workspaceStore, gitStatusStore, - client, ]); // Show loading screen until stores are synced diff --git a/src/browser/components/AuthTokenModal.tsx b/src/browser/components/AuthTokenModal.tsx deleted file mode 100644 index 6110adec1..000000000 --- a/src/browser/components/AuthTokenModal.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { useState, useCallback } from "react"; -import { Modal } from "./Modal"; - -interface AuthTokenModalProps { - isOpen: boolean; - onSubmit: (token: string) => void; - error?: string | null; -} - -const AUTH_TOKEN_STORAGE_KEY = "mux:auth-token"; - -export function getStoredAuthToken(): string | null { - try { - return localStorage.getItem(AUTH_TOKEN_STORAGE_KEY); - } catch { - return null; - } -} - -export function setStoredAuthToken(token: string): void { - try { - localStorage.setItem(AUTH_TOKEN_STORAGE_KEY, token); - } catch { - // Ignore storage errors - } -} - -export function clearStoredAuthToken(): void { - try { - localStorage.removeItem(AUTH_TOKEN_STORAGE_KEY); - } catch { - // Ignore storage errors - } -} - -export function AuthTokenModal(props: AuthTokenModalProps) { - const [token, setToken] = useState(""); - - const { onSubmit } = props; - const handleSubmit = useCallback( - (e: React.FormEvent) => { - e.preventDefault(); - if (token.trim()) { - setStoredAuthToken(token.trim()); - onSubmit(token.trim()); - } - }, - [token, onSubmit] - ); - - return ( - undefined} title="Authentication Required"> -
-

- This server requires an authentication token. Enter the token provided when the server was - started. -

- - {props.error && ( -
- {props.error} -
- )} - - setToken(e.target.value)} - placeholder="Enter auth token" - autoFocus - style={{ - padding: "10px 12px", - borderRadius: 4, - border: "1px solid var(--color-border)", - backgroundColor: "var(--color-input-background)", - color: "var(--color-text)", - fontSize: 14, - outline: "none", - }} - /> - - -
-
- ); -} diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index 57d8db3d0..b6cf018aa 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -11,13 +11,12 @@ import React, { import { CommandSuggestions, COMMAND_SUGGESTION_KEYS } from "../CommandSuggestions"; import type { Toast } from "../ChatInputToast"; import { ChatInputToast } from "../ChatInputToast"; -import { createCommandToast, createErrorToast } from "../ChatInputToasts"; +import { createErrorToast } from "../ChatInputToasts"; import { parseCommand } from "@/browser/utils/slashCommands/parser"; import { usePersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; import { useMode } from "@/browser/contexts/ModeContext"; import { ThinkingSliderComponent } from "../ThinkingSlider"; import { ModelSettings } from "../ModelSettings"; -import { useORPC } from "@/browser/orpc/react"; import { useSendMessageOptions } from "@/browser/hooks/useSendMessageOptions"; import { getModelKey, @@ -27,11 +26,10 @@ import { getPendingScopeId, } from "@/common/constants/storage"; import { - handleNewCommand, - handleCompactCommand, - forkWorkspace, prepareCompactionMessage, - type CommandHandlerContext, + executeCompaction, + processSlashCommand, + type SlashCommandContext, } from "@/browser/utils/chatCommands"; import { CUSTOM_EVENTS } from "@/common/constants/events"; import { @@ -60,13 +58,20 @@ import { import type { ThinkingLevel } from "@/common/types/thinking"; import type { MuxFrontendMetadata } from "@/common/types/message"; import { useTelemetry } from "@/browser/hooks/useTelemetry"; -import { setTelemetryEnabled } from "@/common/telemetry"; import { getTokenCountPromise } from "@/browser/utils/tokenizer/rendererClient"; import { CreationCenterContent } from "./CreationCenterContent"; import { cn } from "@/common/lib/utils"; import { CreationControls } from "./CreationControls"; import { useCreationWorkspace } from "./useCreationWorkspace"; +const LEADING_COMMAND_NOISE = /^(?:\s|\u200B|\u200C|\u200D|\u200E|\u200F|\uFEFF)+/; + +function normalizeSlashCommandInput(value: string): string { + if (!value) { + return value; + } + return value.replace(LEADING_COMMAND_NOISE, ""); +} type TokenCountReader = () => number; function createTokenCountResource(promise: Promise): TokenCountReader { @@ -99,12 +104,11 @@ function createTokenCountResource(promise: Promise): TokenCountReader { // Import types from local types file import type { ChatInputProps, ChatInputAPI } from "./types"; -import type { ImagePart } from "@/common/orpc/types"; +import type { ImagePart } from "@/common/types/ipc"; export type { ChatInputProps, ChatInputAPI }; export const ChatInput: React.FC = (props) => { - const client = useORPC(); const { variant } = props; // Extract workspace-specific props with defaults @@ -140,7 +144,7 @@ export const ChatInput: React.FC = (props) => { const inputRef = useRef(null); const modelSelectorRef = useRef(null); const [mode, setMode] = useMode(); - const { recentModels, addModel, evictModel } = useModelLRU(); + const { recentModels, addModel, evictModel, defaultModel, setDefaultModel } = useModelLRU(); const commandListId = useId(); const telemetry = useTelemetry(); const [vimEnabled, setVimEnabled] = usePersistedState(VIM_ENABLED_KEY, false, { @@ -160,8 +164,8 @@ export const ChatInput: React.FC = (props) => { if (!deferredModel || deferredInput.trim().length === 0 || deferredInput.startsWith("/")) { return Promise.resolve(0); } - return getTokenCountPromise(client, deferredModel, deferredInput); - }, [client, deferredModel, deferredInput]); + return getTokenCountPromise(deferredModel, deferredInput); + }, [deferredModel, deferredInput]); const tokenCountReader = useMemo( () => createTokenCountResource(tokenCountPromise), [tokenCountPromise] @@ -178,6 +182,15 @@ export const ChatInput: React.FC = (props) => { [storageKeys.modelKey, addModel] ); + // When entering creation mode (or when the default model changes), reset the + // project-scoped model to the explicit default so manual picks don't bleed + // into subsequent creation flows. + useEffect(() => { + if (variant === "creation" && defaultModel) { + updatePersistedState(storageKeys.modelKey, defaultModel); + } + }, [variant, defaultModel, storageKeys.modelKey]); + // Creation-specific state (hook always called, but only used when variant === "creation") // This avoids conditional hook calls which violate React rules const creationState = useCreationWorkspace( @@ -297,9 +310,10 @@ export const ChatInput: React.FC = (props) => { // Watch input for slash commands useEffect(() => { - const suggestions = getSlashCommandSuggestions(input, { providerNames }); + const normalizedSlashSource = normalizeSlashCommandInput(input); + const suggestions = getSlashCommandSuggestions(normalizedSlashSource, { providerNames }); setCommandSuggestions(suggestions); - setShowCommandSuggestions(suggestions.length > 0); + setShowCommandSuggestions(normalizedSlashSource.startsWith("/") && suggestions.length > 0); }, [input, providerNames]); // Load provider names for suggestions @@ -308,7 +322,7 @@ export const ChatInput: React.FC = (props) => { const loadProviders = async () => { try { - const names = await client.providers.list(); + const names = await window.api.providers.list(); if (isMounted && Array.isArray(names)) { setProviderNames(names); } @@ -322,7 +336,7 @@ export const ChatInput: React.FC = (props) => { return () => { isMounted = false; }; - }, [client]); + }, []); // Allow external components (e.g., CommandPalette, Queued message edits) to insert text useEffect(() => { @@ -459,266 +473,188 @@ export const ChatInput: React.FC = (props) => { return; } - const messageText = input.trim(); + const rawInputValue = input; + const messageText = rawInputValue.trim(); + const normalizedCommandInput = normalizeSlashCommandInput(messageText); + const isSlashCommand = normalizedCommandInput.startsWith("/"); + const parsed = isSlashCommand ? parseCommand(normalizedCommandInput) : null; + + // Prepare image parts early so slash commands can access them + const imageParts = imageAttachments.map((img, index) => { + // Validate before sending to help with debugging + if (!img.url || typeof img.url !== "string") { + console.error( + `Image attachment [${index}] has invalid url:`, + typeof img.url, + img.url?.slice(0, 50) + ); + } + if (!img.url?.startsWith("data:")) { + console.error(`Image attachment [${index}] url is not a data URL:`, img.url?.slice(0, 100)); + } + if (!img.mediaType || typeof img.mediaType !== "string") { + console.error( + `Image attachment [${index}] has invalid mediaType:`, + typeof img.mediaType, + img.mediaType + ); + } + return { + url: img.url, + mediaType: img.mediaType, + }; + }); + + if (parsed) { + const context: SlashCommandContext = { + variant, + workspaceId: variant === "workspace" ? props.workspaceId : undefined, + sendMessageOptions, + setInput, + setImageAttachments, + setIsSending, + setToast, + setVimEnabled, + setPreferredModel, + onProviderConfig: props.onProviderConfig, + onModelChange: props.onModelChange, + onTruncateHistory: variant === "workspace" ? props.onTruncateHistory : undefined, + onCancelEdit: variant === "workspace" ? props.onCancelEdit : undefined, + editMessageId: editingMessage?.id, + imageParts: imageParts.length > 0 ? imageParts : undefined, + resetInputHeight: () => { + if (inputRef.current) { + inputRef.current.style.height = "36px"; + } + }, + }; + + const result = await processSlashCommand(parsed, context); - // Route to creation handler for creation variant + if (!result.clearInput) { + setInput(rawInputValue); // Restore exact input on failure + } + return; + } + + if (isSlashCommand) { + setToast({ + id: Date.now().toString(), + type: "error", + message: `Unknown command: ${normalizedCommandInput.split(/\s+/)[0] ?? ""}`, + }); + return; + } + + // Handle standard message sending based on variant if (variant === "creation") { - // Creation variant: simple message send + workspace creation setIsSending(true); - setInput(""); // Clear input immediately (will be restored by parent if creation fails) - await creationState.handleSend(messageText); + const ok = await creationState.handleSend(messageText); + if (ok) { + setInput(""); + if (inputRef.current) { + inputRef.current.style.height = "36px"; + } + } setIsSending(false); return; } - // Workspace variant: full command handling + message send - if (variant !== "workspace") return; // Type guard + // Workspace variant: regular message send try { - // Parse command - const parsed = parseCommand(messageText); + // Regular message - send directly via API + setIsSending(true); - if (parsed) { - // Handle /clear command - if (parsed.type === "clear") { - setInput(""); - if (inputRef.current) { - inputRef.current.style.height = "36px"; - } - await props.onTruncateHistory(1.0); - setToast({ - id: Date.now().toString(), - type: "success", - message: "Chat history cleared", - }); - return; - } + // Save current state for restoration on error + const previousImageAttachments = [...imageAttachments]; - // Handle /truncate command - if (parsed.type === "truncate") { - setInput(""); - if (inputRef.current) { - inputRef.current.style.height = "36px"; - } - await props.onTruncateHistory(parsed.percentage); - setToast({ - id: Date.now().toString(), - type: "success", - message: `Chat history truncated by ${Math.round(parsed.percentage * 100)}%`, - }); - return; - } + // Auto-compaction check (workspace variant only) + // Check if we should auto-compact before sending this message + // Result is computed in parent (AIView) and passed down to avoid duplicate calculation + const shouldAutoCompact = + props.autoCompactionCheck && + props.autoCompactionCheck.usagePercentage >= + props.autoCompactionCheck.thresholdPercentage && + !isCompacting; // Skip if already compacting to prevent double-compaction queue + if (variant === "workspace" && !editingMessage && shouldAutoCompact) { + // Clear input immediately for responsive UX + setInput(""); + setImageAttachments([]); + setIsSending(true); - // Handle /providers set command - if (parsed.type === "providers-set" && props.onProviderConfig) { - setIsSending(true); - setInput(""); // Clear input immediately + try { + const result = await executeCompaction({ + workspaceId: props.workspaceId, + continueMessage: { + text: messageText, + imageParts, + model: sendMessageOptions.model, + }, + sendMessageOptions, + }); - try { - await props.onProviderConfig(parsed.provider, parsed.keyPath, parsed.value); - // Success - show toast + if (!result.success) { + // Restore on error + setInput(messageText); + setImageAttachments(previousImageAttachments); setToast({ id: Date.now().toString(), - type: "success", - message: `Provider ${parsed.provider} updated`, + type: "error", + title: "Auto-Compaction Failed", + message: result.error ?? "Failed to start auto-compaction", }); - } catch (error) { - console.error("Failed to update provider config:", error); + } else { setToast({ id: Date.now().toString(), - type: "error", - message: error instanceof Error ? error.message : "Failed to update provider", + type: "success", + message: `Context threshold reached - auto-compacting...`, }); - setInput(messageText); // Restore input on error - } finally { - setIsSending(false); + props.onMessageSent?.(); } - return; - } - - // Handle /model command - if (parsed.type === "model-set") { - setInput(""); // Clear input immediately - setPreferredModel(parsed.modelString); - props.onModelChange?.(parsed.modelString); - setToast({ - id: Date.now().toString(), - type: "success", - message: `Model changed to ${parsed.modelString}`, - }); - return; - } - - // Handle /vim command - if (parsed.type === "vim-toggle") { - setInput(""); // Clear input immediately - setVimEnabled((prev) => !prev); - return; - } - - // Handle /telemetry command - if (parsed.type === "telemetry-set") { - setInput(""); // Clear input immediately - setTelemetryEnabled(parsed.enabled); + } catch (error) { + // Restore on unexpected error + setInput(messageText); + setImageAttachments(previousImageAttachments); setToast({ id: Date.now().toString(), - type: "success", - message: `Telemetry ${parsed.enabled ? "enabled" : "disabled"}`, + type: "error", + title: "Auto-Compaction Failed", + message: + error instanceof Error ? error.message : "Unexpected error during auto-compaction", }); - return; - } - - // Handle /compact command - if (parsed.type === "compact") { - const context: CommandHandlerContext = { - client, - workspaceId: props.workspaceId, - sendMessageOptions, - editMessageId: editingMessage?.id, - setInput, - setImageAttachments, - setIsSending, - setToast, - onCancelEdit: props.onCancelEdit, - }; - - const result = await handleCompactCommand(parsed, context); - if (!result.clearInput) { - setInput(messageText); // Restore input on error - } - return; - } - - // Handle /fork command - if (parsed.type === "fork") { - setInput(""); // Clear input immediately - setIsSending(true); - - try { - const forkResult = await forkWorkspace({ - client, - sourceWorkspaceId: props.workspaceId, - newName: parsed.newName, - startMessage: parsed.startMessage, - sendMessageOptions, - }); - - if (!forkResult.success) { - const errorMsg = forkResult.error ?? "Failed to fork workspace"; - console.error("Failed to fork workspace:", errorMsg); - setToast({ - id: Date.now().toString(), - type: "error", - title: "Fork Failed", - message: errorMsg, - }); - setInput(messageText); // Restore input on error - } else { - setToast({ - id: Date.now().toString(), - type: "success", - message: `Forked to workspace "${parsed.newName}"`, - }); - } - } catch (error) { - const errorMsg = error instanceof Error ? error.message : "Failed to fork workspace"; - console.error("Fork error:", error); - setToast({ - id: Date.now().toString(), - type: "error", - title: "Fork Failed", - message: errorMsg, - }); - setInput(messageText); // Restore input on error - } - + } finally { setIsSending(false); - return; - } - - // Handle /new command - if (parsed.type === "new") { - const context: CommandHandlerContext = { - client, - workspaceId: props.workspaceId, - sendMessageOptions, - setInput, - setImageAttachments, - setIsSending, - setToast, - }; - - const result = await handleNewCommand(parsed, context); - if (!result.clearInput) { - setInput(messageText); // Restore input on error - } - return; } - // Handle all other commands - show display toast - const commandToast = createCommandToast(parsed); - if (commandToast) { - setToast(commandToast); - return; - } + return; // Skip normal send } // Regular message - send directly via API setIsSending(true); - // Save current state for restoration on error - const previousImageAttachments = [...imageAttachments]; - try { - // Prepare image parts if any - const imageParts = imageAttachments.map((img, index) => { - // Validate before sending to help with debugging - if (!img.url || typeof img.url !== "string") { - console.error( - `Image attachment [${index}] has invalid url:`, - typeof img.url, - img.url?.slice(0, 50) - ); - } - if (!img.url?.startsWith("data:")) { - console.error( - `Image attachment [${index}] url is not a data URL:`, - img.url?.slice(0, 100) - ); - } - if (!img.mediaType || typeof img.mediaType !== "string") { - console.error( - `Image attachment [${index}] has invalid mediaType:`, - typeof img.mediaType, - img.mediaType - ); - } - return { - url: img.url, - mediaType: img.mediaType, - }; - }); - // When editing a /compact command, regenerate the actual summarization request let actualMessageText = messageText; let muxMetadata: MuxFrontendMetadata | undefined; let compactionOptions = {}; - if (editingMessage && messageText.startsWith("/")) { - const parsed = parseCommand(messageText); - if (parsed?.type === "compact") { + if (editingMessage && normalizedCommandInput.startsWith("/")) { + const parsedEditingCommand = parseCommand(normalizedCommandInput); + if (parsedEditingCommand?.type === "compact") { const { messageText: regeneratedText, metadata, sendOptions, } = prepareCompactionMessage({ - client, workspaceId: props.workspaceId, - maxOutputTokens: parsed.maxOutputTokens, - continueMessage: parsed.continueMessage - ? { text: parsed.continueMessage } - : undefined, - model: parsed.model, + maxOutputTokens: parsedEditingCommand.maxOutputTokens, + continueMessage: { + text: parsedEditingCommand.continueMessage ?? "", + imageParts, + model: sendMessageOptions.model, + }, + model: parsedEditingCommand.model, sendMessageOptions, }); actualMessageText = regeneratedText; @@ -736,17 +672,17 @@ export const ChatInput: React.FC = (props) => { inputRef.current.style.height = "36px"; } - const result = await client.workspace.sendMessage({ - workspaceId: props.workspaceId, - message: actualMessageText, - options: { + const result = await window.api.workspace.sendMessage( + props.workspaceId, + actualMessageText, + { ...sendMessageOptions, ...compactionOptions, editMessageId: editingMessage?.id, imageParts: imageParts.length > 0 ? imageParts : undefined, muxMetadata, - }, - }); + } + ); if (!result.success) { // Log error for debugging @@ -754,7 +690,7 @@ export const ChatInput: React.FC = (props) => { // Show error using enhanced toast setToast(createErrorToast(result.error)); // Restore input and images on error so user can try again - setInput(messageText); + setInput(rawInputValue); setImageAttachments(previousImageAttachments); } else { // Track telemetry for successful message send @@ -775,7 +711,7 @@ export const ChatInput: React.FC = (props) => { raw: error instanceof Error ? error.message : "Failed to send message", }) ); - setInput(messageText); + setInput(rawInputValue); setImageAttachments(previousImageAttachments); } finally { setIsSending(false); @@ -901,30 +837,28 @@ export const ChatInput: React.FC = (props) => { data-component="ChatInputSection" >
- {/* Creation toast */} - {variant === "creation" && ( - creationState.setToast(null)} - /> - )} - - {/* Workspace toast */} - {variant === "workspace" && ( - - )} - - {/* Command suggestions - workspace only */} - {variant === "workspace" && ( - setShowCommandSuggestions(false)} - isVisible={showCommandSuggestions} - ariaLabel="Slash command suggestions" - listId={commandListId} - /> - )} + {/* Toast - show shared toast (slash commands) or variant-specific toast */} + { + handleToastDismiss(); + if (variant === "creation") { + creationState.setToast(null); + } + }} + /> + + {/* Command suggestions - available in both variants */} + {/* In creation mode, use portal (anchorRef) to escape overflow:hidden containers */} + setShowCommandSuggestions(false)} + isVisible={showCommandSuggestions} + ariaLabel="Slash command suggestions" + listId={commandListId} + anchorRef={variant === "creation" ? inputRef : undefined} + />
= (props) => { recentModels={recentModels} onRemoveModel={evictModel} onComplete={() => inputRef.current?.focus()} + defaultModel={defaultModel} + onSetDefaultModel={setDefaultModel} /> ? @@ -1011,7 +947,7 @@ export const ChatInput: React.FC = (props) => { Calculating tokens… @@ -1072,7 +1008,7 @@ const TokenCountDisplay: React.FC<{ reader: TokenCountReader }> = ({ reader }) = return null; } return ( -
+
{tokens.toLocaleString()} tokens
); diff --git a/src/browser/components/ChatInput/types.ts b/src/browser/components/ChatInput/types.ts index dbf9e16b4..d8ce81687 100644 --- a/src/browser/components/ChatInput/types.ts +++ b/src/browser/components/ChatInput/types.ts @@ -1,4 +1,4 @@ -import type { ImagePart } from "@/common/orpc/types"; +import type { ImagePart } from "@/common/types/ipc"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; import type { AutoCompactionCheckResult } from "@/browser/utils/compaction/autoCompactionCheck"; diff --git a/src/browser/components/ChatInput/useCreationWorkspace.test.tsx b/src/browser/components/ChatInput/useCreationWorkspace.test.tsx index 5df37abdf..3bae2683d 100644 --- a/src/browser/components/ChatInput/useCreationWorkspace.test.tsx +++ b/src/browser/components/ChatInput/useCreationWorkspace.test.tsx @@ -1,4 +1,3 @@ -import type { ORPCClient } from "@/browser/orpc/react"; import type { DraftWorkspaceSettings } from "@/browser/hooks/useDraftWorkspaceSettings"; import { getInputKey, @@ -8,16 +7,13 @@ import { getThinkingLevelKey, } from "@/common/constants/storage"; import type { SendMessageError } from "@/common/types/errors"; -import type { SendMessageOptions, WorkspaceChatMessage } from "@/common/orpc/types"; +import type { BranchListResult, IPCApi, SendMessageOptions } from "@/common/types/ipc"; import type { RuntimeMode } from "@/common/types/runtime"; -import type { - FrontendWorkspaceMetadata, - WorkspaceActivitySnapshot, -} from "@/common/types/workspace"; +import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; import { act, cleanup, render, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { GlobalWindow } from "happy-dom"; -import { useCreationWorkspace } from "./useCreationWorkspace"; +import React from "react"; const readPersistedStateCalls: Array<[string, unknown]> = []; let persistedPreferences: Record = {}; @@ -63,200 +59,14 @@ void mock.module("@/browser/hooks/useDraftWorkspaceSettings", () => ({ let currentSendOptions: SendMessageOptions; const useSendMessageOptionsMock = mock(() => currentSendOptions); +type WorkspaceSendMessage = IPCApi["workspace"]["sendMessage"]; +type WorkspaceSendMessageParams = Parameters; void mock.module("@/browser/hooks/useSendMessageOptions", () => ({ useSendMessageOptions: useSendMessageOptionsMock, })); -let currentORPCClient: MockOrpcClient | null = null; -void mock.module("@/browser/orpc/react", () => ({ - useORPC: () => { - if (!currentORPCClient) { - throw new Error("ORPC client not initialized"); - } - return currentORPCClient as ORPCClient; - }, -})); - const TEST_PROJECT_PATH = "/projects/demo"; -const FALLBACK_BRANCH = "main"; const TEST_WORKSPACE_ID = "ws-created"; -type BranchListResult = Awaited>; -type ListBranchesArgs = Parameters[0]; -type WorkspaceSendMessageArgs = Parameters[0]; -type WorkspaceSendMessageResult = Awaited>; -type MockOrpcProjectsClient = Pick; -type MockOrpcWorkspaceClient = Pick; -type WindowWithApi = Window & typeof globalThis; -type WindowApi = WindowWithApi["api"]; - -function rejectNotImplemented(method: string) { - return (..._args: unknown[]): Promise => - Promise.reject(new Error(`${method} is not implemented in useCreationWorkspace tests`)); -} - -function throwNotImplemented(method: string) { - return (..._args: unknown[]): never => { - throw new Error(`${method} is not implemented in useCreationWorkspace tests`); - }; -} - -const noopUnsubscribe = () => () => undefined; -interface MockOrpcClient { - projects: MockOrpcProjectsClient; - workspace: MockOrpcWorkspaceClient; -} -interface SetupWindowOptions { - listBranches?: ReturnType Promise>>; - sendMessage?: ReturnType< - typeof mock<(args: WorkspaceSendMessageArgs) => Promise> - >; -} - -const setupWindow = ({ listBranches, sendMessage }: SetupWindowOptions = {}) => { - const listBranchesMock = - listBranches ?? - mock<(args: ListBranchesArgs) => Promise>(({ projectPath }) => { - if (!projectPath) { - throw new Error("listBranches mock requires projectPath"); - } - return Promise.resolve({ - branches: [FALLBACK_BRANCH], - recommendedTrunk: FALLBACK_BRANCH, - }); - }); - - const sendMessageMock = - sendMessage ?? - mock<(args: WorkspaceSendMessageArgs) => Promise>((args) => { - if (!args.workspaceId && !args.options?.projectPath) { - return Promise.resolve({ - success: false, - error: { type: "unknown", raw: "Missing project path" } satisfies SendMessageError, - }); - } - - if (!args.workspaceId) { - return Promise.resolve({ - success: true, - data: { - workspaceId: TEST_WORKSPACE_ID, - metadata: TEST_METADATA, - }, - } satisfies WorkspaceSendMessageResult); - } - - const existingWorkspaceResult: WorkspaceSendMessageResult = { - success: true, - data: {}, - }; - return Promise.resolve(existingWorkspaceResult); - }); - - currentORPCClient = { - projects: { - listBranches: (input: ListBranchesArgs) => listBranchesMock(input), - }, - workspace: { - sendMessage: (input: WorkspaceSendMessageArgs) => sendMessageMock(input), - }, - }; - - const windowInstance = new GlobalWindow(); - globalThis.window = windowInstance as unknown as WindowWithApi; - const windowWithApi = globalThis.window as WindowWithApi; - - const apiMock: WindowApi = { - tokenizer: { - countTokens: rejectNotImplemented("tokenizer.countTokens"), - countTokensBatch: rejectNotImplemented("tokenizer.countTokensBatch"), - calculateStats: rejectNotImplemented("tokenizer.calculateStats"), - }, - providers: { - setProviderConfig: rejectNotImplemented("providers.setProviderConfig"), - list: rejectNotImplemented("providers.list"), - }, - projects: { - create: rejectNotImplemented("projects.create"), - pickDirectory: rejectNotImplemented("projects.pickDirectory"), - remove: rejectNotImplemented("projects.remove"), - list: rejectNotImplemented("projects.list"), - listBranches: (projectPath: string) => listBranchesMock({ projectPath }), - secrets: { - get: rejectNotImplemented("projects.secrets.get"), - update: rejectNotImplemented("projects.secrets.update"), - }, - }, - workspace: { - list: rejectNotImplemented("workspace.list"), - create: rejectNotImplemented("workspace.create"), - remove: rejectNotImplemented("workspace.remove"), - rename: rejectNotImplemented("workspace.rename"), - fork: rejectNotImplemented("workspace.fork"), - sendMessage: ( - workspaceId: WorkspaceSendMessageArgs["workspaceId"], - message: WorkspaceSendMessageArgs["message"], - options?: WorkspaceSendMessageArgs["options"] - ) => sendMessageMock({ workspaceId, message, options }), - resumeStream: rejectNotImplemented("workspace.resumeStream"), - interruptStream: rejectNotImplemented("workspace.interruptStream"), - clearQueue: rejectNotImplemented("workspace.clearQueue"), - truncateHistory: rejectNotImplemented("workspace.truncateHistory"), - replaceChatHistory: rejectNotImplemented("workspace.replaceChatHistory"), - getInfo: rejectNotImplemented("workspace.getInfo"), - executeBash: rejectNotImplemented("workspace.executeBash"), - openTerminal: rejectNotImplemented("workspace.openTerminal"), - onChat: (_workspaceId: string, _callback: (data: WorkspaceChatMessage) => void) => - noopUnsubscribe(), - onMetadata: ( - _callback: (data: { workspaceId: string; metadata: FrontendWorkspaceMetadata }) => void - ) => noopUnsubscribe(), - activity: { - list: rejectNotImplemented("workspace.activity.list"), - subscribe: ( - _callback: (payload: { - workspaceId: string; - activity: WorkspaceActivitySnapshot | null; - }) => void - ) => noopUnsubscribe(), - }, - }, - window: { - setTitle: rejectNotImplemented("window.setTitle"), - }, - terminal: { - create: rejectNotImplemented("terminal.create"), - close: rejectNotImplemented("terminal.close"), - resize: rejectNotImplemented("terminal.resize"), - sendInput: throwNotImplemented("terminal.sendInput"), - onOutput: () => noopUnsubscribe(), - onExit: () => noopUnsubscribe(), - openWindow: rejectNotImplemented("terminal.openWindow"), - closeWindow: rejectNotImplemented("terminal.closeWindow"), - }, - update: { - check: rejectNotImplemented("update.check"), - download: rejectNotImplemented("update.download"), - install: throwNotImplemented("update.install"), - onStatus: () => noopUnsubscribe(), - }, - platform: "electron", - versions: { - node: "0", - chrome: "0", - electron: "0", - }, - }; - - windowWithApi.api = apiMock; - - globalThis.document = windowInstance.document as unknown as Document; - globalThis.localStorage = windowInstance.localStorage as unknown as Storage; - - return { - projectsApi: { listBranches: listBranchesMock }, - workspaceApi: { sendMessage: sendMessageMock }, - }; -}; const TEST_METADATA: FrontendWorkspaceMetadata = { id: TEST_WORKSPACE_ID, name: "demo-branch", @@ -267,6 +77,8 @@ const TEST_METADATA: FrontendWorkspaceMetadata = { createdAt: "2025-01-01T00:00:00.000Z", }; +import { useCreationWorkspace } from "./useCreationWorkspace"; + describe("useCreationWorkspace", () => { beforeEach(() => { persistedPreferences = {}; @@ -309,8 +121,7 @@ describe("useCreationWorkspace", () => { }); await waitFor(() => expect(projectsApi.listBranches.mock.calls.length).toBe(1)); - // ORPC uses object argument - expect(projectsApi.listBranches.mock.calls[0][0]).toEqual({ projectPath: TEST_PROJECT_PATH }); + expect(projectsApi.listBranches.mock.calls[0][0]).toBe(TEST_PROJECT_PATH); await waitFor(() => expect(getHook().branches).toEqual(["main", "dev"])); expect(draftSettingsInvocations[0]).toEqual({ @@ -355,15 +166,12 @@ describe("useCreationWorkspace", () => { recommendedTrunk: "main", }) ); - const sendMessageMock = mock( - (_args: WorkspaceSendMessageArgs): Promise => - Promise.resolve({ - success: true as const, - data: { - workspaceId: TEST_WORKSPACE_ID, - metadata: TEST_METADATA, - }, - }) + const sendMessageMock = mock((..._args: WorkspaceSendMessageParams) => + Promise.resolve({ + success: true as const, + workspaceId: TEST_WORKSPACE_ID, + metadata: TEST_METADATA, + }) ); const { workspaceApi } = setupWindow({ listBranches: listBranchesMock, @@ -393,16 +201,7 @@ describe("useCreationWorkspace", () => { }); expect(workspaceApi.sendMessage.mock.calls.length).toBe(1); - // ORPC uses a single argument object - const firstCall = workspaceApi.sendMessage.mock.calls[0]; - if (!firstCall) { - throw new Error("Expected workspace.sendMessage to be called at least once"); - } - const [request] = firstCall; - if (!request) { - throw new Error("sendMessage mock was invoked without arguments"); - } - const { workspaceId, message, options } = request; + const [workspaceId, message, options] = workspaceApi.sendMessage.mock.calls[0]; expect(workspaceId).toBeNull(); expect(message).toBe("launch workspace"); expect(options?.projectPath).toBe(TEST_PROJECT_PATH); @@ -433,12 +232,11 @@ describe("useCreationWorkspace", () => { }); test("handleSend surfaces backend errors and resets state", async () => { - const sendMessageMock = mock( - (_args: WorkspaceSendMessageArgs): Promise => - Promise.resolve({ - success: false as const, - error: { type: "unknown", raw: "backend exploded" } satisfies SendMessageError, - }) + const sendMessageMock = mock((..._args: WorkspaceSendMessageParams) => + Promise.resolve({ + success: false as const, + error: { type: "unknown", raw: "backend exploded" } satisfies SendMessageError, + }) ); setupWindow({ sendMessage: sendMessageMock }); draftSettingsState = createDraftSettingsHarness({ trunkBranch: "dev" }); @@ -525,6 +323,65 @@ function createDraftSettingsHarness( }; } +interface SetupWindowOptions { + listBranches?: ReturnType Promise>>; + sendMessage?: ReturnType< + typeof mock< + ( + workspaceId: string | null, + message: string, + options?: Parameters[2] + ) => ReturnType + > + >; +} + +function setupWindow(options: SetupWindowOptions = {}) { + const windowInstance = new GlobalWindow(); + const listBranches = + options.listBranches ?? + mock((): Promise => Promise.resolve({ branches: [], recommendedTrunk: "" })); + const sendMessage = + options.sendMessage ?? + mock( + ( + _workspaceId: string | null, + _message: string, + _opts?: Parameters[2] + ) => + Promise.resolve({ + success: true as const, + workspaceId: TEST_WORKSPACE_ID, + metadata: TEST_METADATA, + }) + ); + + globalThis.window = windowInstance as unknown as typeof globalThis.window; + const windowWithApi = globalThis.window as typeof globalThis.window & { api: IPCApi }; + windowWithApi.api = { + projects: { + listBranches, + }, + workspace: { + sendMessage, + }, + platform: "test", + versions: { + node: "0", + chrome: "0", + electron: "0", + }, + } as unknown as typeof windowWithApi.api; + + globalThis.document = windowWithApi.document; + globalThis.localStorage = windowWithApi.localStorage; + + return { + projectsApi: { listBranches }, + workspaceApi: { sendMessage }, + }; +} + interface HookOptions { projectPath: string; onWorkspaceCreated: (metadata: FrontendWorkspaceMetadata) => void; diff --git a/src/browser/components/ChatInput/useCreationWorkspace.ts b/src/browser/components/ChatInput/useCreationWorkspace.ts index a6c5217ff..6af1bfe8b 100644 --- a/src/browser/components/ChatInput/useCreationWorkspace.ts +++ b/src/browser/components/ChatInput/useCreationWorkspace.ts @@ -16,7 +16,6 @@ import { } from "@/common/constants/storage"; import type { Toast } from "@/browser/components/ChatInputToast"; import { createErrorToast } from "@/browser/components/ChatInputToasts"; -import { useORPC } from "@/browser/orpc/react"; interface UseCreationWorkspaceOptions { projectPath: string; @@ -64,7 +63,6 @@ export function useCreationWorkspace({ projectPath, onWorkspaceCreated, }: UseCreationWorkspaceOptions): UseCreationWorkspaceReturn { - const client = useORPC(); const [branches, setBranches] = useState([]); const [recommendedTrunk, setRecommendedTrunk] = useState(null); const [toast, setToast] = useState(null); @@ -86,7 +84,7 @@ export function useCreationWorkspace({ } const loadBranches = async () => { try { - const result = await client.projects.listBranches({ projectPath }); + const result = await window.api.projects.listBranches(projectPath); setBranches(result.branches); setRecommendedTrunk(result.recommendedTrunk); } catch (err) { @@ -94,7 +92,7 @@ export function useCreationWorkspace({ } }; void loadBranches(); - }, [projectPath, client]); + }, [projectPath]); const handleSend = useCallback( async (message: string): Promise => { @@ -111,15 +109,11 @@ export function useCreationWorkspace({ : undefined; // Send message with runtime config and creation-specific params - const result = await client.workspace.sendMessage({ - workspaceId: null, - message, - options: { - ...sendMessageOptions, - runtimeConfig, - projectPath, // Pass projectPath when workspaceId is null - trunkBranch: settings.trunkBranch, // Pass selected trunk branch from settings - }, + const result = await window.api.workspace.sendMessage(null, message, { + ...sendMessageOptions, + runtimeConfig, + projectPath, // Pass projectPath when workspaceId is null + trunkBranch: settings.trunkBranch, // Pass selected trunk branch from settings }); if (!result.success) { @@ -128,17 +122,16 @@ export function useCreationWorkspace({ return false; } - // Check if this is a workspace creation result (has metadata in data) - const { metadata } = result.data; - if (metadata) { - syncCreationPreferences(projectPath, metadata.id); + // Check if this is a workspace creation result (has metadata field) + if ("metadata" in result && result.metadata) { + syncCreationPreferences(projectPath, result.metadata.id); if (projectPath) { const pendingInputKey = getInputKey(getPendingScopeId(projectPath)); updatePersistedState(pendingInputKey, ""); } // Settings are already persisted via useDraftWorkspaceSettings // Notify parent to switch workspace (clears input via parent unmount) - onWorkspaceCreated(metadata); + onWorkspaceCreated(result.metadata); setIsSending(false); return true; } else { @@ -163,7 +156,6 @@ export function useCreationWorkspace({ } }, [ - client, isSending, projectPath, onWorkspaceCreated, diff --git a/src/browser/components/ChatInputToast.tsx b/src/browser/components/ChatInputToast.tsx index a4c61afa7..2a4a40b22 100644 --- a/src/browser/components/ChatInputToast.tsx +++ b/src/browser/components/ChatInputToast.tsx @@ -38,10 +38,7 @@ export const ChatInputToast: React.FC = ({ toast, onDismiss // Only auto-dismiss success toasts if (toast.type === "success") { - // Use longer duration in E2E tests to give assertions time to observe the toast - const e2eDuration = 10_000; - const defaultDuration = 3000; - const duration = toast.duration ?? (window.api?.isE2E ? e2eDuration : defaultDuration); + const duration = toast.duration ?? 3000; const timer = setTimeout(() => { handleDismiss(); }, duration); diff --git a/src/browser/components/DirectoryPickerModal.tsx b/src/browser/components/DirectoryPickerModal.tsx index a72670dc7..b05356993 100644 --- a/src/browser/components/DirectoryPickerModal.tsx +++ b/src/browser/components/DirectoryPickerModal.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useState } from "react"; import { Modal, ModalActions, CancelButton, PrimaryButton } from "./Modal"; import type { FileTreeNode } from "@/common/utils/git/numstatParser"; import { DirectoryTree } from "./DirectoryTree"; -import { useORPC } from "@/browser/orpc/react"; +import type { IPCApi } from "@/common/types/ipc"; interface DirectoryPickerModalProps { isOpen: boolean; @@ -17,37 +17,44 @@ export const DirectoryPickerModal: React.FC = ({ onClose, onSelectPath, }) => { - const client = useORPC(); + type FsListDirectoryResponse = FileTreeNode & { success?: boolean; error?: unknown }; const [root, setRoot] = useState(null); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); - const loadDirectory = useCallback( - async (path: string) => { - setIsLoading(true); - setError(null); + const loadDirectory = useCallback(async (path: string) => { + const api = window.api as unknown as IPCApi; + if (!api.fs?.listDirectory) { + setError("Directory picker is not available in this environment."); + return; + } - try { - const result = await client.general.listDirectory({ path }); + setIsLoading(true); + setError(null); - if (!result.success) { - const errorMessage = typeof result.error === "string" ? result.error : "Unknown error"; - setError(`Failed to load directory: ${errorMessage}`); - setRoot(null); - return; - } + try { + const tree = (await api.fs.listDirectory(path)) as FsListDirectoryResponse; - setRoot(result.data); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - setError(`Failed to load directory: ${message}`); + // In browser/server mode, HttpIpcMainAdapter wraps handler errors as + // { success: false, error }, and invokeIPC returns that object instead + // of throwing. Detect that shape and surface a friendly error instead + // of crashing when accessing tree.children. + if (tree.success === false) { + const errorMessage = typeof tree.error === "string" ? tree.error : "Unknown error"; + setError(`Failed to load directory: ${errorMessage}`); setRoot(null); - } finally { - setIsLoading(false); + return; } - }, - [client] - ); + + setRoot(tree); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setError(`Failed to load directory: ${message}`); + setRoot(null); + } finally { + setIsLoading(false); + } + }, []); useEffect(() => { if (!isOpen) return; diff --git a/src/browser/components/ProjectCreateModal.stories.tsx b/src/browser/components/ProjectCreateModal.stories.tsx index 5c4b9d3e0..5b86bf745 100644 --- a/src/browser/components/ProjectCreateModal.stories.tsx +++ b/src/browser/components/ProjectCreateModal.stories.tsx @@ -1,9 +1,9 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { action } from "storybook/actions"; import { expect, userEvent, waitFor, within } from "storybook/test"; -import { useState, useMemo } from "react"; +import { useState } from "react"; import { ProjectCreateModal } from "./ProjectCreateModal"; -import { ORPCProvider, type ORPCClient } from "@/browser/orpc/react"; +import type { IPCApi } from "@/common/types/ipc"; import type { FileTreeNode } from "@/common/utils/git/numstatParser"; // Mock file tree structure for directory picker @@ -67,72 +67,52 @@ function findNodeByPath(root: FileTreeNode, targetPath: string): FileTreeNode | return null; } -// Create mock ORPC client for stories -function createMockClient(options?: { onProjectCreate?: (path: string) => void }): ORPCClient { - return { - projects: { - list: () => Promise.resolve([]), - create: (input: { projectPath: string }) => { - options?.onProjectCreate?.(input.projectPath); - return Promise.resolve({ - success: true as const, - data: { - normalizedPath: input.projectPath, - projectConfig: { workspaces: [] }, - }, - }); - }, - remove: () => Promise.resolve({ success: true as const, data: undefined }), - pickDirectory: () => Promise.resolve(null), - listBranches: () => Promise.resolve({ branches: ["main"], recommendedTrunk: "main" }), - secrets: { - get: () => Promise.resolve([]), - update: () => Promise.resolve({ success: true as const, data: undefined }), - }, - }, - general: { - listDirectory: async (input: { path: string }) => { +// Setup mock API with fs.listDirectory support (browser mode) +function setupMockAPI(options?: { onProjectCreate?: (path: string) => void }) { + const mockApi: Partial & { platform: string } = { + platform: "browser", // Enable web directory picker + fs: { + listDirectory: async (path: string) => { // Simulate async delay await new Promise((resolve) => setTimeout(resolve, 50)); // Handle "." as starting path - const targetPath = input.path === "." ? "/home/user" : input.path; + const targetPath = path === "." ? "/home/user" : path; const node = findNodeByPath(mockFileTree, targetPath); if (!node) { return { - success: false as const, - error: `Directory not found: ${input.path}`, - }; + success: false, + error: `Directory not found: ${path}`, + } as unknown as FileTreeNode; } - return { success: true as const, data: node }; + return node; }, }, - } as unknown as ORPCClient; -} - -// Create mock ORPC client that returns validation error -function createValidationErrorClient(): ORPCClient { - return { projects: { list: () => Promise.resolve([]), - create: () => - Promise.resolve({ - success: false as const, - error: "Not a valid git repository", - }), - remove: () => Promise.resolve({ success: true as const, data: undefined }), + create: (path: string) => { + options?.onProjectCreate?.(path); + return Promise.resolve({ + success: true, + data: { + normalizedPath: path, + projectConfig: { workspaces: [] }, + }, + }); + }, + remove: () => Promise.resolve({ success: true, data: undefined }), pickDirectory: () => Promise.resolve(null), - listBranches: () => Promise.resolve({ branches: [], recommendedTrunk: "main" }), + listBranches: () => Promise.resolve({ branches: ["main"], recommendedTrunk: "main" }), secrets: { get: () => Promise.resolve([]), - update: () => Promise.resolve({ success: true as const, data: undefined }), + update: () => Promise.resolve({ success: true, data: undefined }), }, }, - general: { - listDirectory: () => Promise.resolve({ success: true as const, data: mockFileTree }), - }, - } as unknown as ORPCClient; + }; + + // @ts-expect-error - Assigning partial mock API to window for Storybook + window.api = mockApi; } const meta = { @@ -142,8 +122,12 @@ const meta = { layout: "fullscreen", }, tags: ["autodocs"], - // Stories that need directory picker use custom wrappers with createMockClient() - // Other stories use the global ORPCProvider from preview.tsx + decorators: [ + (Story) => { + setupMockAPI(); + return ; + }, + ], } satisfies Meta; export default meta; @@ -179,12 +163,6 @@ const ProjectCreateModalWrapper: React.FC<{ ); }; -// Wrapper that provides custom ORPC client for directory picker stories -const DirectoryPickerStoryWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => { - const client = useMemo(() => createMockClient(), []); - return {children}; -}; - export const Default: Story = { args: { isOpen: true, @@ -204,8 +182,8 @@ export const WithTypedPath: Story = { const canvas = within(canvasElement); // Wait for modal to be visible - await waitFor(async () => { - await expect(canvas.getByRole("dialog")).toBeInTheDocument(); + await waitFor(() => { + expect(canvas.getByRole("dialog")).toBeInTheDocument(); }); // Find and type in the input field @@ -213,7 +191,7 @@ export const WithTypedPath: Story = { await userEvent.type(input, "/home/user/projects/my-app"); // Verify input value - await expect(input).toHaveValue("/home/user/projects/my-app"); + expect(input).toHaveValue("/home/user/projects/my-app"); }, }; @@ -223,27 +201,23 @@ export const BrowseButtonOpensDirectoryPicker: Story = { onClose: action("close"), onSuccess: action("success"), }, - render: () => ( - - - - ), + render: () => , play: async ({ canvasElement }) => { const canvas = within(canvasElement); // Wait for modal to be visible - await waitFor(async () => { - await expect(canvas.getByRole("dialog")).toBeInTheDocument(); + await waitFor(() => { + expect(canvas.getByRole("dialog")).toBeInTheDocument(); }); // Find and click the Browse button const browseButton = canvas.getByText("Browse…"); - await expect(browseButton).toBeInTheDocument(); + expect(browseButton).toBeInTheDocument(); await userEvent.click(browseButton); // Wait for DirectoryPickerModal to open (it has title "Select Project Directory") - await waitFor(async () => { - await expect(canvas.getByText("Select Project Directory")).toBeInTheDocument(); + await waitFor(() => { + expect(canvas.getByText("Select Project Directory")).toBeInTheDocument(); }); }, }; @@ -254,30 +228,26 @@ export const DirectoryPickerNavigation: Story = { onClose: action("close"), onSuccess: action("success"), }, - render: () => ( - - - - ), + render: () => , play: async ({ canvasElement }) => { const canvas = within(canvasElement); // Wait for modal and click Browse - await waitFor(async () => { - await expect(canvas.getByRole("dialog")).toBeInTheDocument(); + await waitFor(() => { + expect(canvas.getByRole("dialog")).toBeInTheDocument(); }); await userEvent.click(canvas.getByText("Browse…")); // Wait for DirectoryPickerModal to open and load directories - await waitFor(async () => { - await expect(canvas.getByText("Select Project Directory")).toBeInTheDocument(); + await waitFor(() => { + expect(canvas.getByText("Select Project Directory")).toBeInTheDocument(); }); // Wait for directory listing to load (should show subdirectories of /home/user) await waitFor( - async () => { - await expect(canvas.getByText("projects")).toBeInTheDocument(); + () => { + expect(canvas.getByText("projects")).toBeInTheDocument(); }, { timeout: 2000 } ); @@ -287,8 +257,8 @@ export const DirectoryPickerNavigation: Story = { // Wait for subdirectories to load await waitFor( - async () => { - await expect(canvas.getByText("my-app")).toBeInTheDocument(); + () => { + expect(canvas.getByText("my-app")).toBeInTheDocument(); }, { timeout: 2000 } ); @@ -301,30 +271,26 @@ export const DirectoryPickerSelectsPath: Story = { onClose: action("close"), onSuccess: action("success"), }, - render: () => ( - - - - ), + render: () => , play: async ({ canvasElement }) => { const canvas = within(canvasElement); // Wait for modal and click Browse - await waitFor(async () => { - await expect(canvas.getByRole("dialog")).toBeInTheDocument(); + await waitFor(() => { + expect(canvas.getByRole("dialog")).toBeInTheDocument(); }); await userEvent.click(canvas.getByText("Browse…")); // Wait for DirectoryPickerModal - await waitFor(async () => { - await expect(canvas.getByText("Select Project Directory")).toBeInTheDocument(); + await waitFor(() => { + expect(canvas.getByText("Select Project Directory")).toBeInTheDocument(); }); // Wait for directory listing to load await waitFor( - async () => { - await expect(canvas.getByText("projects")).toBeInTheDocument(); + () => { + expect(canvas.getByText("projects")).toBeInTheDocument(); }, { timeout: 2000 } ); @@ -334,8 +300,8 @@ export const DirectoryPickerSelectsPath: Story = { // Wait for subdirectories await waitFor( - async () => { - await expect(canvas.getByText("my-app")).toBeInTheDocument(); + () => { + expect(canvas.getByText("my-app")).toBeInTheDocument(); }, { timeout: 2000 } ); @@ -345,8 +311,8 @@ export const DirectoryPickerSelectsPath: Story = { // Wait for path update in subtitle await waitFor( - async () => { - await expect(canvas.getByText("/home/user/projects/my-app")).toBeInTheDocument(); + () => { + expect(canvas.getByText("/home/user/projects/my-app")).toBeInTheDocument(); }, { timeout: 2000 } ); @@ -355,105 +321,112 @@ export const DirectoryPickerSelectsPath: Story = { await userEvent.click(canvas.getByText("Select")); // Directory picker should close and path should be in input - await waitFor(async () => { + await waitFor(() => { // DirectoryPickerModal should be closed - await expect(canvas.queryByText("Select Project Directory")).not.toBeInTheDocument(); + expect(canvas.queryByText("Select Project Directory")).not.toBeInTheDocument(); }); // Check that the path was populated in the input const input = canvas.getByPlaceholderText("/home/user/projects/my-project"); - await expect(input).toHaveValue("/home/user/projects/my-app"); + expect(input).toHaveValue("/home/user/projects/my-app"); }, }; -// Wrapper for FullFlowWithDirectoryPicker that captures created path -const FullFlowWrapper: React.FC = () => { - const [createdPath, setCreatedPath] = useState(""); - const client = useMemo( - () => - createMockClient({ - onProjectCreate: (path) => setCreatedPath(path), - }), - [] - ); - - return ( - - action("created")(createdPath)} /> - - ); -}; - export const FullFlowWithDirectoryPicker: Story = { args: { isOpen: true, onClose: action("close"), onSuccess: action("success"), }, - render: () => , + render: () => { + let createdPath = ""; + setupMockAPI({ + onProjectCreate: (path) => { + createdPath = path; + }, + }); + return action("created")(createdPath)} />; + }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); // Wait for modal - await waitFor(async () => { - await expect(canvas.getByRole("dialog")).toBeInTheDocument(); + await waitFor(() => { + expect(canvas.getByRole("dialog")).toBeInTheDocument(); }); // Click Browse await userEvent.click(canvas.getByText("Browse…")); // Navigate to project directory - await waitFor(async () => { - await expect(canvas.getByText("projects")).toBeInTheDocument(); + await waitFor(() => { + expect(canvas.getByText("projects")).toBeInTheDocument(); }); await userEvent.click(canvas.getByText("projects")); - await waitFor(async () => { - await expect(canvas.getByText("api-server")).toBeInTheDocument(); + await waitFor(() => { + expect(canvas.getByText("api-server")).toBeInTheDocument(); }); await userEvent.click(canvas.getByText("api-server")); // Wait for path update - await waitFor(async () => { - await expect(canvas.getByText("/home/user/projects/api-server")).toBeInTheDocument(); + await waitFor(() => { + expect(canvas.getByText("/home/user/projects/api-server")).toBeInTheDocument(); }); // Select the directory await userEvent.click(canvas.getByText("Select")); // Verify path is in input - await waitFor(async () => { + await waitFor(() => { const input = canvas.getByPlaceholderText("/home/user/projects/my-project"); - await expect(input).toHaveValue("/home/user/projects/api-server"); + expect(input).toHaveValue("/home/user/projects/api-server"); }); // Click Add Project to complete the flow await userEvent.click(canvas.getByRole("button", { name: "Add Project" })); // Modal should close after successful creation - await waitFor(async () => { - await expect(canvas.queryByRole("dialog")).not.toBeInTheDocument(); + await waitFor(() => { + expect(canvas.queryByRole("dialog")).not.toBeInTheDocument(); }); }, }; -// Wrapper for ValidationError story with error-returning client -const ValidationErrorWrapper: React.FC = () => { - const client = useMemo(() => createValidationErrorClient(), []); - return ( - - - - ); -}; - export const ValidationError: Story = { args: { isOpen: true, onClose: action("close"), onSuccess: action("success"), }, - render: () => , + decorators: [ + (Story) => { + // Setup mock with validation error + const mockApi: Partial = { + fs: { + listDirectory: () => Promise.resolve(mockFileTree), + }, + projects: { + list: () => Promise.resolve([]), + create: () => + Promise.resolve({ + success: false, + error: "Not a valid git repository", + }), + remove: () => Promise.resolve({ success: true, data: undefined }), + pickDirectory: () => Promise.resolve(null), + listBranches: () => Promise.resolve({ branches: [], recommendedTrunk: "main" }), + secrets: { + get: () => Promise.resolve([]), + update: () => Promise.resolve({ success: true, data: undefined }), + }, + }, + }; + // @ts-expect-error - Mock API + window.api = mockApi; + return ; + }, + ], play: async ({ canvasElement }) => { const canvas = within(canvasElement); @@ -465,8 +438,8 @@ export const ValidationError: Story = { await userEvent.click(canvas.getByRole("button", { name: "Add Project" })); // Wait for error message - await waitFor(async () => { - await expect(canvas.getByText("Not a valid git repository")).toBeInTheDocument(); + await waitFor(() => { + expect(canvas.getByText("Not a valid git repository")).toBeInTheDocument(); }); }, }; diff --git a/src/browser/components/ProjectCreateModal.tsx b/src/browser/components/ProjectCreateModal.tsx index 96262accc..107706c42 100644 --- a/src/browser/components/ProjectCreateModal.tsx +++ b/src/browser/components/ProjectCreateModal.tsx @@ -1,8 +1,8 @@ import React, { useState, useCallback } from "react"; import { Modal, ModalActions, CancelButton, PrimaryButton } from "./Modal"; import { DirectoryPickerModal } from "./DirectoryPickerModal"; +import type { IPCApi } from "@/common/types/ipc"; import type { ProjectConfig } from "@/node/config"; -import { useORPC } from "@/browser/orpc/react"; interface ProjectCreateModalProps { isOpen: boolean; @@ -21,13 +21,13 @@ export const ProjectCreateModal: React.FC = ({ onClose, onSuccess, }) => { - const client = useORPC(); const [path, setPath] = useState(""); const [error, setError] = useState(""); - // In Electron mode, window.api exists (set by preload) and has native directory picker via ORPC - // In browser mode, window.api doesn't exist and we use web-based DirectoryPickerModal - const isDesktop = !!window.api; - const hasWebFsPicker = !isDesktop; + // Detect desktop environment where native directory picker is available + const isDesktop = + window.api.platform !== "browser" && typeof window.api.projects.pickDirectory === "function"; + const api = window.api as unknown as IPCApi; + const hasWebFsPicker = window.api.platform === "browser" && !!api.fs?.listDirectory; const [isCreating, setIsCreating] = useState(false); const [isDirPickerOpen, setIsDirPickerOpen] = useState(false); @@ -44,7 +44,7 @@ export const ProjectCreateModal: React.FC = ({ const handleBrowse = useCallback(async () => { try { - const selectedPath = await client.projects.pickDirectory(); + const selectedPath = await window.api.projects.pickDirectory(); if (selectedPath) { setPath(selectedPath); setError(""); @@ -52,7 +52,7 @@ export const ProjectCreateModal: React.FC = ({ } catch (err) { console.error("Failed to pick directory:", err); } - }, [client]); + }, []); const handleSelect = useCallback(async () => { const trimmedPath = path.trim(); @@ -66,15 +66,18 @@ export const ProjectCreateModal: React.FC = ({ try { // First check if project already exists - const existingProjects = await client.projects.list(); + const existingProjects = await window.api.projects.list(); const existingPaths = new Map(existingProjects); // Try to create the project - const result = await client.projects.create({ projectPath: trimmedPath }); + const result = await window.api.projects.create(trimmedPath); if (result.success) { // Check if duplicate (backend may normalize the path) - const { normalizedPath, projectConfig } = result.data; + const { normalizedPath, projectConfig } = result.data as { + normalizedPath: string; + projectConfig: ProjectConfig; + }; if (existingPaths.has(normalizedPath)) { setError("This project has already been added."); return; @@ -98,7 +101,7 @@ export const ProjectCreateModal: React.FC = ({ } finally { setIsCreating(false); } - }, [path, onSuccess, onClose, client]); + }, [path, onSuccess, onClose]); const handleBrowseClick = useCallback(() => { if (isDesktop) { diff --git a/src/browser/components/RightSidebar/CodeReview/ReviewPanel.stories.tsx b/src/browser/components/RightSidebar/CodeReview/ReviewPanel.stories.tsx index a47722f3f..67f5a9794 100644 --- a/src/browser/components/RightSidebar/CodeReview/ReviewPanel.stories.tsx +++ b/src/browser/components/RightSidebar/CodeReview/ReviewPanel.stories.tsx @@ -1,9 +1,10 @@ -import React, { useRef, useMemo } from "react"; +import React, { useRef } from "react"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { ReviewPanel } from "./ReviewPanel"; +import type { IPCApi } from "@/common/types/ipc"; import { deleteWorkspaceStorage } from "@/common/constants/storage"; -import { ORPCProvider } from "@/browser/orpc/react"; -import { createMockORPCClient } from "@/../.storybook/mocks/orpc"; +import type { BashToolResult } from "@/common/types/tools"; +import type { Result } from "@/common/types/result"; type ScenarioName = "rich" | "empty" | "truncated"; @@ -352,33 +353,34 @@ const scenarioConfigs: Record = { }, }; -function createExecuteBashMock(config: ScenarioConfig) { - return (_workspaceId: string, command: string) => { +function createSuccessResult( + output: string, + overrides?: { truncated?: { reason: string; totalLines: number } } +): Result { + return { + success: true as const, + data: { + success: true as const, + output, + exitCode: 0, + wall_duration_ms: 5, + ...overrides, + }, + }; +} + +function setupCodeReviewMocks(config: ScenarioConfig) { + const executeBash: IPCApi["workspace"]["executeBash"] = (_workspaceId, command) => { if (command.includes("git ls-files --others --exclude-standard")) { - return Promise.resolve({ - success: true as const, - output: config.untrackedFiles.join("\n"), - exitCode: 0 as const, - wall_duration_ms: 5, - }); + return Promise.resolve(createSuccessResult(config.untrackedFiles.join("\n"))); } if (command.includes("--numstat")) { - return Promise.resolve({ - success: true as const, - output: config.numstatOutput, - exitCode: 0 as const, - wall_duration_ms: 5, - }); + return Promise.resolve(createSuccessResult(config.numstatOutput)); } if (command.includes("git add --")) { - return Promise.resolve({ - success: true as const, - output: "", - exitCode: 0 as const, - wall_duration_ms: 5, - }); + return Promise.resolve(createSuccessResult("")); } if (command.startsWith("git diff") || command.includes("git diff ")) { @@ -389,25 +391,29 @@ function createExecuteBashMock(config: ScenarioConfig) { ? (config.diffByFile[pathFilter] ?? "") : Object.values(config.diffByFile).filter(Boolean).join("\n\n"); - return Promise.resolve({ - success: true as const, - output: diffOutput, - exitCode: 0 as const, - wall_duration_ms: 5, - ...(!pathFilter && config.truncated ? { truncated: config.truncated } : {}), - }); + const truncated = + !pathFilter && config.truncated ? { truncated: config.truncated } : undefined; + return Promise.resolve(createSuccessResult(diffOutput, truncated)); } - return Promise.resolve({ - success: true as const, - output: "", - exitCode: 0 as const, - wall_duration_ms: 5, - }); + return Promise.resolve(createSuccessResult("")); }; -} -function setupLocalStorage(config: ScenarioConfig) { + const mockApi = { + workspace: { + executeBash, + }, + platform: "browser", + versions: { + node: "18.18.0", + chrome: "120.0.0.0", + electron: "28.0.0", + }, + } as unknown as IPCApi; + + // @ts-expect-error - mockApi is not typed correctly + window.api = mockApi; + deleteWorkspaceStorage(config.workspaceId); localStorage.removeItem(`review-diff-base:${config.workspaceId}`); localStorage.removeItem(`review-file-filter:${config.workspaceId}`); @@ -420,34 +426,23 @@ const ReviewPanelStoryWrapper: React.FC<{ scenario: ScenarioName }> = ({ scenari const initialized = useRef(false); const config = scenarioConfigs[scenario]; - // Create mock ORPC client with the scenario-specific executeBash mock - const client = useMemo( - () => - createMockORPCClient({ - executeBash: createExecuteBashMock(config), - }), - [config] - ); - if (!initialized.current) { - setupLocalStorage(config); + setupCodeReviewMocks(config); initialized.current = true; } return ( - -
- -
-
+
+ +
); }; diff --git a/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx b/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx index 6092b7fd3..d1f21557e 100644 --- a/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx +++ b/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx @@ -37,7 +37,6 @@ import type { FileTreeNode } from "@/common/utils/git/numstatParser"; import { matchesKeybind, KEYBINDS, formatKeybind } from "@/browser/utils/ui/keybinds"; import { applyFrontendFilters } from "@/browser/utils/review/filterHunks"; import { cn } from "@/common/lib/utils"; -import { useORPC } from "@/browser/orpc/react"; interface ReviewPanelProps { workspaceId: string; @@ -121,7 +120,6 @@ export const ReviewPanel: React.FC = ({ onReviewNote, focusTrigger, }) => { - const client = useORPC(); const panelRef = useRef(null); const searchInputRef = useRef(null); const [hunks, setHunks] = useState([]); @@ -203,10 +201,8 @@ export const ReviewPanel: React.FC = ({ "numstat" ); - const numstatResult = await client.workspace.executeBash({ - workspaceId, - script: numstatCommand, - options: { timeout_secs: 30 }, + const numstatResult = await window.api.workspace.executeBash(workspaceId, numstatCommand, { + timeout_secs: 30, }); if (cancelled) return; @@ -231,14 +227,7 @@ export const ReviewPanel: React.FC = ({ return () => { cancelled = true; }; - }, [ - client, - workspaceId, - workspacePath, - filters.diffBase, - filters.includeUncommitted, - refreshTrigger, - ]); + }, [workspaceId, workspacePath, filters.diffBase, filters.includeUncommitted, refreshTrigger]); // Load diff hunks - when workspace, diffBase, selected path, or refreshTrigger changes useEffect(() => { @@ -264,10 +253,8 @@ export const ReviewPanel: React.FC = ({ ); // Fetch diff - const diffResult = await client.workspace.executeBash({ - workspaceId, - script: diffCommand, - options: { timeout_secs: 30 }, + const diffResult = await window.api.workspace.executeBash(workspaceId, diffCommand, { + timeout_secs: 30, }); if (cancelled) return; diff --git a/src/browser/components/RightSidebar/CodeReview/UntrackedStatus.tsx b/src/browser/components/RightSidebar/CodeReview/UntrackedStatus.tsx index 5beef4dd6..c6c516970 100644 --- a/src/browser/components/RightSidebar/CodeReview/UntrackedStatus.tsx +++ b/src/browser/components/RightSidebar/CodeReview/UntrackedStatus.tsx @@ -5,7 +5,6 @@ import React, { useState, useEffect, useRef, useLayoutEffect } from "react"; import { createPortal } from "react-dom"; import { cn } from "@/common/lib/utils"; -import { useORPC } from "@/browser/orpc/react"; interface UntrackedStatusProps { workspaceId: string; @@ -20,7 +19,6 @@ export const UntrackedStatus: React.FC = ({ refreshTrigger, onRefresh, }) => { - const client = useORPC(); const [untrackedFiles, setUntrackedFiles] = useState([]); const [isLoading, setIsLoading] = useState(false); const [showTooltip, setShowTooltip] = useState(false); @@ -74,11 +72,11 @@ export const UntrackedStatus: React.FC = ({ } try { - const result = await client.workspace.executeBash({ + const result = await window.api.workspace.executeBash( workspaceId, - script: "git ls-files --others --exclude-standard", - options: { timeout_secs: 5 }, - }); + "git ls-files --others --exclude-standard", + { timeout_secs: 5 } + ); if (cancelled) return; @@ -104,7 +102,7 @@ export const UntrackedStatus: React.FC = ({ return () => { cancelled = true; }; - }, [client, workspaceId, workspacePath, refreshTrigger]); + }, [workspaceId, workspacePath, refreshTrigger]); // Close tooltip when clicking outside useEffect(() => { @@ -131,11 +129,11 @@ export const UntrackedStatus: React.FC = ({ // Use git add with -- to treat all arguments as file paths // Escape single quotes by replacing ' with '\'' for safe shell quoting const escapedFiles = untrackedFiles.map((f) => `'${f.replace(/'/g, "'\\''")}'`).join(" "); - const result = await client.workspace.executeBash({ + const result = await window.api.workspace.executeBash( workspaceId, - script: `git add -- ${escapedFiles}`, - options: { timeout_secs: 10 }, - }); + `git add -- ${escapedFiles}`, + { timeout_secs: 10 } + ); if (result.success) { // Close tooltip first diff --git a/src/browser/components/Settings/Settings.stories.tsx b/src/browser/components/Settings/Settings.stories.tsx index 1902338c3..34bc95b4a 100644 --- a/src/browser/components/Settings/Settings.stories.tsx +++ b/src/browser/components/Settings/Settings.stories.tsx @@ -1,13 +1,15 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, waitFor, within } from "storybook/test"; -import React, { useMemo, useState } from "react"; +import React, { useState } from "react"; import { SettingsProvider, useSettings } from "@/browser/contexts/SettingsContext"; import { SettingsModal } from "./SettingsModal"; -import type { ProvidersConfigMap } from "./types"; -import { ORPCProvider, type ORPCClient } from "@/browser/orpc/react"; +import type { IPCApi } from "@/common/types/ipc"; // Mock providers config for stories -const mockProvidersConfig: ProvidersConfigMap = { +const mockProvidersConfig: Record< + string, + { apiKeySet: boolean; baseUrl?: string; models?: string[] } +> = { anthropic: { apiKeySet: true }, openai: { apiKeySet: true, baseUrl: "https://custom.openai.com" }, google: { apiKeySet: false }, @@ -16,27 +18,27 @@ const mockProvidersConfig: ProvidersConfigMap = { openrouter: { apiKeySet: true, models: ["mistral/mistral-7b"] }, }; -function createMockProviderClient(config = mockProvidersConfig): ORPCClient { - return { - providers: { - setProviderConfig: () => Promise.resolve({ success: true as const, data: undefined }), - setModels: () => Promise.resolve({ success: true as const, data: undefined }), - getConfig: () => Promise.resolve(config), - list: () => Promise.resolve(Object.keys(config)), - }, - } as unknown as ORPCClient; +function setupMockAPI(config = mockProvidersConfig) { + const mockProviders: IPCApi["providers"] = { + setProviderConfig: () => Promise.resolve({ success: true, data: undefined }), + setModels: () => Promise.resolve({ success: true, data: undefined }), + getConfig: () => Promise.resolve(config), + list: () => Promise.resolve([]), + }; + + // @ts-expect-error - Assigning mock API to window for Storybook + window.api = { + providers: mockProviders, + }; } // Wrapper component that auto-opens the settings modal -function SettingsStoryWrapper(props: { initialSection?: string; config?: ProvidersConfigMap }) { - const client = useMemo(() => createMockProviderClient(props.config), [props.config]); +function SettingsStoryWrapper(props: { initialSection?: string }) { return ( - - - - - - + + + + ); } @@ -57,27 +59,24 @@ function SettingsAutoOpen(props: { initialSection?: string }) { // Interactive wrapper for testing close behavior function InteractiveSettingsWrapper(props: { initialSection?: string }) { const [reopenCount, setReopenCount] = useState(0); - const client = useMemo(() => createMockProviderClient(), []); return ( - - -
- -
- Click overlay or press Escape to close -
+ +
+ +
+ Click overlay or press Escape to close
- - - - +
+ + +
); } @@ -88,6 +87,12 @@ const meta = { layout: "fullscreen", }, tags: ["autodocs"], + decorators: [ + (Story) => { + setupMockAPI(); + return ; + }, + ], } satisfies Meta; export default meta; @@ -150,19 +155,20 @@ export const Models: Story = { * Models section with no custom models configured. */ export const ModelsEmpty: Story = { - render: () => ( - { + setupMockAPI({ anthropic: { apiKeySet: true }, openai: { apiKeySet: true }, google: { apiKeySet: false }, xai: { apiKeySet: false }, ollama: { apiKeySet: false }, openrouter: { apiKeySet: false }, - }} - /> - ), + }); + return ; + }, + ], + render: () => , }; /** diff --git a/src/browser/components/Settings/sections/ModelsSection.tsx b/src/browser/components/Settings/sections/ModelsSection.tsx index 026adbb73..c2056142c 100644 --- a/src/browser/components/Settings/sections/ModelsSection.tsx +++ b/src/browser/components/Settings/sections/ModelsSection.tsx @@ -2,7 +2,6 @@ import React, { useState, useEffect, useCallback } from "react"; import { Plus, Trash2 } from "lucide-react"; import type { ProvidersConfigMap } from "../types"; import { SUPPORTED_PROVIDERS, PROVIDER_DISPLAY_NAMES } from "@/common/constants/providers"; -import { useORPC } from "@/browser/orpc/react"; interface NewModelForm { provider: string; @@ -10,7 +9,6 @@ interface NewModelForm { } export function ModelsSection() { - const client = useORPC(); const [config, setConfig] = useState({}); const [newModel, setNewModel] = useState({ provider: "", modelId: "" }); const [saving, setSaving] = useState(false); @@ -18,10 +16,10 @@ export function ModelsSection() { // Load config on mount useEffect(() => { void (async () => { - const cfg = await client.providers.getConfig(); + const cfg = await window.api.providers.getConfig(); setConfig(cfg); })(); - }, [client]); + }, []); // Get all custom models across providers const getAllModels = (): Array<{ provider: string; modelId: string }> => { @@ -44,10 +42,10 @@ export function ModelsSection() { const currentModels = config[newModel.provider]?.models ?? []; const updatedModels = [...currentModels, newModel.modelId.trim()]; - await client.providers.setModels({ provider: newModel.provider, models: updatedModels }); + await window.api.providers.setModels(newModel.provider, updatedModels); // Refresh config - const cfg = await client.providers.getConfig(); + const cfg = await window.api.providers.getConfig(); setConfig(cfg); setNewModel({ provider: "", modelId: "" }); @@ -56,7 +54,7 @@ export function ModelsSection() { } finally { setSaving(false); } - }, [client, newModel, config]); + }, [newModel, config]); const handleRemoveModel = useCallback( async (provider: string, modelId: string) => { @@ -65,10 +63,10 @@ export function ModelsSection() { const currentModels = config[provider]?.models ?? []; const updatedModels = currentModels.filter((m) => m !== modelId); - await client.providers.setModels({ provider, models: updatedModels }); + await window.api.providers.setModels(provider, updatedModels); // Refresh config - const cfg = await client.providers.getConfig(); + const cfg = await window.api.providers.getConfig(); setConfig(cfg); // Notify other components about the change @@ -77,7 +75,7 @@ export function ModelsSection() { setSaving(false); } }, - [client, config] + [config] ); const allModels = getAllModels(); diff --git a/src/browser/components/Settings/sections/ProvidersSection.tsx b/src/browser/components/Settings/sections/ProvidersSection.tsx index ff85fca2b..ac18481e8 100644 --- a/src/browser/components/Settings/sections/ProvidersSection.tsx +++ b/src/browser/components/Settings/sections/ProvidersSection.tsx @@ -3,7 +3,6 @@ import { ChevronDown, ChevronRight, Check, X } from "lucide-react"; import type { ProvidersConfigMap } from "../types"; import { SUPPORTED_PROVIDERS, PROVIDER_DISPLAY_NAMES } from "@/common/constants/providers"; import type { ProviderName } from "@/common/constants/providers"; -import { useORPC } from "@/browser/orpc/react"; interface FieldConfig { key: string; @@ -59,7 +58,6 @@ function getProviderFields(provider: ProviderName): FieldConfig[] { } export function ProvidersSection() { - const client = useORPC(); const [config, setConfig] = useState({}); const [expandedProvider, setExpandedProvider] = useState(null); const [editingField, setEditingField] = useState<{ @@ -72,10 +70,10 @@ export function ProvidersSection() { // Load config on mount useEffect(() => { void (async () => { - const cfg = await client.providers.getConfig(); + const cfg = await window.api.providers.getConfig(); setConfig(cfg); })(); - }, [client]); + }, []); const handleToggleProvider = (provider: string) => { setExpandedProvider((prev) => (prev === provider ? null : provider)); @@ -86,8 +84,10 @@ export function ProvidersSection() { setEditingField({ provider, field }); // For secrets, start empty since we only show masked value // For text fields, show current value - const currentValue = getFieldValue(provider, field); - setEditValue(fieldConfig.type === "text" && currentValue ? currentValue : ""); + const currentValue = (config[provider] as Record | undefined)?.[field]; + setEditValue( + fieldConfig.type === "text" && typeof currentValue === "string" ? currentValue : "" + ); }; const handleCancelEdit = () => { @@ -101,40 +101,41 @@ export function ProvidersSection() { setSaving(true); try { const { provider, field } = editingField; - await client.providers.setProviderConfig({ provider, keyPath: [field], value: editValue }); + await window.api.providers.setProviderConfig(provider, [field], editValue); // Refresh config - const cfg = await client.providers.getConfig(); + const cfg = await window.api.providers.getConfig(); setConfig(cfg); setEditingField(null); setEditValue(""); } finally { setSaving(false); } - }, [client, editingField, editValue]); + }, [editingField, editValue]); - const handleClearField = useCallback( - async (provider: string, field: string) => { - setSaving(true); - try { - await client.providers.setProviderConfig({ provider, keyPath: [field], value: "" }); - const cfg = await client.providers.getConfig(); - setConfig(cfg); - } finally { - setSaving(false); - } - }, - [client] - ); + const handleClearField = useCallback(async (provider: string, field: string) => { + setSaving(true); + try { + await window.api.providers.setProviderConfig(provider, [field], ""); + const cfg = await window.api.providers.getConfig(); + setConfig(cfg); + } finally { + setSaving(false); + } + }, []); const isConfigured = (provider: string): boolean => { const providerConfig = config[provider]; if (!providerConfig) return false; - // For Bedrock, check if any AWS credential field is set - if (provider === "bedrock" && providerConfig.aws) { - const { aws } = providerConfig; - return !!(aws.region ?? aws.bearerTokenSet ?? aws.accessKeyIdSet ?? aws.secretAccessKeySet); + // For Bedrock, check if any credential field is set + if (provider === "bedrock") { + return !!( + providerConfig.region ?? + providerConfig.bearerTokenSet ?? + providerConfig.accessKeyIdSet ?? + providerConfig.secretAccessKeySet + ); } // For other providers, check apiKeySet @@ -142,40 +143,20 @@ export function ProvidersSection() { }; const getFieldValue = (provider: string, field: string): string | undefined => { - const providerConfig = config[provider]; + const providerConfig = config[provider] as Record | undefined; if (!providerConfig) return undefined; - - // For bedrock, check aws nested object for region - if (provider === "bedrock" && field === "region") { - return providerConfig.aws?.region; - } - - // For standard fields like baseUrl - const value = providerConfig[field as keyof typeof providerConfig]; + const value = providerConfig[field]; return typeof value === "string" ? value : undefined; }; const isFieldSet = (provider: string, field: string, fieldConfig: FieldConfig): boolean => { - const providerConfig = config[provider]; - if (!providerConfig) return false; - if (fieldConfig.type === "secret") { // For apiKey, we have apiKeySet from the sanitized config - if (field === "apiKey") return providerConfig.apiKeySet ?? false; - - // For AWS secrets, check the aws nested object - if (provider === "bedrock" && providerConfig.aws) { - const { aws } = providerConfig; - switch (field) { - case "bearerToken": - return aws.bearerTokenSet ?? false; - case "accessKeyId": - return aws.accessKeyIdSet ?? false; - case "secretAccessKey": - return aws.secretAccessKeySet ?? false; - } - } - return false; + if (field === "apiKey") return config[provider]?.apiKeySet ?? false; + // For other secrets, check if the field exists in the raw config + // Since we don't expose secret values, we assume they're not set if undefined + const providerConfig = config[provider] as Record | undefined; + return providerConfig?.[`${field}Set`] === true; } return !!getFieldValue(provider, field); }; diff --git a/src/browser/components/Settings/types.ts b/src/browser/components/Settings/types.ts index 96c6e790a..831d991d0 100644 --- a/src/browser/components/Settings/types.ts +++ b/src/browser/components/Settings/types.ts @@ -7,20 +7,17 @@ export interface SettingsSection { component: React.ComponentType; } -/** AWS credential status for Bedrock provider */ -export interface AWSCredentialStatus { - region?: string; - bearerTokenSet: boolean; - accessKeyIdSet: boolean; - secretAccessKeySet: boolean; -} - export interface ProviderConfigDisplay { apiKeySet: boolean; baseUrl?: string; models?: string[]; - /** AWS-specific fields (only present for bedrock provider) */ - aws?: AWSCredentialStatus; + // Bedrock-specific fields + region?: string; + bearerTokenSet?: boolean; + accessKeyIdSet?: boolean; + secretAccessKeySet?: boolean; + // Allow additional fields for extensibility + [key: string]: unknown; } export type ProvidersConfigMap = Record; diff --git a/src/browser/components/TerminalView.tsx b/src/browser/components/TerminalView.tsx index 8324d8ffd..e6f1a266d 100644 --- a/src/browser/components/TerminalView.tsx +++ b/src/browser/components/TerminalView.tsx @@ -1,7 +1,6 @@ import { useRef, useEffect, useState } from "react"; import { Terminal, FitAddon } from "ghostty-web"; import { useTerminalSession } from "@/browser/hooks/useTerminalSession"; -import { useORPC } from "@/browser/orpc/react"; interface TerminalViewProps { workspaceId: string; @@ -33,25 +32,6 @@ export function TerminalView({ workspaceId, sessionId, visible }: TerminalViewPr } }; - const client = useORPC(); - - // Set window title - useEffect(() => { - const setWindowDetails = async () => { - try { - const workspaces = await client.workspace.list(); - const workspace = workspaces.find((ws) => ws.id === workspaceId); - if (workspace) { - document.title = `Terminal — ${workspace.projectName}/${workspace.name}`; - } else { - document.title = `Terminal — ${workspaceId}`; - } - } catch { - document.title = `Terminal — ${workspaceId}`; - } - }; - void setWindowDetails(); - }, [client, workspaceId]); const { sendInput, resize, diff --git a/src/browser/components/TitleBar.tsx b/src/browser/components/TitleBar.tsx index 01ee6f2fc..44f714f0c 100644 --- a/src/browser/components/TitleBar.tsx +++ b/src/browser/components/TitleBar.tsx @@ -3,9 +3,8 @@ import { cn } from "@/common/lib/utils"; import { VERSION } from "@/version"; import { SettingsButton } from "./SettingsButton"; import { TooltipWrapper, Tooltip } from "./Tooltip"; -import type { UpdateStatus } from "@/common/orpc/types"; +import type { UpdateStatus } from "@/common/types/ipc"; import { isTelemetryEnabled } from "@/common/telemetry"; -import { useORPC } from "@/browser/orpc/react"; // Update check intervals const UPDATE_CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000; // 4 hours @@ -74,7 +73,6 @@ function parseBuildInfo(version: unknown) { } export function TitleBar() { - const client = useORPC(); const { buildDate, extendedTimestamp, gitDescribe } = parseBuildInfo(VERSION satisfies unknown); const [updateStatus, setUpdateStatus] = useState({ type: "idle" }); const [isCheckingOnHover, setIsCheckingOnHover] = useState(false); @@ -88,41 +86,29 @@ export function TitleBar() { } // Skip update checks in browser mode - app updates only apply to Electron - if (!window.api) { + if (window.api.platform === "browser") { return; } - const controller = new AbortController(); - const { signal } = controller; - - (async () => { - try { - const iterator = await client.update.onStatus(undefined, { signal }); - for await (const status of iterator) { - if (signal.aborted) break; - setUpdateStatus(status); - setIsCheckingOnHover(false); // Clear checking state when status updates - } - } catch (error) { - if (!signal.aborted) { - console.error("Update status stream error:", error); - } - } - })(); + // Subscribe to update status changes (will receive current status immediately) + const unsubscribe = window.api.update.onStatus((status) => { + setUpdateStatus(status); + setIsCheckingOnHover(false); // Clear checking state when status updates + }); // Check for updates on mount - client.update.check(undefined).catch(console.error); + window.api.update.check().catch(console.error); // Check periodically const checkInterval = setInterval(() => { - client.update.check(undefined).catch(console.error); + window.api.update.check().catch(console.error); }, UPDATE_CHECK_INTERVAL_MS); return () => { - controller.abort(); + unsubscribe(); clearInterval(checkInterval); }; - }, [telemetryEnabled, client]); + }, [telemetryEnabled]); const handleIndicatorHover = () => { if (!telemetryEnabled) return; @@ -141,7 +127,7 @@ export function TitleBar() { ) { lastHoverCheckTime.current = now; setIsCheckingOnHover(true); - client.update.check().catch((error) => { + window.api.update.check().catch((error) => { console.error("Update check failed:", error); setIsCheckingOnHover(false); }); @@ -152,9 +138,9 @@ export function TitleBar() { if (!telemetryEnabled) return; // No-op if telemetry disabled if (updateStatus.type === "available") { - client.update.download().catch(console.error); + window.api.update.download().catch(console.error); } else if (updateStatus.type === "downloaded") { - void client.update.install(); + window.api.update.install(); } }; diff --git a/src/browser/components/WorkspaceHeader.tsx b/src/browser/components/WorkspaceHeader.tsx index 42a3a0c01..3fe55ac84 100644 --- a/src/browser/components/WorkspaceHeader.tsx +++ b/src/browser/components/WorkspaceHeader.tsx @@ -6,7 +6,6 @@ import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds"; import { useGitStatus } from "@/browser/stores/GitStatusStore"; import type { RuntimeConfig } from "@/common/types/runtime"; import { WorkspaceStatusDot } from "./WorkspaceStatusDot"; -import { useOpenTerminal } from "@/browser/hooks/useOpenTerminal"; interface WorkspaceHeaderProps { workspaceId: string; @@ -23,11 +22,10 @@ export const WorkspaceHeader: React.FC = ({ namedWorkspacePath, runtimeConfig, }) => { - const openTerminal = useOpenTerminal(); const gitStatus = useGitStatus(workspaceId); const handleOpenTerminal = useCallback(() => { - openTerminal(workspaceId); - }, [workspaceId, openTerminal]); + void window.api.terminal.openWindow(workspaceId); + }, [workspaceId]); return (
diff --git a/src/browser/components/hooks/useGitBranchDetails.ts b/src/browser/components/hooks/useGitBranchDetails.ts index d822b7331..e8c12fb9a 100644 --- a/src/browser/components/hooks/useGitBranchDetails.ts +++ b/src/browser/components/hooks/useGitBranchDetails.ts @@ -6,7 +6,6 @@ import { type GitCommit, type GitBranchHeader, } from "@/common/utils/git/parseGitLog"; -import { useORPC } from "@/browser/orpc/react"; const GitBranchDataSchema = z.object({ showBranch: z.string(), @@ -155,7 +154,6 @@ export function useGitBranchDetails( "useGitBranchDetails expects a non-empty workspaceId argument." ); - const client = useORPC(); const [branchHeaders, setBranchHeaders] = useState(null); const [commits, setCommits] = useState(null); const [dirtyFiles, setDirtyFiles] = useState(null); @@ -217,13 +215,9 @@ printf '__MUX_BRANCH_DATA__BEGIN_DATES__\\n%s\\n__MUX_BRANCH_DATA__END_DATES__\\ printf '__MUX_BRANCH_DATA__BEGIN_DIRTY_FILES__\\n%s\\n__MUX_BRANCH_DATA__END_DIRTY_FILES__\\n' "$DIRTY_FILES" `; - const result = await client.workspace.executeBash({ - workspaceId, - script, - options: { - timeout_secs: 5, - niceness: 19, // Lowest priority - don't interfere with user operations - }, + const result = await window.api.workspace.executeBash(workspaceId, script, { + timeout_secs: 5, + niceness: 19, // Lowest priority - don't interfere with user operations }); if (!result.success) { @@ -283,7 +277,7 @@ printf '__MUX_BRANCH_DATA__BEGIN_DIRTY_FILES__\\n%s\\n__MUX_BRANCH_DATA__END_DIR } finally { setIsLoading(false); } - }, [client, workspaceId, gitStatus]); + }, [workspaceId, gitStatus]); useEffect(() => { if (!enabled) { diff --git a/src/browser/contexts/ProjectContext.test.tsx b/src/browser/contexts/ProjectContext.test.tsx index 3267a731c..b031ad1b7 100644 --- a/src/browser/contexts/ProjectContext.test.tsx +++ b/src/browser/contexts/ProjectContext.test.tsx @@ -1,30 +1,19 @@ import type { ProjectConfig } from "@/node/config"; +import type { IPCApi } from "@/common/types/ipc"; import { act, cleanup, render, waitFor } from "@testing-library/react"; import { afterEach, describe, expect, mock, test } from "bun:test"; import { GlobalWindow } from "happy-dom"; import type { ProjectContext } from "./ProjectContext"; import { ProjectProvider, useProjectContext } from "./ProjectContext"; -import type { RecursivePartial } from "@/browser/testUtils"; - -import type { ORPCClient } from "@/browser/orpc/react"; - -// Mock ORPC -let currentClientMock: RecursivePartial = {}; -void mock.module("@/browser/orpc/react", () => ({ - useORPC: () => currentClientMock as ORPCClient, - ORPCProvider: ({ children }: { children: React.ReactNode }) => children, -})); describe("ProjectContext", () => { afterEach(() => { cleanup(); - // Resetting global state in tests - globalThis.window = undefined as unknown as Window & typeof globalThis; - // Resetting global state in tests - globalThis.document = undefined as unknown as Document; - - currentClientMock = {}; + // @ts-expect-error - Resetting global state in tests + globalThis.window = undefined; + // @ts-expect-error - Resetting global state in tests + globalThis.document = undefined; }); test("loads projects on mount and supports add/remove mutations", async () => { @@ -61,7 +50,7 @@ describe("ProjectContext", () => { await act(async () => { await ctx().removeProject("/alpha"); }); - expect(projectsApi.remove).toHaveBeenCalledWith({ projectPath: "/alpha" }); + expect(projectsApi.remove).toHaveBeenCalledWith("/alpha"); expect(ctx().projects.has("/alpha")).toBe(false); }); @@ -174,14 +163,11 @@ describe("ProjectContext", () => { const ctx = await setup(); const secrets = await ctx().getSecrets("/alpha"); - expect(projectsApi.secrets.get).toHaveBeenCalledWith({ projectPath: "/alpha" }); + expect(projectsApi.secrets.get).toHaveBeenCalledWith("/alpha"); expect(secrets).toEqual([{ key: "A", value: "1" }]); await ctx().updateSecrets("/alpha", [{ key: "B", value: "2" }]); - expect(projectsApi.secrets.update).toHaveBeenCalledWith({ - projectPath: "/alpha", - secrets: [{ key: "B", value: "2" }], - }); + expect(projectsApi.secrets.update).toHaveBeenCalledWith("/alpha", [{ key: "B", value: "2" }]); }); test("updateSecrets handles failure gracefully", async () => { @@ -199,10 +185,7 @@ describe("ProjectContext", () => { // Should not throw even when update fails expect(ctx().updateSecrets("/alpha", [{ key: "C", value: "3" }])).resolves.toBeUndefined(); - expect(projectsApi.secrets.update).toHaveBeenCalledWith({ - projectPath: "/alpha", - secrets: [{ key: "C", value: "3" }], - }); + expect(projectsApi.secrets.update).toHaveBeenCalledWith("/alpha", [{ key: "C", value: "3" }]); }); test("refreshProjects sets empty map on API error", async () => { @@ -305,8 +288,8 @@ describe("ProjectContext", () => { createMockAPI({ list: () => Promise.resolve([]), remove: () => Promise.resolve({ success: true as const, data: undefined }), - listBranches: ({ projectPath }: { projectPath: string }) => { - if (projectPath === "/project-a") { + listBranches: (path: string) => { + if (path === "/project-a") { return projectAPromise; } return Promise.resolve({ branches: ["main-b"], recommendedTrunk: "main-b" }); @@ -354,7 +337,7 @@ async function setup() { return () => contextRef.current!; } -function createMockAPI(overrides: RecursivePartial) { +function createMockAPI(overrides: Partial) { const projects = { create: mock( overrides.create ?? @@ -378,26 +361,30 @@ function createMockAPI(overrides: RecursivePartial) { ), pickDirectory: mock(overrides.pickDirectory ?? (() => Promise.resolve(null))), secrets: { - get: mock(overrides.secrets?.get ?? (() => Promise.resolve([]))), + get: mock( + overrides.secrets?.get + ? (...args: Parameters) => overrides.secrets!.get(...args) + : () => Promise.resolve([]) + ), update: mock( - overrides.secrets?.update ?? - (() => - Promise.resolve({ - success: true as const, - data: undefined, - })) + overrides.secrets?.update + ? (...args: Parameters) => + overrides.secrets!.update(...args) + : () => + Promise.resolve({ + success: true as const, + data: undefined, + }) ), }, - }; + } satisfies IPCApi["projects"]; - // Update the global mock - currentClientMock = { - projects: projects as unknown as RecursivePartial, + // @ts-expect-error - Setting up global state for tests + globalThis.window = new GlobalWindow(); + // @ts-expect-error - Setting up global state for tests + globalThis.window.api = { + projects, }; - - // Setting up global state for tests - globalThis.window = new GlobalWindow() as unknown as Window & typeof globalThis; - // Setting up global state for tests globalThis.document = globalThis.window.document; return projects; diff --git a/src/browser/contexts/ProjectContext.tsx b/src/browser/contexts/ProjectContext.tsx index ed5a248f2..71d3c0982 100644 --- a/src/browser/contexts/ProjectContext.tsx +++ b/src/browser/contexts/ProjectContext.tsx @@ -8,9 +8,8 @@ import { useState, type ReactNode, } from "react"; -import { useORPC } from "@/browser/orpc/react"; import type { ProjectConfig } from "@/node/config"; -import type { BranchListResult } from "@/common/orpc/types"; +import type { BranchListResult } from "@/common/types/ipc"; import type { Secret } from "@/common/types/secrets"; interface WorkspaceModalState { @@ -61,7 +60,6 @@ function deriveProjectName(projectPath: string): string { } export function ProjectProvider(props: { children: ReactNode }) { - const orpc = useORPC(); const [projects, setProjects] = useState>(new Map()); const [isProjectCreateModalOpen, setProjectCreateModalOpen] = useState(false); const [workspaceModalState, setWorkspaceModalState] = useState({ @@ -78,13 +76,13 @@ export function ProjectProvider(props: { children: ReactNode }) { const refreshProjects = useCallback(async () => { try { - const projectsList = await orpc.projects.list(); + const projectsList = await window.api.projects.list(); setProjects(new Map(projectsList)); } catch (error) { console.error("Failed to load projects:", error); setProjects(new Map()); } - }, [orpc]); + }, []); useEffect(() => { void refreshProjects(); @@ -98,32 +96,28 @@ export function ProjectProvider(props: { children: ReactNode }) { }); }, []); - const removeProject = useCallback( - async (path: string) => { - try { - const result = await orpc.projects.remove({ projectPath: path }); - if (result.success) { - setProjects((prev) => { - const next = new Map(prev); - next.delete(path); - return next; - }); - } else { - console.error("Failed to remove project:", result.error); - } - } catch (error) { - console.error("Failed to remove project:", error); + const removeProject = useCallback(async (path: string) => { + try { + const result = await window.api.projects.remove(path); + if (result.success) { + setProjects((prev) => { + const next = new Map(prev); + next.delete(path); + return next; + }); + } else { + console.error("Failed to remove project:", result.error); } - }, - [orpc] - ); + } catch (error) { + console.error("Failed to remove project:", error); + } + }, []); const getBranchesForProject = useCallback( async (projectPath: string): Promise => { - const branchResult = await orpc.projects.listBranches({ projectPath }); - const branches = branchResult.branches; - const sanitizedBranches = Array.isArray(branches) - ? branches.filter((branch): branch is string => typeof branch === "string") + const branchResult = await window.api.projects.listBranches(projectPath); + const sanitizedBranches = Array.isArray(branchResult?.branches) + ? branchResult.branches.filter((branch): branch is string => typeof branch === "string") : []; const recommended = @@ -137,7 +131,7 @@ export function ProjectProvider(props: { children: ReactNode }) { recommendedTrunk: recommended, }; }, - [orpc] + [] ); const openWorkspaceModal = useCallback( @@ -207,22 +201,16 @@ export function ProjectProvider(props: { children: ReactNode }) { setPendingNewWorkspaceProject(null); }, []); - const getSecrets = useCallback( - async (projectPath: string) => { - return await orpc.projects.secrets.get({ projectPath }); - }, - [orpc] - ); + const getSecrets = useCallback(async (projectPath: string) => { + return await window.api.projects.secrets.get(projectPath); + }, []); - const updateSecrets = useCallback( - async (projectPath: string, secrets: Secret[]) => { - const result = await orpc.projects.secrets.update({ projectPath, secrets }); - if (!result.success) { - console.error("Failed to update secrets:", result.error); - } - }, - [orpc] - ); + const updateSecrets = useCallback(async (projectPath: string, secrets: Secret[]) => { + const result = await window.api.projects.secrets.update(projectPath, secrets); + if (!result.success) { + console.error("Failed to update secrets:", result.error); + } + }, []); const value = useMemo( () => ({ diff --git a/src/browser/contexts/WorkspaceContext.test.tsx b/src/browser/contexts/WorkspaceContext.test.tsx index 90f2a5447..deddfde1f 100644 --- a/src/browser/contexts/WorkspaceContext.test.tsx +++ b/src/browser/contexts/WorkspaceContext.test.tsx @@ -1,21 +1,16 @@ -import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; +import type { + FrontendWorkspaceMetadata, + WorkspaceActivitySnapshot, +} from "@/common/types/workspace"; +import type { IPCApi } from "@/common/types/ipc"; +import type { ProjectConfig } from "@/common/types/project"; import { act, cleanup, render, waitFor } from "@testing-library/react"; import { afterEach, describe, expect, mock, test } from "bun:test"; import { GlobalWindow } from "happy-dom"; import type { WorkspaceContext } from "./WorkspaceContext"; import { WorkspaceProvider, useWorkspaceContext } from "./WorkspaceContext"; import { ProjectProvider } from "@/browser/contexts/ProjectContext"; -import { useWorkspaceStoreRaw as getWorkspaceStoreRaw } from "@/browser/stores/WorkspaceStore"; -import type { RecursivePartial } from "@/browser/testUtils"; - -import type { ORPCClient } from "@/browser/orpc/react"; - -// Mock ORPC -let currentClientMock: RecursivePartial = {}; -void mock.module("@/browser/orpc/react", () => ({ - useORPC: () => currentClientMock as ORPCClient, - ORPCProvider: ({ children }: { children: React.ReactNode }) => children, -})); +import { useWorkspaceStoreRaw } from "@/browser/stores/WorkspaceStore"; // Helper to create test workspace metadata with default runtime config const createWorkspaceMetadata = ( @@ -35,13 +30,14 @@ describe("WorkspaceContext", () => { cleanup(); // Reset global workspace store to avoid cross-test leakage - getWorkspaceStoreRaw().dispose(); - - globalThis.window = undefined as unknown as Window & typeof globalThis; - globalThis.document = undefined as unknown as Document; - globalThis.localStorage = undefined as unknown as Storage; - - currentClientMock = {}; + useWorkspaceStoreRaw().dispose(); + + // @ts-expect-error - Resetting global state in tests + globalThis.window = undefined; + // @ts-expect-error - Resetting global state in tests + globalThis.document = undefined; + // @ts-expect-error - Resetting global state in tests + globalThis.localStorage = undefined; }); test("syncs workspace store subscriptions when metadata loads", async () => { @@ -66,10 +62,7 @@ describe("WorkspaceContext", () => { await waitFor(() => expect(ctx().workspaceMetadata.size).toBe(1)); await waitFor(() => expect( - workspaceApi.onChat.mock.calls.some( - ([{ workspaceId }]: [{ workspaceId: string }, ...unknown[]]) => - workspaceId === "ws-sync-load" - ) + workspaceApi.onChat.mock.calls.some(([workspaceId]) => workspaceId === "ws-sync-load") ).toBe(true) ); }); @@ -84,9 +77,20 @@ describe("WorkspaceContext", () => { await setup(); await waitFor(() => expect(workspaceApi.onMetadata.mock.calls.length).toBeGreaterThan(0)); - expect(workspaceApi.onMetadata).toHaveBeenCalled(); - }); + const metadataListener: Parameters[0] = + workspaceApi.onMetadata.mock.calls[0][0]; + + const newWorkspace = createWorkspaceMetadata({ id: "ws-from-event" }); + act(() => { + metadataListener({ workspaceId: newWorkspace.id, metadata: newWorkspace }); + }); + await waitFor(() => + expect( + workspaceApi.onChat.mock.calls.some(([workspaceId]) => workspaceId === "ws-from-event") + ).toBe(true) + ); + }); test("loads workspace metadata on mount", async () => { const initialWorkspaces: FrontendWorkspaceMetadata[] = [ createWorkspaceMetadata({ @@ -95,10 +99,19 @@ describe("WorkspaceContext", () => { projectName: "alpha", name: "main", namedWorkspacePath: "/alpha-main", + createdAt: "2025-01-01T00:00:00.000Z", + }), + createWorkspaceMetadata({ + id: "ws-2", + projectPath: "/beta", + projectName: "beta", + name: "dev", + namedWorkspacePath: "/beta-dev", + createdAt: "2025-01-02T00:00:00.000Z", }), ]; - createMockAPI({ + const { workspace: workspaceApi } = createMockAPI({ workspace: { list: () => Promise.resolve(initialWorkspaces), }, @@ -106,36 +119,55 @@ describe("WorkspaceContext", () => { const ctx = await setup(); - await waitFor(() => expect(ctx().workspaceMetadata.size).toBe(1)); - - const metadata = ctx().workspaceMetadata.get("ws-1"); - expect(metadata?.createdAt).toBe("2025-01-01T00:00:00.000Z"); + await waitFor(() => expect(ctx().workspaceMetadata.size).toBe(2)); + expect(workspaceApi.list).toHaveBeenCalled(); + expect(ctx().loading).toBe(false); + expect(ctx().workspaceMetadata.has("ws-1")).toBe(true); + expect(ctx().workspaceMetadata.has("ws-2")).toBe(true); }); test("sets empty map on API error during load", async () => { createMockAPI({ workspace: { - list: () => Promise.reject(new Error("API Error")), + list: () => Promise.reject(new Error("network failure")), }, }); const ctx = await setup(); - await waitFor(() => expect(ctx().loading).toBe(false)); - expect(ctx().workspaceMetadata.size).toBe(0); + // Should have empty workspaces after failed load + await waitFor(() => { + expect(ctx().workspaceMetadata.size).toBe(0); + expect(ctx().loading).toBe(false); + }); }); test("refreshWorkspaceMetadata reloads workspace data", async () => { const initialWorkspaces: FrontendWorkspaceMetadata[] = [ - createWorkspaceMetadata({ id: "ws-1" }), + createWorkspaceMetadata({ + id: "ws-1", + projectPath: "/alpha", + projectName: "alpha", + name: "main", + namedWorkspacePath: "/alpha-main", + createdAt: "2025-01-01T00:00:00.000Z", + }), ]; + const updatedWorkspaces: FrontendWorkspaceMetadata[] = [ - createWorkspaceMetadata({ id: "ws-1" }), - createWorkspaceMetadata({ id: "ws-2" }), + ...initialWorkspaces, + createWorkspaceMetadata({ + id: "ws-2", + projectPath: "/beta", + projectName: "beta", + name: "dev", + namedWorkspacePath: "/beta-dev", + createdAt: "2025-01-02T00:00:00.000Z", + }), ]; let callCount = 0; - createMockAPI({ + const { workspace: workspaceApi } = createMockAPI({ workspace: { list: () => { callCount++; @@ -148,279 +180,624 @@ describe("WorkspaceContext", () => { await waitFor(() => expect(ctx().workspaceMetadata.size).toBe(1)); - await ctx().refreshWorkspaceMetadata(); + await act(async () => { + await ctx().refreshWorkspaceMetadata(); + }); - await waitFor(() => expect(ctx().workspaceMetadata.size).toBe(2)); + expect(ctx().workspaceMetadata.size).toBe(2); + expect(workspaceApi.list.mock.calls.length).toBeGreaterThanOrEqual(2); }); test("createWorkspace creates new workspace and reloads data", async () => { - const { workspace: workspaceApi } = createMockAPI(); + const newWorkspace: FrontendWorkspaceMetadata = createWorkspaceMetadata({ + id: "ws-new", + projectPath: "/gamma", + projectName: "gamma", + name: "feature", + namedWorkspacePath: "/gamma-feature", + createdAt: "2025-01-03T00:00:00.000Z", + }); + + const { workspace: workspaceApi, projects: projectsApi } = createMockAPI({ + workspace: { + list: () => Promise.resolve([]), + create: () => + Promise.resolve({ + success: true as const, + metadata: newWorkspace, + }), + }, + projects: { + list: () => Promise.resolve([]), + }, + }); const ctx = await setup(); - const newMetadata = createWorkspaceMetadata({ id: "ws-new" }); - workspaceApi.create.mockResolvedValue({ success: true as const, metadata: newMetadata }); + await waitFor(() => expect(ctx().loading).toBe(false)); - await ctx().createWorkspace("path", "name", "main"); + let result: Awaited>; + await act(async () => { + result = await ctx().createWorkspace("/gamma", "feature", "main"); + }); - expect(workspaceApi.create).toHaveBeenCalled(); - // Verify list called (might be 1 or 2 times depending on optimization) - expect(workspaceApi.list).toHaveBeenCalled(); + expect(workspaceApi.create).toHaveBeenCalledWith("/gamma", "feature", "main", undefined); + expect(projectsApi.list).toHaveBeenCalled(); + expect(result!.workspaceId).toBe("ws-new"); + expect(result!.projectPath).toBe("/gamma"); + expect(result!.projectName).toBe("gamma"); }); test("createWorkspace throws on failure", async () => { - const { workspace: workspaceApi } = createMockAPI(); + createMockAPI({ + workspace: { + list: () => Promise.resolve([]), + create: () => + Promise.resolve({ + success: false, + error: "Failed to create workspace", + }), + }, + projects: { + list: () => Promise.resolve([]), + }, + }); const ctx = await setup(); - workspaceApi.create.mockResolvedValue({ success: false, error: "Failed" }); + await waitFor(() => expect(ctx().loading).toBe(false)); - return expect(ctx().createWorkspace("path", "name", "main")).rejects.toThrow("Failed"); + expect(async () => { + await act(async () => { + await ctx().createWorkspace("/gamma", "feature", "main"); + }); + }).toThrow("Failed to create workspace"); }); test("removeWorkspace removes workspace and clears selection if active", async () => { - const initialWorkspaces = [ - createWorkspaceMetadata({ - id: "ws-remove", - projectPath: "/remove", - projectName: "remove", - name: "main", - namedWorkspacePath: "/remove-main", - }), - ]; + const workspace: FrontendWorkspaceMetadata = createWorkspaceMetadata({ + id: "ws-1", + projectPath: "/alpha", + projectName: "alpha", + name: "main", + namedWorkspacePath: "/alpha-main", + createdAt: "2025-01-01T00:00:00.000Z", + }); - createMockAPI({ + const { workspace: workspaceApi } = createMockAPI({ workspace: { - list: () => Promise.resolve(initialWorkspaces), + list: () => Promise.resolve([workspace]), + remove: () => Promise.resolve({ success: true as const }), }, - localStorage: { - selectedWorkspace: JSON.stringify({ - workspaceId: "ws-remove", - projectPath: "/remove", - projectName: "remove", - namedWorkspacePath: "/remove-main", - }), + projects: { + list: () => Promise.resolve([]), }, }); const ctx = await setup(); - await waitFor(() => expect(ctx().workspaceMetadata.size).toBe(1)); - expect(ctx().selectedWorkspace?.workspaceId).toBe("ws-remove"); + await waitFor(() => expect(ctx().loading).toBe(false)); + + // Set the selected workspace via context API + act(() => { + ctx().setSelectedWorkspace({ + workspaceId: "ws-1", + projectPath: "/alpha", + projectName: "alpha", + namedWorkspacePath: "/alpha-main", + }); + }); + + expect(ctx().selectedWorkspace?.workspaceId).toBe("ws-1"); - await ctx().removeWorkspace("ws-remove"); + let result: Awaited>; + await act(async () => { + result = await ctx().removeWorkspace("ws-1"); + }); - await waitFor(() => expect(ctx().selectedWorkspace).toBeNull()); + expect(workspaceApi.remove).toHaveBeenCalledWith("ws-1", undefined); + expect(result!.success).toBe(true); + // Verify selectedWorkspace was cleared + expect(ctx().selectedWorkspace).toBeNull(); }); test("removeWorkspace handles failure gracefully", async () => { - const { workspace: workspaceApi } = createMockAPI(); + const workspace: FrontendWorkspaceMetadata = createWorkspaceMetadata({ + id: "ws-1", + projectPath: "/alpha", + projectName: "alpha", + name: "main", + namedWorkspacePath: "/alpha-main", + createdAt: "2025-01-01T00:00:00.000Z", + }); + + const { workspace: workspaceApi } = createMockAPI({ + workspace: { + list: () => Promise.resolve([workspace]), + remove: () => Promise.resolve({ success: false, error: "Permission denied" }), + }, + projects: { + list: () => Promise.resolve([]), + }, + }); const ctx = await setup(); - workspaceApi.remove.mockResolvedValue({ - success: false, - error: "Failed", + await waitFor(() => expect(ctx().loading).toBe(false)); + + let result: Awaited>; + await act(async () => { + result = await ctx().removeWorkspace("ws-1"); }); - const result = await ctx().removeWorkspace("ws-1"); - expect(result.success).toBe(false); - expect(result.error).toBe("Failed"); + expect(workspaceApi.remove).toHaveBeenCalledWith("ws-1", undefined); + expect(result!.success).toBe(false); + expect(result!.error).toBe("Permission denied"); }); test("renameWorkspace renames workspace and updates selection if active", async () => { - const initialWorkspaces = [ - createWorkspaceMetadata({ - id: "ws-rename", - projectPath: "/rename", - projectName: "rename", - name: "old", - namedWorkspacePath: "/rename-old", - }), - ]; + const oldWorkspace: FrontendWorkspaceMetadata = createWorkspaceMetadata({ + id: "ws-1", + projectPath: "/alpha", + projectName: "alpha", + name: "main", + namedWorkspacePath: "/alpha-main", + createdAt: "2025-01-01T00:00:00.000Z", + }); + + const newWorkspace: FrontendWorkspaceMetadata = createWorkspaceMetadata({ + id: "ws-2", + projectPath: "/alpha", + projectName: "alpha", + name: "renamed", + namedWorkspacePath: "/alpha-renamed", + createdAt: "2025-01-01T00:00:00.000Z", + }); const { workspace: workspaceApi } = createMockAPI({ workspace: { - list: () => Promise.resolve(initialWorkspaces), + list: () => Promise.resolve([oldWorkspace]), + rename: () => + Promise.resolve({ + success: true as const, + data: { newWorkspaceId: "ws-2" }, + }), + getInfo: (workspaceId: string) => { + if (workspaceId === "ws-2") { + return Promise.resolve(newWorkspace); + } + return Promise.resolve(null); + }, }, - localStorage: { - selectedWorkspace: JSON.stringify({ - workspaceId: "ws-rename", - projectPath: "/rename", - projectName: "rename", - namedWorkspacePath: "/rename-old", - }), + projects: { + list: () => Promise.resolve([]), }, }); const ctx = await setup(); - await waitFor(() => expect(ctx().selectedWorkspace?.namedWorkspacePath).toBe("/rename-old")); + await waitFor(() => expect(ctx().loading).toBe(false)); - workspaceApi.rename.mockResolvedValue({ - success: true as const, - data: { newWorkspaceId: "ws-rename-new" }, + // Set the selected workspace via context API + act(() => { + ctx().setSelectedWorkspace({ + workspaceId: "ws-1", + projectPath: "/alpha", + projectName: "alpha", + namedWorkspacePath: "/alpha-main", + }); }); - // Mock list to return updated workspace after rename - workspaceApi.list.mockResolvedValue([ - createWorkspaceMetadata({ - id: "ws-rename-new", - projectPath: "/rename", - projectName: "rename", - name: "new", - namedWorkspacePath: "/rename-new", - }), - ]); - workspaceApi.getInfo.mockResolvedValue( - createWorkspaceMetadata({ - id: "ws-rename-new", - projectPath: "/rename", - projectName: "rename", - name: "new", - namedWorkspacePath: "/rename-new", - }) - ); + expect(ctx().selectedWorkspace?.workspaceId).toBe("ws-1"); - await ctx().renameWorkspace("ws-rename", "new"); + let result: Awaited>; + await act(async () => { + result = await ctx().renameWorkspace("ws-1", "renamed"); + }); - expect(workspaceApi.rename).toHaveBeenCalled(); - await waitFor(() => expect(ctx().selectedWorkspace?.namedWorkspacePath).toBe("/rename-new")); + expect(workspaceApi.rename).toHaveBeenCalledWith("ws-1", "renamed"); + expect(result!.success).toBe(true); + expect(workspaceApi.getInfo).toHaveBeenCalledWith("ws-2"); + // Verify selectedWorkspace was updated with new ID + expect(ctx().selectedWorkspace).toEqual({ + workspaceId: "ws-2", + projectPath: "/alpha", + projectName: "alpha", + namedWorkspacePath: "/alpha-renamed", + }); }); test("renameWorkspace handles failure gracefully", async () => { - const { workspace: workspaceApi } = createMockAPI(); + const workspace: FrontendWorkspaceMetadata = createWorkspaceMetadata({ + id: "ws-1", + projectPath: "/alpha", + projectName: "alpha", + name: "main", + namedWorkspacePath: "/alpha-main", + createdAt: "2025-01-01T00:00:00.000Z", + }); + + const { workspace: workspaceApi } = createMockAPI({ + workspace: { + list: () => Promise.resolve([workspace]), + rename: () => Promise.resolve({ success: false, error: "Name already exists" }), + }, + projects: { + list: () => Promise.resolve([]), + }, + }); const ctx = await setup(); - workspaceApi.rename.mockResolvedValue({ - success: false, - error: "Failed", + await waitFor(() => expect(ctx().loading).toBe(false)); + + let result: Awaited>; + await act(async () => { + result = await ctx().renameWorkspace("ws-1", "renamed"); }); - const result = await ctx().renameWorkspace("ws-1", "new"); - expect(result.success).toBe(false); - expect(result.error).toBe("Failed"); + expect(workspaceApi.rename).toHaveBeenCalledWith("ws-1", "renamed"); + expect(result!.success).toBe(false); + expect(result!.error).toBe("Name already exists"); }); test("getWorkspaceInfo fetches workspace metadata", async () => { - const { workspace: workspaceApi } = createMockAPI(); - const mockInfo = createWorkspaceMetadata({ id: "ws-info" }); - workspaceApi.getInfo.mockResolvedValue(mockInfo); + const workspace: FrontendWorkspaceMetadata = createWorkspaceMetadata({ + id: "ws-1", + projectPath: "/alpha", + projectName: "alpha", + name: "main", + namedWorkspacePath: "/alpha-main", + createdAt: "2025-01-01T00:00:00.000Z", + }); + + const { workspace: workspaceApi } = createMockAPI({ + workspace: { + list: () => Promise.resolve([]), + getInfo: (workspaceId: string) => { + if (workspaceId === "ws-1") { + return Promise.resolve(workspace); + } + return Promise.resolve(null); + }, + }, + projects: { + list: () => Promise.resolve([]), + }, + }); const ctx = await setup(); - const info = await ctx().getWorkspaceInfo("ws-info"); - expect(info).toEqual(mockInfo); - expect(workspaceApi.getInfo).toHaveBeenCalledWith({ workspaceId: "ws-info" }); + await waitFor(() => expect(ctx().loading).toBe(false)); + + const info = await ctx().getWorkspaceInfo("ws-1"); + expect(workspaceApi.getInfo).toHaveBeenCalledWith("ws-1"); + expect(info).toEqual(workspace); }); test("beginWorkspaceCreation clears selection and tracks pending state", async () => { createMockAPI({ - localStorage: { - selectedWorkspace: JSON.stringify({ - workspaceId: "ws-existing", - projectPath: "/existing", - projectName: "existing", - namedWorkspacePath: "/existing-main", - }), + workspace: { + list: () => Promise.resolve([]), + }, + projects: { + list: () => Promise.resolve([]), }, }); const ctx = await setup(); - await waitFor(() => expect(ctx().selectedWorkspace).toBeTruthy()); + await waitFor(() => expect(ctx().loading).toBe(false)); + + expect(ctx().pendingNewWorkspaceProject).toBeNull(); act(() => { - ctx().beginWorkspaceCreation("/new/project"); + ctx().setSelectedWorkspace({ + workspaceId: "ws-123", + projectPath: "/alpha", + projectName: "alpha", + namedWorkspacePath: "alpha/ws-123", + }); }); + expect(ctx().selectedWorkspace?.workspaceId).toBe("ws-123"); + act(() => { + ctx().beginWorkspaceCreation("/alpha"); + }); + expect(ctx().pendingNewWorkspaceProject).toBe("/alpha"); expect(ctx().selectedWorkspace).toBeNull(); - expect(ctx().pendingNewWorkspaceProject).toBe("/new/project"); + + act(() => { + ctx().clearPendingWorkspaceCreation(); + }); + expect(ctx().pendingNewWorkspaceProject).toBeNull(); }); test("reacts to metadata update events (new workspace)", async () => { - const { workspace: workspaceApi } = createMockAPI(); - await setup(); + let metadataListener: + | ((event: { workspaceId: string; metadata: FrontendWorkspaceMetadata | null }) => void) + | null = null; + + const { projects: projectsApi } = createMockAPI({ + workspace: { + list: () => Promise.resolve([]), + // Preload.ts type is incorrect - it should allow metadata: null for deletions + /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment */ + onMetadata: (( + listener: (event: { + workspaceId: string; + metadata: FrontendWorkspaceMetadata | null; + }) => void + ) => { + metadataListener = listener; + return () => { + metadataListener = null; + }; + }) as any, + /* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment */ + }, + projects: { + list: () => Promise.resolve([]), + }, + }); + + const ctx = await setup(); + + await waitFor(() => expect(ctx().loading).toBe(false)); + + const newWorkspace: FrontendWorkspaceMetadata = createWorkspaceMetadata({ + id: "ws-new", + projectPath: "/gamma", + projectName: "gamma", + name: "feature", + namedWorkspacePath: "/gamma-feature", + createdAt: "2025-01-03T00:00:00.000Z", + }); - // Verify subscription started - await waitFor(() => expect(workspaceApi.onMetadata).toHaveBeenCalled()); + await act(async () => { + metadataListener!({ workspaceId: "ws-new", metadata: newWorkspace }); + // Give async side effects time to run + await new Promise((resolve) => setTimeout(resolve, 10)); + }); - // Note: We cannot easily simulate incoming events from the async generator mock - // in this simple setup. We verify the subscription happens. + expect(ctx().workspaceMetadata.has("ws-new")).toBe(true); + // Should reload projects when new workspace is created + expect(projectsApi.list.mock.calls.length).toBeGreaterThan(1); + }); + + test("reacts to metadata update events (delete workspace)", async () => { + const workspace: FrontendWorkspaceMetadata = createWorkspaceMetadata({ + id: "ws-1", + projectPath: "/alpha", + projectName: "alpha", + name: "main", + namedWorkspacePath: "/alpha-main", + createdAt: "2025-01-01T00:00:00.000Z", + }); + + let metadataListener: + | ((event: { workspaceId: string; metadata: FrontendWorkspaceMetadata | null }) => void) + | null = null; + + createMockAPI({ + workspace: { + list: () => Promise.resolve([workspace]), + // Preload.ts type is incorrect - it should allow metadata: null for deletions + /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment */ + onMetadata: (( + listener: (event: { + workspaceId: string; + metadata: FrontendWorkspaceMetadata | null; + }) => void + ) => { + metadataListener = listener; + return () => { + metadataListener = null; + }; + }) as any, + /* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment */ + }, + projects: { + list: () => Promise.resolve([]), + }, + }); + + const ctx = await setup(); + + await waitFor(() => expect(ctx().workspaceMetadata.has("ws-1")).toBe(true)); + + act(() => { + metadataListener!({ workspaceId: "ws-1", metadata: null }); + }); + + expect(ctx().workspaceMetadata.has("ws-1")).toBe(false); }); test("selectedWorkspace persists to localStorage", async () => { - createMockAPI(); + createMockAPI({ + workspace: { + list: () => + Promise.resolve([ + createWorkspaceMetadata({ + id: "ws-1", + projectPath: "/alpha", + projectName: "alpha", + name: "main", + namedWorkspacePath: "/alpha-main", + }), + ]), + }, + projects: { + list: () => Promise.resolve([]), + }, + }); + const ctx = await setup(); - const selection = { - workspaceId: "ws-persist", - projectPath: "/persist", - projectName: "persist", - namedWorkspacePath: "/persist-main", - }; + await waitFor(() => expect(ctx().loading).toBe(false)); + // Set selected workspace act(() => { - ctx().setSelectedWorkspace(selection); + ctx().setSelectedWorkspace({ + workspaceId: "ws-1", + projectPath: "/alpha", + projectName: "alpha", + namedWorkspacePath: "/alpha-main", + }); }); - await waitFor(() => expect(localStorage.getItem("selectedWorkspace")).toContain("ws-persist")); + // Verify it's set and persisted to localStorage + await waitFor(() => { + expect(ctx().selectedWorkspace?.workspaceId).toBe("ws-1"); + const stored = globalThis.localStorage.getItem("selectedWorkspace"); + expect(stored).toBeTruthy(); + const parsed = JSON.parse(stored!) as { workspaceId?: string }; + expect(parsed.workspaceId).toBe("ws-1"); + }); }); test("selectedWorkspace restores from localStorage on mount", async () => { + // Pre-populate localStorage + const mockSelection = { + workspaceId: "ws-1", + projectPath: "/alpha", + projectName: "alpha", + namedWorkspacePath: "/alpha-main", + }; + createMockAPI({ + workspace: { + list: () => + Promise.resolve([ + createWorkspaceMetadata({ + id: "ws-1", + projectPath: "/alpha", + projectName: "alpha", + name: "main", + namedWorkspacePath: "/alpha-main", + }), + ]), + }, + projects: { + list: () => Promise.resolve([]), + }, + localStorage: { + selectedWorkspace: JSON.stringify(mockSelection), + }, + }); + + const ctx = await setup(); + + await waitFor(() => expect(ctx().loading).toBe(false)); + + // Should have restored from localStorage (happens after loading completes) + await waitFor(() => { + expect(ctx().selectedWorkspace?.workspaceId).toBe("ws-1"); + }); + expect(ctx().selectedWorkspace?.projectPath).toBe("/alpha"); + }); + + test("URL hash overrides localStorage for selectedWorkspace", async () => { + createMockAPI({ + workspace: { + list: () => + Promise.resolve([ + createWorkspaceMetadata({ + id: "ws-1", + projectPath: "/alpha", + projectName: "alpha", + name: "main", + namedWorkspacePath: "/alpha-main", + }), + createWorkspaceMetadata({ + id: "ws-2", + projectPath: "/beta", + projectName: "beta", + name: "dev", + namedWorkspacePath: "/beta-dev", + }), + ]), + }, + projects: { + list: () => Promise.resolve([]), + }, localStorage: { selectedWorkspace: JSON.stringify({ - workspaceId: "ws-restore", - projectPath: "/restore", - projectName: "restore", - namedWorkspacePath: "/restore-main", + workspaceId: "ws-1", + projectPath: "/alpha", + projectName: "alpha", + namedWorkspacePath: "/alpha-main", }), }, + locationHash: "#workspace=ws-2", }); const ctx = await setup(); - await waitFor(() => expect(ctx().selectedWorkspace?.workspaceId).toBe("ws-restore")); + await waitFor(() => expect(ctx().loading).toBe(false)); + + // Should have selected ws-2 from URL hash, not ws-1 from localStorage + await waitFor(() => { + expect(ctx().selectedWorkspace?.workspaceId).toBe("ws-2"); + }); + expect(ctx().selectedWorkspace?.projectPath).toBe("/beta"); }); - test("launch project takes precedence over localStorage selection", async () => { + test("URL hash with non-existent workspace ID does not crash", async () => { createMockAPI({ workspace: { list: () => Promise.resolve([ createWorkspaceMetadata({ - id: "ws-existing", - projectPath: "/existing", - projectName: "existing", + id: "ws-1", + projectPath: "/alpha", + projectName: "alpha", name: "main", - namedWorkspacePath: "/existing-main", + namedWorkspacePath: "/alpha-main", }), + ]), + }, + projects: { + list: () => Promise.resolve([]), + }, + locationHash: "#workspace=non-existent", + }); + + const ctx = await setup(); + + await waitFor(() => expect(ctx().loading).toBe(false)); + + // Should not have selected anything (workspace doesn't exist) + expect(ctx().selectedWorkspace).toBeNull(); + }); + + test("launch project selects first workspace when no selection exists", async () => { + createMockAPI({ + workspace: { + list: () => + Promise.resolve([ createWorkspaceMetadata({ - id: "ws-launch", + id: "ws-1", projectPath: "/launch-project", projectName: "launch-project", name: "main", namedWorkspacePath: "/launch-project-main", }), + createWorkspaceMetadata({ + id: "ws-2", + projectPath: "/launch-project", + projectName: "launch-project", + name: "dev", + namedWorkspacePath: "/launch-project-dev", + }), ]), }, projects: { list: () => Promise.resolve([]), }, - localStorage: { - selectedWorkspace: JSON.stringify({ - workspaceId: "ws-existing", - projectPath: "/existing", - projectName: "existing", - namedWorkspacePath: "/existing-main", - }), - }, server: { getLaunchProject: () => Promise.resolve("/launch-project"), }, - locationHash: "#/launch-project", // Simulate launch project via URL hash }); const ctx = await setup(); @@ -546,23 +923,42 @@ async function setup() { ); - - // Inject client immediately to handle race conditions where effects run before store update - getWorkspaceStoreRaw().setClient(currentClientMock as ORPCClient); - await waitFor(() => expect(contextRef.current).toBeTruthy()); return () => contextRef.current!; } interface MockAPIOptions { - workspace?: RecursivePartial; - projects?: RecursivePartial; - server?: RecursivePartial; + workspace?: Partial; + projects?: Partial; + server?: { + getLaunchProject?: () => Promise; + }; localStorage?: Record; locationHash?: string; } +// Mock type helpers - only include methods used in tests +interface MockedWorkspaceAPI { + create: ReturnType>; + list: ReturnType>; + remove: ReturnType>; + rename: ReturnType>; + getInfo: ReturnType>; + onMetadata: ReturnType>; + onChat: ReturnType>; + activity: { + list: ReturnType>; + subscribe: ReturnType>; + }; +} + +// Just type the list method directly since Pick with conditional types causes issues +interface MockedProjectsAPI { + list: ReturnType Promise>>>; +} + function createMockAPI(options: MockAPIOptions = {}) { + // Create fresh window environment with explicit typing const happyWindow = new GlobalWindow(); globalThis.window = happyWindow as unknown as Window & typeof globalThis; globalThis.document = happyWindow.document as unknown as Document; @@ -580,8 +976,19 @@ function createMockAPI(options: MockAPIOptions = {}) { happyWindow.location.hash = options.locationHash; } - // Create mocks - const workspace = { + // Create workspace API with proper types + const defaultActivityList: IPCApi["workspace"]["activity"]["list"] = () => + Promise.resolve({} as Record); + const defaultActivitySubscribe: IPCApi["workspace"]["activity"]["subscribe"] = () => () => + undefined; + + const workspaceActivity = options.workspace?.activity; + const activityListImpl: IPCApi["workspace"]["activity"]["list"] = + workspaceActivity?.list?.bind(workspaceActivity) ?? defaultActivityList; + const activitySubscribeImpl: IPCApi["workspace"]["activity"]["subscribe"] = + workspaceActivity?.subscribe?.bind(workspaceActivity) ?? defaultActivitySubscribe; + + const workspace: MockedWorkspaceAPI = { create: mock( options.workspace?.create ?? (() => @@ -591,82 +998,57 @@ function createMockAPI(options: MockAPIOptions = {}) { })) ), list: mock(options.workspace?.list ?? (() => Promise.resolve([]))), - remove: mock(options.workspace?.remove ?? (() => Promise.resolve({ success: true as const }))), + remove: mock( + options.workspace?.remove ?? + (() => Promise.resolve({ success: true as const, data: undefined })) + ), rename: mock( options.workspace?.rename ?? - (() => Promise.resolve({ success: true as const, data: { newWorkspaceId: "ws-1" } })) + (() => + Promise.resolve({ + success: true as const, + data: { newWorkspaceId: "ws-1" }, + })) ), getInfo: mock(options.workspace?.getInfo ?? (() => Promise.resolve(null))), - // Async generators for subscriptions onMetadata: mock( options.workspace?.onMetadata ?? - (async () => { - await Promise.resolve(); - return ( - // eslint-disable-next-line require-yield - (async function* () { - await Promise.resolve(); - })() as unknown as Awaited> - ); + (() => () => { + // Empty cleanup function }) ), onChat: mock( options.workspace?.onChat ?? - (async () => { - await Promise.resolve(); - return ( - // eslint-disable-next-line require-yield - (async function* () { - await Promise.resolve(); - })() as unknown as Awaited> - ); + ((_workspaceId: string, _callback: Parameters[1]) => () => { + // Empty cleanup function }) ), activity: { - list: mock(options.workspace?.activity?.list ?? (() => Promise.resolve({}))), - subscribe: mock( - options.workspace?.activity?.subscribe ?? - (async () => { - await Promise.resolve(); - return ( - // eslint-disable-next-line require-yield - (async function* () { - await Promise.resolve(); - })() as unknown as Awaited< - ReturnType - > - ); - }) - ), + list: mock(activityListImpl), + subscribe: mock(activitySubscribeImpl), }, - // Needed for ProjectCreateModal - truncateHistory: mock(() => Promise.resolve({ success: true as const, data: undefined })), - interruptStream: mock(() => Promise.resolve({ success: true as const, data: undefined })), }; - const projects = { + // Create projects API with proper types + const projects: MockedProjectsAPI = { list: mock(options.projects?.list ?? (() => Promise.resolve([]))), - listBranches: mock(() => Promise.resolve({ branches: ["main"], recommendedTrunk: "main" })), - secrets: { - get: mock(() => Promise.resolve([])), - }, }; - const server = { - getLaunchProject: mock(options.server?.getLaunchProject ?? (() => Promise.resolve(null))), - }; - - const terminal = { - openWindow: mock(() => Promise.resolve()), - }; - - // Update the global mock - currentClientMock = { + // Set up window.api with proper typing + // Tests only mock the methods they need, so cast to full API type + const windowWithApi = happyWindow as unknown as Window & { api: IPCApi }; + (windowWithApi.api as unknown) = { workspace, projects, - server, - terminal, }; + // Set up server API if provided + if (options.server) { + (windowWithApi.api as { server?: { getLaunchProject: () => Promise } }).server = + { + getLaunchProject: mock(options.server.getLaunchProject ?? (() => Promise.resolve(null))), + }; + } + return { workspace, projects, window: happyWindow }; } diff --git a/src/browser/contexts/WorkspaceContext.tsx b/src/browser/contexts/WorkspaceContext.tsx index b33dafbdb..74d8441ac 100644 --- a/src/browser/contexts/WorkspaceContext.tsx +++ b/src/browser/contexts/WorkspaceContext.tsx @@ -12,7 +12,6 @@ import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; import type { WorkspaceSelection } from "@/browser/components/ProjectSidebar"; import type { RuntimeConfig } from "@/common/types/runtime"; import { deleteWorkspaceStorage } from "@/common/constants/storage"; -import { useORPC } from "@/browser/orpc/react"; import { usePersistedState } from "@/browser/hooks/usePersistedState"; import { useProjectContext } from "@/browser/contexts/ProjectContext"; import { useWorkspaceStoreRaw } from "@/browser/stores/WorkspaceStore"; @@ -81,7 +80,6 @@ interface WorkspaceProviderProps { } export function WorkspaceProvider(props: WorkspaceProviderProps) { - const client = useORPC(); // Get project refresh function from ProjectContext const { refreshProjects } = useProjectContext(); @@ -115,7 +113,7 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { const loadWorkspaceMetadata = useCallback(async () => { try { - const metadataList = await client.workspace.list(undefined); + const metadataList = await window.api.workspace.list(); const metadataMap = new Map(); for (const metadata of metadataList) { ensureCreatedAt(metadata); @@ -127,7 +125,7 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { console.error("Failed to load workspace metadata:", error); setWorkspaceMetadata(new Map()); } - }, [setWorkspaceMetadata, client]); + }, [setWorkspaceMetadata]); // Load metadata once on mount useEffect(() => { @@ -161,25 +159,6 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { namedWorkspacePath: metadata.namedWorkspacePath, }); } - } else if (hash.length > 1) { - // Try to interpret hash as project path (for direct deep linking) - // e.g. #/Users/me/project or #/launch-project - const projectPath = decodeURIComponent(hash.substring(1)); - - // Find first workspace with this project path - const projectWorkspaces = Array.from(workspaceMetadata.values()).filter( - (meta) => meta.projectPath === projectPath - ); - - if (projectWorkspaces.length > 0) { - const metadata = projectWorkspaces[0]; - setSelectedWorkspace({ - workspaceId: metadata.id, - projectPath: metadata.projectPath, - projectName: metadata.projectName, - namedWorkspacePath: metadata.namedWorkspacePath, - }); - } } // Only run once when loading finishes // eslint-disable-next-line react-hooks/exhaustive-deps @@ -194,30 +173,26 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { if (selectedWorkspace) return; const checkLaunchProject = async () => { - // Only available in server mode (checked via platform/capabilities in future) - // For now, try the call - it will return null if not applicable - try { - const launchProjectPath = await client.server.getLaunchProject(undefined); - if (!launchProjectPath) return; - - // Find first workspace in this project - const projectWorkspaces = Array.from(workspaceMetadata.values()).filter( - (meta) => meta.projectPath === launchProjectPath - ); - - if (projectWorkspaces.length > 0) { - // Select the first workspace in the project - const metadata = projectWorkspaces[0]; - setSelectedWorkspace({ - workspaceId: metadata.id, - projectPath: metadata.projectPath, - projectName: metadata.projectName, - namedWorkspacePath: metadata.namedWorkspacePath, - }); - } - } catch (error) { - // Ignore errors (e.g. method not found if running against old backend) - console.debug("Failed to check launch project:", error); + // Only available in server mode + if (!window.api.server?.getLaunchProject) return; + + const launchProjectPath = await window.api.server.getLaunchProject(); + if (!launchProjectPath) return; + + // Find first workspace in this project + const projectWorkspaces = Array.from(workspaceMetadata.values()).filter( + (meta) => meta.projectPath === launchProjectPath + ); + + if (projectWorkspaces.length > 0) { + // Select the first workspace in the project + const metadata = projectWorkspaces[0]; + setSelectedWorkspace({ + workspaceId: metadata.id, + projectPath: metadata.projectPath, + projectName: metadata.projectName, + namedWorkspacePath: metadata.namedWorkspacePath, + }); } // If no workspaces exist yet, just leave the project in the sidebar // The user will need to create a workspace @@ -230,48 +205,35 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { // Subscribe to metadata updates (for create/rename/delete operations) useEffect(() => { - const controller = new AbortController(); - const { signal } = controller; - - (async () => { - try { - const iterator = await client.workspace.onMetadata(undefined, { signal }); - - for await (const event of iterator) { - if (signal.aborted) break; - - setWorkspaceMetadata((prev) => { - const updated = new Map(prev); - const isNewWorkspace = !prev.has(event.workspaceId) && event.metadata !== null; - - if (event.metadata === null) { - // Workspace deleted - remove from map - updated.delete(event.workspaceId); - } else { - ensureCreatedAt(event.metadata); - updated.set(event.workspaceId, event.metadata); - } + const unsubscribe = window.api.workspace.onMetadata( + (event: { workspaceId: string; metadata: FrontendWorkspaceMetadata | null }) => { + setWorkspaceMetadata((prev) => { + const updated = new Map(prev); + const isNewWorkspace = !prev.has(event.workspaceId) && event.metadata !== null; + + if (event.metadata === null) { + // Workspace deleted - remove from map + updated.delete(event.workspaceId); + } else { + ensureCreatedAt(event.metadata); + updated.set(event.workspaceId, event.metadata); + } - // If this is a new workspace (e.g., from fork), reload projects - // to ensure the sidebar shows the updated workspace list - if (isNewWorkspace) { - void refreshProjects(); - } + // If this is a new workspace (e.g., from fork), reload projects + // to ensure the sidebar shows the updated workspace list + if (isNewWorkspace) { + void refreshProjects(); + } - return updated; - }); - } - } catch (err) { - if (!signal.aborted) { - console.error("Failed to subscribe to metadata:", err); - } + return updated; + }); } - })(); + ); return () => { - controller.abort(); + unsubscribe(); }; - }, [refreshProjects, setWorkspaceMetadata, client]); + }, [refreshProjects, setWorkspaceMetadata]); const createWorkspace = useCallback( async ( @@ -284,12 +246,12 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { typeof trunkBranch === "string" && trunkBranch.trim().length > 0, "Expected trunk branch to be provided when creating a workspace" ); - const result = await client.workspace.create({ + const result = await window.api.workspace.create( projectPath, branchName, trunkBranch, - runtimeConfig, - }); + runtimeConfig + ); if (result.success) { // Backend has already updated the config - reload projects to get updated state await refreshProjects(); @@ -313,7 +275,9 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { throw new Error(result.error); } }, - [client, refreshProjects, setWorkspaceMetadata] + // refreshProjects is stable from context, doesn't need to be in deps + // eslint-disable-next-line react-hooks/exhaustive-deps + [loadWorkspaceMetadata] ); const removeWorkspace = useCallback( @@ -322,7 +286,7 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { options?: { force?: boolean } ): Promise<{ success: boolean; error?: string }> => { try { - const result = await client.workspace.remove({ workspaceId, options }); + const result = await window.api.workspace.remove(workspaceId, options); if (result.success) { // Clean up workspace-specific localStorage keys deleteWorkspaceStorage(workspaceId); @@ -348,13 +312,13 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { return { success: false, error: errorMessage }; } }, - [loadWorkspaceMetadata, refreshProjects, selectedWorkspace, setSelectedWorkspace, client] + [loadWorkspaceMetadata, refreshProjects, selectedWorkspace, setSelectedWorkspace] ); const renameWorkspace = useCallback( async (workspaceId: string, newName: string): Promise<{ success: boolean; error?: string }> => { try { - const result = await client.workspace.rename({ workspaceId, newName }); + const result = await window.api.workspace.rename(workspaceId, newName); if (result.success) { // Backend has already updated the config - reload projects to get updated state await refreshProjects(); @@ -367,7 +331,7 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { const newWorkspaceId = result.data.newWorkspaceId; // Get updated workspace metadata from backend - const newMetadata = await client.workspace.getInfo({ workspaceId: newWorkspaceId }); + const newMetadata = await window.api.workspace.getInfo(newWorkspaceId); if (newMetadata) { ensureCreatedAt(newMetadata); setSelectedWorkspace({ @@ -389,23 +353,20 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { return { success: false, error: errorMessage }; } }, - [loadWorkspaceMetadata, refreshProjects, selectedWorkspace, setSelectedWorkspace, client] + [loadWorkspaceMetadata, refreshProjects, selectedWorkspace, setSelectedWorkspace] ); const refreshWorkspaceMetadata = useCallback(async () => { await loadWorkspaceMetadata(); }, [loadWorkspaceMetadata]); - const getWorkspaceInfo = useCallback( - async (workspaceId: string) => { - const metadata = await client.workspace.getInfo({ workspaceId }); - if (metadata) { - ensureCreatedAt(metadata); - } - return metadata; - }, - [client] - ); + const getWorkspaceInfo = useCallback(async (workspaceId: string) => { + const metadata = await window.api.workspace.getInfo(workspaceId); + if (metadata) { + ensureCreatedAt(metadata); + } + return metadata; + }, []); const beginWorkspaceCreation = useCallback( (projectPath: string) => { diff --git a/src/browser/hooks/useAIViewKeybinds.ts b/src/browser/hooks/useAIViewKeybinds.ts index acf7efb10..0d4ba8243 100644 --- a/src/browser/hooks/useAIViewKeybinds.ts +++ b/src/browser/hooks/useAIViewKeybinds.ts @@ -9,7 +9,6 @@ import { getThinkingPolicyForModel } from "@/browser/utils/thinking/policy"; import { getDefaultModel } from "@/browser/hooks/useModelLRU"; import type { StreamingMessageAggregator } from "@/browser/utils/messages/StreamingMessageAggregator"; import { isCompactingStream, cancelCompaction } from "@/browser/utils/compaction/handler"; -import { useORPC } from "@/browser/orpc/react"; interface UseAIViewKeybindsParams { workspaceId: string; @@ -22,7 +21,7 @@ interface UseAIViewKeybindsParams { chatInputAPI: React.RefObject; jumpToBottom: () => void; handleOpenTerminal: () => void; - aggregator: StreamingMessageAggregator | undefined; // For compaction detection + aggregator: StreamingMessageAggregator; // For compaction detection setEditingMessage: (editing: { id: string; content: string } | undefined) => void; vimEnabled: boolean; // For vim-aware interrupt keybind } @@ -53,8 +52,6 @@ export function useAIViewKeybinds({ setEditingMessage, vimEnabled, }: UseAIViewKeybindsParams): void { - const client = useORPC(); - useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // Check vim-aware interrupt keybind @@ -65,11 +62,11 @@ export function useAIViewKeybinds({ // Interrupt stream: Ctrl+C in vim mode, Esc in normal mode // Only intercept if actively compacting (otherwise allow browser default for copy in vim mode) if (matchesKeybind(e, interruptKeybind)) { - if (canInterrupt && aggregator && isCompactingStream(aggregator)) { + if (canInterrupt && isCompactingStream(aggregator)) { // Ctrl+C during compaction: restore original state and enter edit mode // Stores cancellation marker in localStorage (persists across reloads) e.preventDefault(); - void cancelCompaction(client, workspaceId, aggregator, (messageId, command) => { + void cancelCompaction(workspaceId, aggregator, (messageId, command) => { setEditingMessage({ id: messageId, content: command }); }); setAutoRetry(false); @@ -82,7 +79,7 @@ export function useAIViewKeybinds({ if (canInterrupt || showRetryBarrier) { e.preventDefault(); setAutoRetry(false); // User explicitly stopped - don't auto-retry - void client.workspace.interruptStream({ workspaceId }); + void window.api.workspace.interruptStream(workspaceId); return; } } @@ -161,6 +158,5 @@ export function useAIViewKeybinds({ aggregator, setEditingMessage, vimEnabled, - client, ]); } diff --git a/src/browser/hooks/useModelLRU.ts b/src/browser/hooks/useModelLRU.ts index 8a220aa51..8d2c352a4 100644 --- a/src/browser/hooks/useModelLRU.ts +++ b/src/browser/hooks/useModelLRU.ts @@ -3,7 +3,6 @@ import { usePersistedState, readPersistedState, updatePersistedState } from "./u import { MODEL_ABBREVIATIONS } from "@/browser/utils/slashCommands/registry"; import { defaultModel } from "@/common/utils/ai/models"; import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults"; -import { useORPC } from "@/browser/orpc/react"; const MAX_LRU_SIZE = 12; const LRU_KEY = "model-lru"; @@ -46,7 +45,6 @@ export function getDefaultModel(): string { * Also includes custom models configured in Settings. */ export function useModelLRU() { - const client = useORPC(); const [recentModels, setRecentModels] = usePersistedState( LRU_KEY, DEFAULT_MODELS.slice(0, MAX_LRU_SIZE), @@ -78,11 +76,11 @@ export function useModelLRU() { useEffect(() => { const fetchCustomModels = async () => { try { - const providerConfig = await client.providers.getConfig(); + const config = await window.api.providers.getConfig(); const models: string[] = []; - for (const [provider, config] of Object.entries(providerConfig)) { - if (config.models) { - for (const modelId of config.models) { + for (const [provider, providerConfig] of Object.entries(config)) { + if (providerConfig.models) { + for (const modelId of providerConfig.models) { // Format as provider:modelId for consistency models.push(`${provider}:${modelId}`); } @@ -99,7 +97,7 @@ export function useModelLRU() { const handleSettingsChange = () => void fetchCustomModels(); window.addEventListener("providers-config-changed", handleSettingsChange); return () => window.removeEventListener("providers-config-changed", handleSettingsChange); - }, [client]); + }, []); // Combine LRU models with custom models (custom models appended, deduplicated) const allModels = useMemo(() => { diff --git a/src/browser/hooks/useOpenTerminal.ts b/src/browser/hooks/useOpenTerminal.ts deleted file mode 100644 index 982d56481..000000000 --- a/src/browser/hooks/useOpenTerminal.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { useCallback } from "react"; -import { useORPC } from "@/browser/orpc/react"; - -/** - * Hook to open a terminal window for a workspace. - * Handles the difference between Desktop (Electron) and Browser (Web) environments. - * - * In Electron (desktop) mode: Opens the user's native terminal emulator - * (Ghostty, Terminal.app, etc.) with the working directory set to the workspace path. - * - * In browser mode: Opens a web-based xterm.js terminal in a popup window. - */ -export function useOpenTerminal() { - const client = useORPC(); - - return useCallback( - (workspaceId: string) => { - // Check if running in browser mode - // window.api is only available in Electron (set by preload.ts) - // If window.api exists, we're in Electron; if not, we're in browser mode - const isBrowser = !window.api; - - if (isBrowser) { - // In browser mode, we must open the window client-side using window.open - // The backend cannot open a window on the user's client - const url = `/terminal.html?workspaceId=${encodeURIComponent(workspaceId)}`; - window.open( - url, - `terminal-${workspaceId}-${Date.now()}`, - "width=1000,height=600,popup=yes" - ); - - // We also notify the backend, though in browser mode the backend handler currently does nothing. - // This is kept for consistency and in case the backend logic changes to track open windows. - void client.terminal.openWindow({ workspaceId }); - } else { - // In Electron (desktop) mode, open the native system terminal - // This spawns the user's preferred terminal emulator (Ghostty, Terminal.app, etc.) - void client.terminal.openNative({ workspaceId }); - } - }, - [client] - ); -} diff --git a/src/browser/hooks/useResumeManager.ts b/src/browser/hooks/useResumeManager.ts index b015cd931..1b893936e 100644 --- a/src/browser/hooks/useResumeManager.ts +++ b/src/browser/hooks/useResumeManager.ts @@ -15,7 +15,6 @@ import { calculateBackoffDelay, INITIAL_DELAY, } from "@/browser/utils/messages/retryState"; -import { useORPC } from "@/browser/orpc/react"; export interface RetryState { attempt: number; @@ -28,7 +27,7 @@ export interface RetryState { * * DESIGN PRINCIPLE: Single Source of Truth for ALL Retry Logic * ============================================================ - * This hook is the ONLY place that calls client.workspace.resumeStream(). + * This hook is the ONLY place that calls window.api.workspace.resumeStream(). * All other components (RetryBarrier, etc.) emit RESUME_CHECK_REQUESTED events * and let this hook handle the actual retry logic. * @@ -63,7 +62,6 @@ export interface RetryState { * - Manual retry button (event from RetryBarrier) */ export function useResumeManager() { - const client = useORPC(); // Get workspace states from store // NOTE: We use a ref-based approach instead of useSyncExternalStore to avoid // re-rendering AppInner on every workspace state change. This hook only needs @@ -185,7 +183,7 @@ export function useResumeManager() { } } - const result = await client.workspace.resumeStream({ workspaceId, options }); + const result = await window.api.workspace.resumeStream(workspaceId, options); if (!result.success) { // Store error in retry state so RetryBarrier can display it diff --git a/src/browser/hooks/useSendMessageOptions.ts b/src/browser/hooks/useSendMessageOptions.ts index f848a8eb4..576211c96 100644 --- a/src/browser/hooks/useSendMessageOptions.ts +++ b/src/browser/hooks/useSendMessageOptions.ts @@ -4,7 +4,7 @@ import { usePersistedState } from "./usePersistedState"; import { getDefaultModel } from "./useModelLRU"; import { modeToToolPolicy, PLAN_MODE_INSTRUCTION } from "@/common/utils/ui/modeUtils"; import { getModelKey } from "@/common/constants/storage"; -import type { SendMessageOptions } from "@/common/orpc/types"; +import type { SendMessageOptions } from "@/common/types/ipc"; import type { UIMode } from "@/common/types/mode"; import type { ThinkingLevel } from "@/common/types/thinking"; import type { MuxProviderOptions } from "@/common/types/providerOptions"; diff --git a/src/browser/hooks/useStartHere.ts b/src/browser/hooks/useStartHere.ts index 2fec31b02..0d7057f19 100644 --- a/src/browser/hooks/useStartHere.ts +++ b/src/browser/hooks/useStartHere.ts @@ -3,7 +3,39 @@ import React from "react"; import { COMPACTED_EMOJI } from "@/common/constants/ui"; import { StartHereModal } from "@/browser/components/StartHereModal"; import { createMuxMessage } from "@/common/types/message"; -import { useORPC } from "@/browser/orpc/react"; + +/** + * Replace chat history with a specific message. + * This allows starting fresh from a plan or final assistant message. + */ +async function startHereWithMessage( + workspaceId: string, + content: string +): Promise<{ success: boolean; error?: string }> { + try { + const summaryMessage = createMuxMessage( + `start-here-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`, + "assistant", + content, + { + timestamp: Date.now(), + compacted: true, + } + ); + + const result = await window.api.workspace.replaceChatHistory(workspaceId, summaryMessage); + + if (!result.success) { + console.error("Failed to start here:", result.error); + return { success: false, error: result.error }; + } + + return { success: true }; + } catch (err) { + console.error("Start here error:", err); + return { success: false, error: String(err) }; + } +} /** * Hook for managing Start Here button state and modal. @@ -18,7 +50,6 @@ export function useStartHere( content: string, isCompacted = false ) { - const client = useORPC(); const [isModalOpen, setIsModalOpen] = useState(false); const [isStartingHere, setIsStartingHere] = useState(false); @@ -39,26 +70,7 @@ export function useStartHere( setIsStartingHere(true); try { - const summaryMessage = createMuxMessage( - `start-here-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`, - "assistant", - content, - { - timestamp: Date.now(), - compacted: true, - } - ); - - const result = await client.workspace.replaceChatHistory({ - workspaceId, - summaryMessage, - }); - - if (!result.success) { - console.error("Failed to start here:", result.error); - } - } catch (err) { - console.error("Start here error:", err); + await startHereWithMessage(workspaceId, content); } finally { setIsStartingHere(false); } diff --git a/src/browser/hooks/useTerminalSession.ts b/src/browser/hooks/useTerminalSession.ts index 523bb809e..a7ffecee3 100644 --- a/src/browser/hooks/useTerminalSession.ts +++ b/src/browser/hooks/useTerminalSession.ts @@ -1,7 +1,4 @@ import { useState, useEffect, useCallback } from "react"; -import { useORPC } from "@/browser/orpc/react"; - -import type { TerminalSession } from "@/common/types/terminal"; /** * Hook to manage terminal IPC session lifecycle @@ -14,7 +11,6 @@ export function useTerminalSession( onOutput?: (data: string) => void, onExit?: (exitCode: number) => void ) { - const client = useORPC(); const [sessionId, setSessionId] = useState(null); const [connected, setConnected] = useState(false); const [error, setError] = useState(null); @@ -36,12 +32,20 @@ export function useTerminalSession( let mounted = true; let createdSessionId: string | null = null; // Track session ID in closure - const cleanupFns: Array<() => void> = []; + let cleanupFns: Array<() => void> = []; const initSession = async () => { try { + // Check if window.api is available + if (!window.api) { + throw new Error("window.api is not available - preload script may not have loaded"); + } + if (!window.api.terminal) { + throw new Error("window.api.terminal is not available"); + } + // Create terminal session with current terminal size - const session: TerminalSession = await client.terminal.create({ + const session = await window.api.terminal.create({ workspaceId, cols: terminalSize.cols, rows: terminalSize.rows, @@ -54,49 +58,24 @@ export function useTerminalSession( createdSessionId = session.sessionId; // Store in closure setSessionId(session.sessionId); - const abortController = new AbortController(); - const { signal } = abortController; - - // Subscribe to output events via ORPC async iterator - // Fire and forget async loop - (async () => { - try { - const iterator = await client.terminal.onOutput( - { sessionId: session.sessionId }, - { signal } - ); - for await (const data of iterator) { - if (!mounted) break; - if (onOutput) onOutput(data); - } - } catch (err) { - if (!signal.aborted) { - console.error("[Terminal] Output stream error:", err); - } + // Subscribe to output events + const unsubOutput = window.api.terminal.onOutput(createdSessionId, (data: string) => { + if (onOutput) { + onOutput(data); } - })(); - - // Subscribe to exit events via ORPC async iterator - (async () => { - try { - const iterator = await client.terminal.onExit( - { sessionId: session.sessionId }, - { signal } - ); - for await (const code of iterator) { - if (!mounted) break; - setConnected(false); - if (onExit) onExit(code); - break; // Exit happens only once - } - } catch (err) { - if (!signal.aborted) { - console.error("[Terminal] Exit stream error:", err); - } + }); + + // Subscribe to exit events + const unsubExit = window.api.terminal.onExit(createdSessionId, (exitCode: number) => { + if (mounted) { + setConnected(false); } - })(); + if (onExit) { + onExit(exitCode); + } + }); - cleanupFns.push(() => abortController.abort()); + cleanupFns = [unsubOutput, unsubExit]; setConnected(true); setError(null); } catch (err) { @@ -118,7 +97,7 @@ export function useTerminalSession( // Close terminal session using the closure variable // This ensures we close the session created by this specific effect run if (createdSessionId) { - void client.terminal.close({ sessionId: createdSessionId }); + void window.api.terminal.close(createdSessionId); } // Reset init flag so a new session can be created if workspace changes @@ -131,20 +110,20 @@ export function useTerminalSession( const sendInput = useCallback( (data: string) => { if (sessionId) { - void client.terminal.sendInput({ sessionId, data }); + window.api.terminal.sendInput(sessionId, data); } }, - [sessionId, client] + [sessionId] ); // Resize terminal const resize = useCallback( (cols: number, rows: number) => { if (sessionId) { - void client.terminal.resize({ sessionId, cols, rows }); + void window.api.terminal.resize({ sessionId, cols, rows }); } }, - [sessionId, client] + [sessionId] ); return { diff --git a/src/browser/main.tsx b/src/browser/main.tsx index 5c3e79d2c..ce6c81a0b 100644 --- a/src/browser/main.tsx +++ b/src/browser/main.tsx @@ -3,6 +3,10 @@ import ReactDOM from "react-dom/client"; import { AppLoader } from "@/browser/components/AppLoader"; import { initTelemetry, trackAppStarted } from "@/common/telemetry"; +// Shims the `window.api` object with the browser API. +// This occurs if we are not running in Electron. +import "./api"; + // Initialize telemetry on app startup initTelemetry(); trackAppStarted(); diff --git a/src/browser/orpc/react.tsx b/src/browser/orpc/react.tsx deleted file mode 100644 index 153ee9078..000000000 --- a/src/browser/orpc/react.tsx +++ /dev/null @@ -1,237 +0,0 @@ -import { createContext, useContext, useEffect, useState, useCallback } from "react"; -import { createClient } from "@/common/orpc/client"; -import { RPCLink as WebSocketLink } from "@orpc/client/websocket"; -import { RPCLink as MessagePortLink } from "@orpc/client/message-port"; -import type { AppRouter } from "@/node/orpc/router"; -import type { RouterClient } from "@orpc/server"; -import { - AuthTokenModal, - getStoredAuthToken, - clearStoredAuthToken, -} from "@/browser/components/AuthTokenModal"; - -type ORPCClient = ReturnType; - -export type { ORPCClient }; - -const ORPCContext = createContext(null); - -interface ORPCProviderProps { - children: React.ReactNode; - /** Optional pre-created client. If provided, skips internal connection setup. */ - client?: ORPCClient; -} - -type ConnectionState = - | { status: "connecting" } - | { status: "connected"; client: ORPCClient; cleanup: () => void } - | { status: "auth_required"; error?: string } - | { status: "error"; error: string }; - -function getApiBase(): string { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment, @typescript-eslint/prefer-ts-expect-error - // @ts-ignore - import.meta is available in Vite - return import.meta.env.VITE_BACKEND_URL ?? window.location.origin; -} - -function createElectronClient(): { client: ORPCClient; cleanup: () => void } { - const { port1: clientPort, port2: serverPort } = new MessageChannel(); - window.postMessage("start-orpc-client", "*", [serverPort]); - - const link = new MessagePortLink({ port: clientPort }); - clientPort.start(); - - return { - client: createClient(link), - cleanup: () => clientPort.close(), - }; -} - -function createBrowserClient(authToken: string | null): { - client: ORPCClient; - cleanup: () => void; - ws: WebSocket; -} { - const API_BASE = getApiBase(); - const WS_BASE = API_BASE.replace("http://", "ws://").replace("https://", "wss://"); - - const wsUrl = authToken - ? `${WS_BASE}/orpc/ws?token=${encodeURIComponent(authToken)}` - : `${WS_BASE}/orpc/ws`; - - const ws = new WebSocket(wsUrl); - const link = new WebSocketLink({ websocket: ws }); - - return { - client: createClient(link), - cleanup: () => ws.close(), - ws, - }; -} - -export const ORPCProvider = (props: ORPCProviderProps) => { - // If client is provided externally, start in connected state immediately - // This avoids a flash of null content on first render - const [state, setState] = useState(() => { - if (props.client) { - // Also set the global client reference immediately - window.__ORPC_CLIENT__ = props.client; - return { status: "connected", client: props.client, cleanup: () => undefined }; - } - return { status: "connecting" }; - }); - const [authToken, setAuthToken] = useState(() => { - // Check URL param first, then localStorage - const urlParams = new URLSearchParams(window.location.search); - return urlParams.get("token") ?? getStoredAuthToken(); - }); - - const connect = useCallback( - (token: string | null) => { - // If client provided externally, use it directly - if (props.client) { - window.__ORPC_CLIENT__ = props.client; - setState({ status: "connected", client: props.client, cleanup: () => undefined }); - return; - } - - // Electron mode - no auth needed - if (window.api) { - const { client, cleanup } = createElectronClient(); - window.__ORPC_CLIENT__ = client; - setState({ status: "connected", client, cleanup }); - return; - } - - // Browser mode - connect with optional auth token - setState({ status: "connecting" }); - const { client, cleanup, ws } = createBrowserClient(token); - - ws.addEventListener("open", () => { - // Connection successful - test with a ping to verify auth - client.general - .ping("auth-check") - .then(() => { - window.__ORPC_CLIENT__ = client; - setState({ status: "connected", client, cleanup }); - }) - .catch((err: unknown) => { - cleanup(); - const errMsg = err instanceof Error ? err.message : String(err); - const errMsgLower = errMsg.toLowerCase(); - // Check for auth-related errors (case-insensitive) - const isAuthError = - errMsgLower.includes("unauthorized") || - errMsgLower.includes("401") || - errMsgLower.includes("auth token") || - errMsgLower.includes("authentication"); - if (isAuthError) { - clearStoredAuthToken(); - setState({ status: "auth_required", error: token ? "Invalid token" : undefined }); - } else { - setState({ status: "error", error: errMsg }); - } - }); - }); - - ws.addEventListener("error", () => { - // WebSocket connection failed - might be auth issue or network - cleanup(); - // If we had a token and failed, likely auth issue - if (token) { - clearStoredAuthToken(); - setState({ status: "auth_required", error: "Connection failed - invalid token?" }); - } else { - // Try without token first, server might not require auth - // If server requires auth, the ping will fail with UNAUTHORIZED - setState({ status: "auth_required" }); - } - }); - - ws.addEventListener("close", (event) => { - // 1008 = Policy Violation (often used for auth failures) - // 4401 = Custom unauthorized code - if (event.code === 1008 || event.code === 4401) { - cleanup(); - clearStoredAuthToken(); - setState({ status: "auth_required", error: "Authentication required" }); - } - }); - }, - [props.client] - ); - - // Initial connection attempt - useEffect(() => { - connect(authToken); - - return () => { - if (state.status === "connected") { - state.cleanup(); - } - }; - // Only run on mount and when authToken changes via handleAuthSubmit - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const handleAuthSubmit = useCallback( - (token: string) => { - setAuthToken(token); - connect(token); - }, - [connect] - ); - - // Show auth modal if auth is required - if (state.status === "auth_required") { - return ; - } - - // Show error state - if (state.status === "error") { - return ( -
-
Failed to connect to server
-
{state.error}
- -
- ); - } - - // Show loading while connecting - if (state.status === "connecting") { - return null; // Or a loading spinner - } - - return {props.children}; -}; - -export const useORPC = (): RouterClient => { - const context = useContext(ORPCContext); - if (!context) { - throw new Error("useORPC must be used within an ORPCProvider"); - } - return context; -}; diff --git a/src/browser/stores/GitStatusStore.test.ts b/src/browser/stores/GitStatusStore.test.ts index 6c4ddde91..bbc8361be 100644 --- a/src/browser/stores/GitStatusStore.test.ts +++ b/src/browser/stores/GitStatusStore.test.ts @@ -44,12 +44,6 @@ describe("GitStatusStore", () => { } as unknown as Window & typeof globalThis; store = new GitStatusStore(); - // Set up mock client for ORPC calls - store.setClient({ - workspace: { - executeBash: mockExecuteBash, - }, - } as unknown as Parameters[0]); }); afterEach(() => { diff --git a/src/browser/stores/GitStatusStore.ts b/src/browser/stores/GitStatusStore.ts index 2089f8d62..f566f314f 100644 --- a/src/browser/stores/GitStatusStore.ts +++ b/src/browser/stores/GitStatusStore.ts @@ -1,5 +1,3 @@ -import type { RouterClient } from "@orpc/server"; -import type { AppRouter } from "@/node/orpc/router"; import type { FrontendWorkspaceMetadata, GitStatus } from "@/common/types/workspace"; import { parseGitShowBranchForStatus } from "@/common/utils/git/parseGitStatus"; import { @@ -44,14 +42,10 @@ interface FetchState { export class GitStatusStore { private statuses = new MapStore(); private fetchCache = new Map(); - private client: RouterClient | null = null; private pollInterval: NodeJS.Timeout | null = null; private workspaceMetadata = new Map(); private isActive = true; - setClient(client: RouterClient) { - this.client = client; - } constructor() { // Store is ready for workspace sync } @@ -215,19 +209,15 @@ export class GitStatusStore { private async checkWorkspaceStatus( metadata: FrontendWorkspaceMetadata ): Promise<[string, GitStatus | null]> { - // Defensive: Return null if client is unavailable - if (!this.client) { + // Defensive: Return null if window.api is unavailable (e.g., test environment) + if (typeof window === "undefined" || !window.api) { return [metadata.id, null]; } try { - const result = await this.client.workspace.executeBash({ - workspaceId: metadata.id, - script: GIT_STATUS_SCRIPT, - options: { - timeout_secs: 5, - niceness: 19, - }, + const result = await window.api.workspace.executeBash(metadata.id, GIT_STATUS_SCRIPT, { + timeout_secs: 5, + niceness: 19, // Lowest priority - don't interfere with user operations }); if (!result.success) { @@ -336,8 +326,8 @@ export class GitStatusStore { * For SSH workspaces: fetches the workspace's individual repo. */ private async fetchWorkspace(fetchKey: string, workspaceId: string): Promise { - // Defensive: Return early if client is unavailable - if (!this.client) { + // Defensive: Return early if window.api is unavailable (e.g., test environment) + if (typeof window === "undefined" || !window.api) { return; } @@ -353,13 +343,9 @@ export class GitStatusStore { this.fetchCache.set(fetchKey, { ...cache, inProgress: true }); try { - const result = await this.client.workspace.executeBash({ - workspaceId, - script: GIT_FETCH_SCRIPT, - options: { - timeout_secs: 30, - niceness: 19, - }, + const result = await window.api.workspace.executeBash(workspaceId, GIT_FETCH_SCRIPT, { + timeout_secs: 30, + niceness: 19, // Lowest priority - don't interfere with user operations }); if (!result.success) { diff --git a/src/browser/stores/WorkspaceConsumerManager.ts b/src/browser/stores/WorkspaceConsumerManager.ts index 3065a8102..e5877ed0a 100644 --- a/src/browser/stores/WorkspaceConsumerManager.ts +++ b/src/browser/stores/WorkspaceConsumerManager.ts @@ -2,27 +2,33 @@ import type { WorkspaceConsumersState } from "./WorkspaceStore"; import type { StreamingMessageAggregator } from "@/browser/utils/messages/StreamingMessageAggregator"; import type { ChatStats } from "@/common/types/chatStats"; import type { MuxMessage } from "@/common/types/message"; +import assert from "@/common/utils/assert"; const TOKENIZER_CANCELLED_MESSAGE = "Cancelled by newer request"; let globalTokenStatsRequestId = 0; const latestRequestByWorkspace = new Map(); +function getTokenizerApi() { + if (typeof window === "undefined") { + return null; + } + return window.api?.tokenizer ?? null; +} + async function calculateTokenStatsLatest( workspaceId: string, messages: MuxMessage[], model: string ): Promise { - const orpcClient = window.__ORPC_CLIENT__; - if (!orpcClient) { - throw new Error("ORPC client not initialized"); - } + const tokenizer = getTokenizerApi(); + assert(tokenizer, "Tokenizer IPC bridge unavailable"); const requestId = ++globalTokenStatsRequestId; latestRequestByWorkspace.set(workspaceId, requestId); try { - const stats = await orpcClient.tokenizer.calculateStats({ messages, model }); + const stats = await tokenizer.calculateStats(messages, model); const latestRequestId = latestRequestByWorkspace.get(workspaceId); if (latestRequestId !== requestId) { throw new Error(TOKENIZER_CANCELLED_MESSAGE); diff --git a/src/browser/stores/WorkspaceStore.test.ts b/src/browser/stores/WorkspaceStore.test.ts index cd10c3451..e085810f4 100644 --- a/src/browser/stores/WorkspaceStore.test.ts +++ b/src/browser/stores/WorkspaceStore.test.ts @@ -1,39 +1,46 @@ -import { describe, expect, it, beforeEach, afterEach, mock, type Mock } from "bun:test"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; -import type { WorkspaceChatMessage } from "@/common/orpc/types"; import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace"; import { WorkspaceStore } from "./WorkspaceStore"; -// Mock client -// eslint-disable-next-line require-yield -const mockOnChat = mock(async function* (): AsyncGenerator { - // yield nothing by default - await Promise.resolve(); -}); - -const mockClient = { - workspace: { - onChat: mockOnChat, +// Mock window.api +const mockExecuteBash = jest.fn(() => ({ + success: true, + data: { + success: false, + error: "executeBash is mocked in WorkspaceStore.test.ts", + output: "", + exitCode: 0, }, -}; +})); const mockWindow = { api: { workspace: { - onChat: mock((_workspaceId, _callback) => { + onChat: jest.fn((_workspaceId, _callback) => { + // Return unsubscribe function return () => { - // cleanup + // Empty unsubscribe }; }), + replaceChatHistory: jest.fn(), + executeBash: mockExecuteBash, }, }, }; global.window = mockWindow as unknown as Window & typeof globalThis; -global.window.dispatchEvent = mock(); -// Mock queueMicrotask -global.queueMicrotask = (fn) => fn(); +// Mock dispatchEvent +global.window.dispatchEvent = jest.fn(); + +// Helper to get IPC callback in a type-safe way +function getOnChatCallback(): (data: T) => void { + const mock = mockWindow.api.workspace.onChat as jest.Mock< + () => void, + [string, (data: T) => void] + >; + return mock.mock.calls[0][1]; +} // Helper to create and add a workspace function createAndAddWorkspace( @@ -56,14 +63,13 @@ function createAndAddWorkspace( describe("WorkspaceStore", () => { let store: WorkspaceStore; - let mockOnModelUsed: Mock<(model: string) => void>; + let mockOnModelUsed: jest.Mock; beforeEach(() => { - mockOnChat.mockClear(); - mockOnModelUsed = mock(() => undefined); + jest.clearAllMocks(); + mockExecuteBash.mockClear(); + mockOnModelUsed = jest.fn(); store = new WorkspaceStore(mockOnModelUsed); - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any - store.setClient(mockClient as any); }); afterEach(() => { @@ -112,18 +118,6 @@ describe("WorkspaceStore", () => { runtimeConfig: DEFAULT_RUNTIME_CONFIG, }; - // Setup mock stream - mockOnChat.mockImplementation(async function* (): AsyncGenerator< - WorkspaceChatMessage, - void, - unknown - > { - yield { type: "caught-up" }; - await new Promise((resolve) => { - setTimeout(resolve, 10); - }); - }); - // Add workspace store.addWorkspace(metadata); @@ -131,6 +125,12 @@ describe("WorkspaceStore", () => { const initialState = store.getWorkspaceState(workspaceId); expect(initialState.recencyTimestamp).toBe(new Date(createdAt).getTime()); + // Get the IPC callback to simulate messages + const callback = getOnChatCallback(); + + // Simulate CAUGHT_UP message with no history (new workspace with no messages) + callback({ type: "caught-up" }); + // Wait for async processing await new Promise((resolve) => setTimeout(resolve, 10)); @@ -146,7 +146,7 @@ describe("WorkspaceStore", () => { describe("subscription", () => { it("should call listener when workspace state changes", async () => { - const listener = mock(() => undefined); + const listener = jest.fn(); const unsubscribe = store.subscribe(listener); // Create workspace metadata @@ -160,29 +160,23 @@ describe("WorkspaceStore", () => { runtimeConfig: DEFAULT_RUNTIME_CONFIG, }; - // Setup mock stream - mockOnChat.mockImplementation(async function* (): AsyncGenerator< - WorkspaceChatMessage, - void, - unknown - > { - await Promise.resolve(); - yield { type: "caught-up" }; - }); - // Add workspace (should trigger IPC subscription) store.addWorkspace(metadata); - // Wait for async processing - await new Promise((resolve) => setTimeout(resolve, 10)); + // Simulate a caught-up message (triggers emit) + const onChatCallback = getOnChatCallback(); + onChatCallback({ type: "caught-up" }); + + // Wait for queueMicrotask to complete + await new Promise((resolve) => setTimeout(resolve, 0)); expect(listener).toHaveBeenCalled(); unsubscribe(); }); - it("should allow unsubscribe", async () => { - const listener = mock(() => undefined); + it("should allow unsubscribe", () => { + const listener = jest.fn(); const unsubscribe = store.subscribe(listener); const metadata: FrontendWorkspaceMetadata = { @@ -195,22 +189,13 @@ describe("WorkspaceStore", () => { runtimeConfig: DEFAULT_RUNTIME_CONFIG, }; - // Setup mock stream - mockOnChat.mockImplementation(async function* (): AsyncGenerator< - WorkspaceChatMessage, - void, - unknown - > { - await Promise.resolve(); - yield { type: "caught-up" }; - }); + store.addWorkspace(metadata); - // Unsubscribe before adding workspace (which triggers updates) + // Unsubscribe before emitting unsubscribe(); - store.addWorkspace(metadata); - // Wait for async processing - await new Promise((resolve) => setTimeout(resolve, 10)); + const onChatCallback = getOnChatCallback(); + onChatCallback({ type: "caught-up" }); expect(listener).not.toHaveBeenCalled(); }); @@ -231,7 +216,10 @@ describe("WorkspaceStore", () => { const workspaceMap = new Map([[metadata1.id, metadata1]]); store.syncWorkspaces(workspaceMap); - expect(mockOnChat).toHaveBeenCalledWith({ workspaceId: "workspace-1" }, expect.anything()); + expect(mockWindow.api.workspace.onChat).toHaveBeenCalledWith( + "workspace-1", + expect.any(Function) + ); }); it("should remove deleted workspaces", () => { @@ -247,13 +235,14 @@ describe("WorkspaceStore", () => { // Add workspace store.addWorkspace(metadata1); + const unsubscribeSpy = jest.fn(); + (mockWindow.api.workspace.onChat as jest.Mock).mockReturnValue(unsubscribeSpy); // Sync with empty map (removes all workspaces) store.syncWorkspaces(new Map()); - // Should verify that the controller was aborted, but since we mock the implementation - // we just check that the workspace was removed from internal state - expect(store.getAggregator("workspace-1")).toBeUndefined(); + // Note: The unsubscribe function from the first add won't be captured + // since we mocked it before. In real usage, this would be called. }); }); @@ -311,30 +300,27 @@ describe("WorkspaceStore", () => { runtimeConfig: DEFAULT_RUNTIME_CONFIG, }; - // Setup mock stream - mockOnChat.mockImplementation(async function* (): AsyncGenerator< - WorkspaceChatMessage, - void, - unknown - > { - yield { type: "caught-up" }; - await new Promise((resolve) => setTimeout(resolve, 0)); - yield { - type: "stream-start", - historySequence: 1, - messageId: "msg1", - model: "claude-opus-4", - workspaceId: "test-workspace", - }; - await new Promise((resolve) => { - setTimeout(resolve, 10); - }); + store.addWorkspace(metadata); + + const onChatCallback = getOnChatCallback<{ + type: string; + messageId?: string; + model?: string; + }>(); + + // Mark workspace as caught-up first (required for stream events to process) + onChatCallback({ + type: "caught-up", }); - store.addWorkspace(metadata); + onChatCallback({ + type: "stream-start", + messageId: "msg-1", + model: "claude-opus-4", + }); - // Wait for async processing - await new Promise((resolve) => setTimeout(resolve, 20)); + // Wait for queueMicrotask to complete + await new Promise((resolve) => setTimeout(resolve, 0)); expect(mockOnModelUsed).toHaveBeenCalledWith("claude-opus-4"); }); @@ -367,7 +353,7 @@ describe("WorkspaceStore", () => { }); it("syncWorkspaces() does not emit when workspaces unchanged", () => { - const listener = mock(() => undefined); + const listener = jest.fn(); store.subscribe(listener); const metadata = new Map(); @@ -415,33 +401,30 @@ describe("WorkspaceStore", () => { createdAt: new Date().toISOString(), runtimeConfig: DEFAULT_RUNTIME_CONFIG, }; - - // Setup mock stream - mockOnChat.mockImplementation(async function* (): AsyncGenerator< - WorkspaceChatMessage, - void, - unknown - > { - yield { type: "caught-up" }; - await new Promise((resolve) => setTimeout(resolve, 0)); - yield { - type: "stream-start", - historySequence: 1, - messageId: "msg1", - model: "claude-sonnet-4", - workspaceId: "test-workspace", - }; - await new Promise((resolve) => { - setTimeout(resolve, 10); - }); - }); - store.addWorkspace(metadata); const state1 = store.getWorkspaceState("test-workspace"); - // Wait for async processing - await new Promise((resolve) => setTimeout(resolve, 20)); + // Trigger change + const onChatCallback = getOnChatCallback<{ + type: string; + messageId?: string; + model?: string; + }>(); + + // Mark workspace as caught-up first + onChatCallback({ + type: "caught-up", + }); + + onChatCallback({ + type: "stream-start", + messageId: "msg1", + model: "claude-sonnet-4", + }); + + // Wait for queueMicrotask to complete + await new Promise((resolve) => setTimeout(resolve, 0)); const state2 = store.getWorkspaceState("test-workspace"); expect(state1).not.toBe(state2); // Cache should be invalidated @@ -458,33 +441,30 @@ describe("WorkspaceStore", () => { createdAt: new Date().toISOString(), runtimeConfig: DEFAULT_RUNTIME_CONFIG, }; - - // Setup mock stream - mockOnChat.mockImplementation(async function* (): AsyncGenerator< - WorkspaceChatMessage, - void, - unknown - > { - yield { type: "caught-up" }; - await new Promise((resolve) => setTimeout(resolve, 0)); - yield { - type: "stream-start", - historySequence: 1, - messageId: "msg1", - model: "claude-sonnet-4", - workspaceId: "test-workspace", - }; - await new Promise((resolve) => { - setTimeout(resolve, 10); - }); - }); - store.addWorkspace(metadata); const states1 = store.getAllStates(); - // Wait for async processing - await new Promise((resolve) => setTimeout(resolve, 20)); + // Trigger change + const onChatCallback = getOnChatCallback<{ + type: string; + messageId?: string; + model?: string; + }>(); + + // Mark workspace as caught-up first + onChatCallback({ + type: "caught-up", + }); + + onChatCallback({ + type: "stream-start", + messageId: "msg1", + model: "claude-sonnet-4", + }); + + // Wait for queueMicrotask to complete + await new Promise((resolve) => setTimeout(resolve, 0)); const states2 = store.getAllStates(); expect(states1).not.toBe(states2); // Cache should be invalidated @@ -563,7 +543,9 @@ describe("WorkspaceStore", () => { expect(allStates.size).toBe(0); // Verify aggregator is gone - expect(store.getAggregator("test-workspace")).toBeUndefined(); + expect(() => store.getAggregator("test-workspace")).toThrow( + /Workspace test-workspace not found/ + ); }); it("handles concurrent workspace additions", () => { diff --git a/src/browser/stores/WorkspaceStore.ts b/src/browser/stores/WorkspaceStore.ts index 9b25a890e..4b0be45f8 100644 --- a/src/browser/stores/WorkspaceStore.ts +++ b/src/browser/stores/WorkspaceStore.ts @@ -1,9 +1,7 @@ import assert from "@/common/utils/assert"; import type { MuxMessage, DisplayedMessage, QueuedMessage } from "@/common/types/message"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; -import type { WorkspaceChatMessage } from "@/common/orpc/types"; -import type { RouterClient } from "@orpc/server"; -import type { AppRouter } from "@/node/orpc/router"; +import type { WorkspaceChatMessage } from "@/common/types/ipc"; import type { TodoItem } from "@/common/types/tools"; import { StreamingMessageAggregator } from "@/browser/utils/messages/StreamingMessageAggregator"; import { updatePersistedState } from "@/browser/hooks/usePersistedState"; @@ -17,7 +15,7 @@ import { isMuxMessage, isQueuedMessageChanged, isRestoreToInput, -} from "@/common/orpc/types"; +} from "@/common/types/ipc"; import { MapStore } from "./MapStore"; import { collectUsageHistory, createDisplayUsage } from "@/common/utils/tokens/displayUsage"; import { WorkspaceConsumerManager } from "./WorkspaceConsumerManager"; @@ -97,7 +95,6 @@ export class WorkspaceStore { // Usage and consumer stores (two-store approach for CostsTab optimization) private usageStore = new MapStore(); - private client: RouterClient | null = null; private consumersStore = new MapStore(); // Manager for consumer calculations (debouncing, caching, lazy loading) @@ -259,10 +256,6 @@ export class WorkspaceStore { // message completion events (not on deltas) to prevent App.tsx re-renders. } - setClient(client: RouterClient) { - this.client = client; - } - /** * Dispatch resume check event for a workspace. * Triggers useResumeManager to check if interrupted stream can be resumed. @@ -417,10 +410,11 @@ export class WorkspaceStore { /** * Get aggregator for a workspace (used by components that need direct access). - * Returns undefined if workspace does not exist. + * + * REQUIRES: Workspace must have been added via addWorkspace() first. */ - getAggregator(workspaceId: string): StreamingMessageAggregator | undefined { - return this.aggregators.get(workspaceId); + getAggregator(workspaceId: string): StreamingMessageAggregator { + return this.assertGet(workspaceId); } /** @@ -595,35 +589,13 @@ export class WorkspaceStore { // Subscribe to IPC events // Wrap in queueMicrotask to ensure IPC events don't update during React render - if (this.client) { - const controller = new AbortController(); - const { signal } = controller; - - // Fire and forget the async loop - (async () => { - try { - const iterator = await this.client!.workspace.onChat({ workspaceId }, { signal }); - - for await (const data of iterator) { - if (signal.aborted) break; - queueMicrotask(() => { - this.handleChatMessage(workspaceId, data); - }); - } - } catch (error) { - if (!signal.aborted) { - console.error( - `[WorkspaceStore] Error in onChat subscription for ${workspaceId}:`, - error - ); - } - } - })(); + const unsubscribe = window.api.workspace.onChat(workspaceId, (data: WorkspaceChatMessage) => { + queueMicrotask(() => { + this.handleChatMessage(workspaceId, data); + }); + }); - this.ipcUnsubscribers.set(workspaceId, () => controller.abort()); - } else { - console.warn(`[WorkspaceStore] No ORPC client available for workspace ${workspaceId}`); - } + this.ipcUnsubscribers.set(workspaceId, unsubscribe); } /** @@ -948,9 +920,7 @@ export function useWorkspaceSidebarState(workspaceId: string): WorkspaceSidebarS /** * Hook to get an aggregator for a workspace. */ -export function useWorkspaceAggregator( - workspaceId: string -): StreamingMessageAggregator | undefined { +export function useWorkspaceAggregator(workspaceId: string) { const store = useWorkspaceStoreRaw(); return store.getAggregator(workspaceId); } diff --git a/src/browser/styles/globals.css b/src/browser/styles/globals.css index 8c90f812d..ee0cf3cd5 100644 --- a/src/browser/styles/globals.css +++ b/src/browser/styles/globals.css @@ -116,11 +116,7 @@ --color-token-cached: hsl(0 0% 50%); /* Plan surfaces */ - --surface-plan-gradient: linear-gradient( - 135deg, - color-mix(in srgb, var(--color-plan-mode), transparent 92%) 0%, - color-mix(in srgb, var(--color-plan-mode), transparent 95%) 100% - ); + --surface-plan-gradient: linear-gradient(135deg, color-mix(in srgb, var(--color-plan-mode), transparent 92%) 0%, color-mix(in srgb, var(--color-plan-mode), transparent 95%) 100%); --surface-plan-border: color-mix(in srgb, var(--color-plan-mode), transparent 70%); --surface-plan-border-subtle: color-mix(in srgb, var(--color-plan-mode), transparent 80%); --surface-plan-border-strong: color-mix(in srgb, var(--color-plan-mode), transparent 60%); @@ -348,11 +344,7 @@ --color-token-output: hsl(207 90% 40%); --color-token-cached: hsl(210 16% 50%); - --surface-plan-gradient: linear-gradient( - 135deg, - color-mix(in srgb, var(--color-plan-mode), transparent 94%) 0%, - color-mix(in srgb, var(--color-plan-mode), transparent 97%) 100% - ); + --surface-plan-gradient: linear-gradient(135deg, color-mix(in srgb, var(--color-plan-mode), transparent 94%) 0%, color-mix(in srgb, var(--color-plan-mode), transparent 97%) 100%); --surface-plan-border: color-mix(in srgb, var(--color-plan-mode), transparent 78%); --surface-plan-border-subtle: color-mix(in srgb, var(--color-plan-mode), transparent 85%); --surface-plan-border-strong: color-mix(in srgb, var(--color-plan-mode), transparent 70%); @@ -482,6 +474,7 @@ /* Theme override hook: redeclare tokens inside this block to swap palettes */ } + h1, h2, h3 { @@ -589,6 +582,7 @@ body, } } + /* Tailwind utility extensions for dark theme surfaces */ @utility plan-surface { background: var(--surface-plan-gradient); @@ -607,10 +601,7 @@ body, color: var(--color-plan-mode); background: var(--surface-plan-chip-bg); border: 1px solid var(--surface-plan-chip-border); - transition: - background 150ms ease, - border-color 150ms ease, - color 150ms ease; + transition: background 150ms ease, border-color 150ms ease, color 150ms ease; } @utility plan-chip-hover { @@ -627,10 +618,7 @@ body, color: var(--color-muted); background: transparent; border: 1px solid var(--surface-plan-neutral-border); - transition: - background 150ms ease, - border-color 150ms ease, - color 150ms ease; + transition: background 150ms ease, border-color 150ms ease, color 150ms ease; } @utility plan-chip-ghost-hover { @@ -643,9 +631,7 @@ body, background: var(--surface-assistant-chip-bg); border: 1px solid var(--surface-assistant-chip-border); color: var(--color-foreground); - transition: - background 150ms ease, - border-color 150ms ease; + transition: background 150ms ease, border-color 150ms ease; } @utility assistant-chip-hover { @@ -653,6 +639,7 @@ body, border-color: var(--surface-assistant-chip-border-strong); } + @utility edit-cutoff-divider { border-bottom: 3px solid; border-image: repeating-linear-gradient( diff --git a/src/browser/terminal-window.tsx b/src/browser/terminal-window.tsx index 9fbb7fec3..09dc258d0 100644 --- a/src/browser/terminal-window.tsx +++ b/src/browser/terminal-window.tsx @@ -8,9 +8,11 @@ import React from "react"; import ReactDOM from "react-dom/client"; import { TerminalView } from "@/browser/components/TerminalView"; -import { ORPCProvider } from "@/browser/orpc/react"; import "./styles/globals.css"; +// Shims the `window.api` object with the browser API if not running in Electron +import "./api"; + // Get workspace ID from query parameter const params = new URLSearchParams(window.location.search); const workspaceId = params.get("workspaceId"); @@ -23,14 +25,30 @@ if (!workspaceId) {
`; } else { - document.title = `Terminal — ${workspaceId}`; + // Set document title for browser tab + // Fetch workspace metadata to get a better title + if (window.api) { + window.api.workspace + .list() + .then((workspaces: Array<{ id: string; projectName: string; name: string }>) => { + const workspace = workspaces.find((ws) => ws.id === workspaceId); + if (workspace) { + document.title = `Terminal — ${workspace.projectName}/${workspace.name}`; + } else { + document.title = `Terminal — ${workspaceId}`; + } + }) + .catch(() => { + document.title = `Terminal — ${workspaceId}`; + }); + } else { + document.title = `Terminal — ${workspaceId}`; + } // Don't use StrictMode for terminal windows to avoid double-mounting issues // StrictMode intentionally double-mounts components in dev, which causes // race conditions with WebSocket connections and terminal lifecycle ReactDOM.createRoot(document.getElementById("root")!).render( - - - + ); } diff --git a/src/browser/testUtils.ts b/src/browser/testUtils.ts deleted file mode 100644 index 055fbeb1f..000000000 --- a/src/browser/testUtils.ts +++ /dev/null @@ -1,13 +0,0 @@ -// Shared test utilities for browser tests - -/** - * Helper type for recursive partial mocks. - * Allows partial mocking of nested objects and async functions. - */ -export type RecursivePartial = { - [P in keyof T]?: T[P] extends (...args: infer A) => infer R - ? (...args: A) => Promise> | R - : T[P] extends object - ? RecursivePartial - : T[P]; -}; diff --git a/src/browser/utils/chatCommands.test.ts b/src/browser/utils/chatCommands.test.ts index 76c887730..d3d20093f 100644 --- a/src/browser/utils/chatCommands.test.ts +++ b/src/browser/utils/chatCommands.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test, beforeEach } from "bun:test"; -import type { SendMessageOptions } from "@/common/orpc/types"; +import type { SendMessageOptions } from "@/common/types/ipc"; import { parseRuntimeString, prepareCompactionMessage } from "./chatCommands"; // Simple mock for localStorage to satisfy resolveCompactionModel diff --git a/src/browser/utils/chatCommands.ts b/src/browser/utils/chatCommands.ts index f33667a58..2acc50671 100644 --- a/src/browser/utils/chatCommands.ts +++ b/src/browser/utils/chatCommands.ts @@ -6,9 +6,7 @@ * to ensure consistent behavior and avoid duplication. */ -import type { RouterClient } from "@orpc/server"; -import type { AppRouter } from "@/node/orpc/router"; -import type { SendMessageOptions, ImagePart } from "@/common/orpc/types"; +import type { SendMessageOptions, ImagePart } from "@/common/types/ipc"; import type { MuxFrontendMetadata, CompactionRequestData, @@ -34,7 +32,6 @@ import { createCommandToast } from "@/browser/components/ChatInputToasts"; import { setTelemetryEnabled } from "@/common/telemetry"; export interface ForkOptions { - client: RouterClient; sourceWorkspaceId: string; newName: string; startMessage?: string; @@ -54,11 +51,7 @@ export interface ForkResult { * Caller is responsible for error handling, logging, and showing toasts */ export async function forkWorkspace(options: ForkOptions): Promise { - const { client } = options; - const result = await client.workspace.fork({ - sourceWorkspaceId: options.sourceWorkspaceId, - newName: options.newName, - }); + const result = await window.api.workspace.fork(options.sourceWorkspaceId, options.newName); if (!result.success) { return { success: false, error: result.error ?? "Failed to fork workspace" }; @@ -68,7 +61,7 @@ export async function forkWorkspace(options: ForkOptions): Promise { copyWorkspaceStorage(options.sourceWorkspaceId, result.metadata.id); // Get workspace info for switching - const workspaceInfo = await client.workspace.getInfo({ workspaceId: result.metadata.id }); + const workspaceInfo = await window.api.workspace.getInfo(result.metadata.id); if (!workspaceInfo) { return { success: false, error: "Failed to get workspace info after fork" }; } @@ -83,11 +76,11 @@ export async function forkWorkspace(options: ForkOptions): Promise { // 3. WorkspaceStore to subscribe to the new workspace's IPC channel if (options.startMessage && options.sendMessageOptions) { requestAnimationFrame(() => { - void client.workspace.sendMessage({ - workspaceId: result.metadata.id, - message: options.startMessage!, - options: options.sendMessageOptions, - }); + void window.api.workspace.sendMessage( + result.metadata.id, + options.startMessage!, + options.sendMessageOptions + ); }); } @@ -313,7 +306,7 @@ async function handleForkCommand( parsed: Extract, context: SlashCommandContext ): Promise { - const { client, workspaceId, sendMessageOptions, setInput, setIsSending, setToast } = context; + const { workspaceId, sendMessageOptions, setInput, setIsSending, setToast } = context; setInput(""); // Clear input immediately setIsSending(true); @@ -323,9 +316,7 @@ async function handleForkCommand( // If we are here, variant === "workspace", so workspaceId should be defined. if (!workspaceId) throw new Error("Workspace ID required for fork"); - if (!client) throw new Error("Client required for fork"); const forkResult = await forkWorkspace({ - client, sourceWorkspaceId: workspaceId, newName: parsed.newName, startMessage: parsed.startMessage, @@ -408,7 +399,6 @@ export function parseRuntimeString( } export interface CreateWorkspaceOptions { - client: RouterClient; projectPath: string; workspaceName: string; trunkBranch?: string; @@ -435,9 +425,7 @@ export async function createNewWorkspace( // Get recommended trunk if not provided let effectiveTrunk = options.trunkBranch; if (!effectiveTrunk) { - const { recommendedTrunk } = await options.client.projects.listBranches({ - projectPath: options.projectPath, - }); + const { recommendedTrunk } = await window.api.projects.listBranches(options.projectPath); effectiveTrunk = recommendedTrunk ?? "main"; } @@ -454,19 +442,19 @@ export async function createNewWorkspace( // Parse runtime config if provided const runtimeConfig = parseRuntimeString(effectiveRuntime, options.workspaceName); - const result = await options.client.workspace.create({ - projectPath: options.projectPath, - branchName: options.workspaceName, - trunkBranch: effectiveTrunk, - runtimeConfig, - }); + const result = await window.api.workspace.create( + options.projectPath, + options.workspaceName, + effectiveTrunk, + runtimeConfig + ); if (!result.success) { return { success: false, error: result.error ?? "Failed to create workspace" }; } // Get workspace info for switching - const workspaceInfo = await options.client.workspace.getInfo({ workspaceId: result.metadata.id }); + const workspaceInfo = await window.api.workspace.getInfo(result.metadata.id); if (!workspaceInfo) { return { success: false, error: "Failed to get workspace info after creation" }; } @@ -477,11 +465,11 @@ export async function createNewWorkspace( // If there's a start message, defer until React finishes rendering and WorkspaceStore subscribes if (options.startMessage && options.sendMessageOptions) { requestAnimationFrame(() => { - void options.client.workspace.sendMessage({ - workspaceId: result.metadata.id, - message: options.startMessage!, - options: options.sendMessageOptions, - }); + void window.api.workspace.sendMessage( + result.metadata.id, + options.startMessage!, + options.sendMessageOptions + ); }); } @@ -519,7 +507,6 @@ export function formatNewCommand( // ============================================================================ export interface CompactionOptions { - client?: RouterClient; workspaceId: string; maxOutputTokens?: number; continueMessage?: ContinueMessage; @@ -587,19 +574,13 @@ export function prepareCompactionMessage(options: CompactionOptions): { /** * Execute a compaction command */ -export async function executeCompaction( - options: CompactionOptions & { client: RouterClient } -): Promise { +export async function executeCompaction(options: CompactionOptions): Promise { const { messageText, metadata, sendOptions } = prepareCompactionMessage(options); - const result = await options.client.workspace.sendMessage({ - workspaceId: options.workspaceId, - message: messageText, - options: { - ...sendOptions, - muxMetadata: metadata, - editMessageId: options.editMessageId, - }, + const result = await window.api.workspace.sendMessage(options.workspaceId, messageText, { + ...sendOptions, + muxMetadata: metadata, + editMessageId: options.editMessageId, }); if (!result.success) { @@ -639,7 +620,6 @@ function formatCompactionCommand(options: CompactionOptions): string { // ============================================================================ export interface CommandHandlerContext { - client: RouterClient; workspaceId: string; sendMessageOptions: SendMessageOptions; imageParts?: ImagePart[]; @@ -665,14 +645,14 @@ export async function handleNewCommand( parsed: Extract, context: CommandHandlerContext ): Promise { - const { client, workspaceId, sendMessageOptions, setInput, setIsSending, setToast } = context; + const { workspaceId, sendMessageOptions, setInput, setIsSending, setToast } = context; // Open modal if no workspace name provided if (!parsed.workspaceName) { setInput(""); // Get workspace info to extract projectPath for the modal - const workspaceInfo = await client.workspace.getInfo({ workspaceId }); + const workspaceInfo = await window.api.workspace.getInfo(workspaceId); if (!workspaceInfo) { setToast({ id: Date.now().toString(), @@ -700,13 +680,12 @@ export async function handleNewCommand( try { // Get workspace info to extract projectPath - const workspaceInfo = await client.workspace.getInfo({ workspaceId }); + const workspaceInfo = await window.api.workspace.getInfo(workspaceId); if (!workspaceInfo) { throw new Error("Failed to get workspace info"); } const createResult = await createNewWorkspace({ - client, projectPath: workspaceInfo.projectPath, workspaceName: parsed.workspaceName, trunkBranch: parsed.trunkBranch, @@ -756,7 +735,6 @@ export async function handleCompactCommand( context: CommandHandlerContext ): Promise { const { - client, workspaceId, sendMessageOptions, editMessageId, @@ -773,7 +751,6 @@ export async function handleCompactCommand( try { const result = await executeCompaction({ - client, workspaceId, maxOutputTokens: parsed.maxOutputTokens, continueMessage: diff --git a/src/browser/utils/commands/sources.test.ts b/src/browser/utils/commands/sources.test.ts index 6b28d8358..c322ea63a 100644 --- a/src/browser/utils/commands/sources.test.ts +++ b/src/browser/utils/commands/sources.test.ts @@ -1,9 +1,7 @@ -import { expect, test, mock } from "bun:test"; import { buildCoreSources } from "./sources"; import type { ProjectConfig } from "@/node/config"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace"; -import type { ORPCClient } from "@/browser/orpc/react"; const mk = (over: Partial[0]> = {}) => { const projects = new Map(); @@ -51,12 +49,6 @@ const mk = (over: Partial[0]> = {}) => { onOpenWorkspaceInTerminal: () => undefined, onToggleTheme: () => undefined, onSetTheme: () => undefined, - client: { - workspace: { - truncateHistory: () => Promise.resolve({ success: true, data: undefined }), - interruptStream: () => Promise.resolve({ success: true, data: undefined }), - }, - } as unknown as ORPCClient, getBranchesForProject: () => Promise.resolve({ branches: ["main"], @@ -87,7 +79,7 @@ test("buildCoreSources adds thinking effort command", () => { }); test("thinking effort command submits selected level", async () => { - const onSetThinkingLevel = mock(); + const onSetThinkingLevel = jest.fn(); const sources = mk({ onSetThinkingLevel, getThinkingLevel: () => "low" }); const actions = sources.flatMap((s) => s()); const thinkingAction = actions.find((a) => a.id === "thinking:set-level"); diff --git a/src/browser/utils/commands/sources.ts b/src/browser/utils/commands/sources.ts index e277347b7..09029e5f4 100644 --- a/src/browser/utils/commands/sources.ts +++ b/src/browser/utils/commands/sources.ts @@ -1,6 +1,5 @@ import type { ThemeMode } from "@/browser/contexts/ThemeContext"; import type { CommandAction } from "@/browser/contexts/CommandRegistryContext"; -import type { ORPCClient } from "@/browser/orpc/react"; import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds"; import type { ThinkingLevel } from "@/common/types/thinking"; import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events"; @@ -8,10 +7,9 @@ import { CommandIds } from "@/browser/utils/commandIds"; import type { ProjectConfig } from "@/node/config"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; -import type { BranchListResult } from "@/common/orpc/types"; +import type { BranchListResult } from "@/common/types/ipc"; export interface BuildSourcesParams { - client: ORPCClient; projects: Map; /** Map of workspace ID to workspace metadata (keyed by metadata.id, not path) */ workspaceMetadata: Map; @@ -358,7 +356,7 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi title: "Clear History", section: section.chat, run: async () => { - await p.client.workspace.truncateHistory({ workspaceId: id, percentage: 1.0 }); + await window.api.workspace.truncateHistory(id, 1.0); }, }); for (const pct of [0.75, 0.5, 0.25]) { @@ -367,7 +365,7 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi title: `Truncate History to ${Math.round((1 - pct) * 100)}%`, section: section.chat, run: async () => { - await p.client.workspace.truncateHistory({ workspaceId: id, percentage: pct }); + await window.api.workspace.truncateHistory(id, pct); }, }); } @@ -376,7 +374,7 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi title: "Interrupt Streaming", section: section.chat, run: async () => { - await p.client.workspace.interruptStream({ workspaceId: id }); + await window.api.workspace.interruptStream(id); }, }); list.push({ diff --git a/src/browser/utils/compaction/handler.ts b/src/browser/utils/compaction/handler.ts index ee12afda5..ad57962af 100644 --- a/src/browser/utils/compaction/handler.ts +++ b/src/browser/utils/compaction/handler.ts @@ -6,7 +6,6 @@ */ import type { StreamingMessageAggregator } from "@/browser/utils/messages/StreamingMessageAggregator"; -import type { ORPCClient } from "@/browser/orpc/react"; /** * Check if the workspace is currently in a compaction stream @@ -59,7 +58,6 @@ export function getCompactionCommand(aggregator: StreamingMessageAggregator): st * 2. Enter edit mode on compaction-request message with original command */ export async function cancelCompaction( - client: ORPCClient, workspaceId: string, aggregator: StreamingMessageAggregator, startEditingMessage: (messageId: string, initialText: string) => void @@ -78,7 +76,7 @@ export async function cancelCompaction( // Interrupt stream with abandonPartial flag // Backend detects this and skips compaction (Ctrl+C flow) - await client.workspace.interruptStream({ workspaceId, options: { abandonPartial: true } }); + await window.api.workspace.interruptStream(workspaceId, { abandonPartial: true }); // Enter edit mode on the compaction-request message with original command // This lets user immediately edit the message or delete it diff --git a/src/browser/utils/messages/ChatEventProcessor.test.ts b/src/browser/utils/messages/ChatEventProcessor.test.ts index b1f01b5c5..78efd2185 100644 --- a/src/browser/utils/messages/ChatEventProcessor.test.ts +++ b/src/browser/utils/messages/ChatEventProcessor.test.ts @@ -1,5 +1,5 @@ import { createChatEventProcessor } from "./ChatEventProcessor"; -import type { WorkspaceChatMessage } from "@/common/orpc/types"; +import type { WorkspaceChatMessage } from "@/common/types/ipc"; describe("ChatEventProcessor - Reasoning Delta", () => { it("should merge consecutive reasoning deltas into a single part", () => { diff --git a/src/browser/utils/messages/ChatEventProcessor.ts b/src/browser/utils/messages/ChatEventProcessor.ts index 7d19b1140..cbb5ca929 100644 --- a/src/browser/utils/messages/ChatEventProcessor.ts +++ b/src/browser/utils/messages/ChatEventProcessor.ts @@ -17,7 +17,7 @@ */ import type { MuxMessage, MuxMetadata } from "@/common/types/message"; -import type { WorkspaceChatMessage } from "@/common/orpc/types"; +import type { WorkspaceChatMessage } from "@/common/types/ipc"; import { isStreamStart, isStreamDelta, @@ -32,7 +32,7 @@ import { isInitStart, isInitOutput, isInitEnd, -} from "@/common/orpc/types"; +} from "@/common/types/ipc"; import type { DynamicToolPart, DynamicToolPartPending, @@ -87,7 +87,7 @@ type ExtendedStreamStartEvent = StreamStartEvent & { timestamp?: number; }; -type ExtendedStreamEndEvent = Omit & { +type ExtendedStreamEndEvent = StreamEndEvent & { metadata: StreamEndEvent["metadata"] & Partial; }; diff --git a/src/browser/utils/messages/StreamingMessageAggregator.ts b/src/browser/utils/messages/StreamingMessageAggregator.ts index 2c6e317cb..7e5a47269 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.ts @@ -21,8 +21,8 @@ import type { import type { LanguageModelV2Usage } from "@ai-sdk/provider"; import type { TodoItem, StatusSetToolResult } from "@/common/types/tools"; -import type { WorkspaceChatMessage, StreamErrorMessage, DeleteMessage } from "@/common/orpc/types"; -import { isInitStart, isInitOutput, isInitEnd, isMuxMessage } from "@/common/orpc/types"; +import type { WorkspaceChatMessage, StreamErrorMessage, DeleteMessage } from "@/common/types/ipc"; +import { isInitStart, isInitOutput, isInitEnd, isMuxMessage } from "@/common/types/ipc"; import type { DynamicToolPart, DynamicToolPartPending, @@ -64,6 +64,7 @@ function hasFailureResult(result: unknown): boolean { export class StreamingMessageAggregator { private messages = new Map(); private activeStreams = new Map(); + private streamSequenceCounter = 0; // For ordering parts within a streaming message // Simple cache for derived values (invalidated on every mutation) private cachedAllMessages: MuxMessage[] | null = null; @@ -325,6 +326,7 @@ export class StreamingMessageAggregator { clear(): void { this.messages.clear(); this.activeStreams.clear(); + this.streamSequenceCounter = 0; this.invalidateCache(); } diff --git a/src/browser/utils/messages/compactionOptions.test.ts b/src/browser/utils/messages/compactionOptions.test.ts index 0033373eb..dd5efd6c5 100644 --- a/src/browser/utils/messages/compactionOptions.test.ts +++ b/src/browser/utils/messages/compactionOptions.test.ts @@ -3,7 +3,7 @@ */ import { applyCompactionOverrides } from "./compactionOptions"; -import type { SendMessageOptions } from "@/common/orpc/types"; +import type { SendMessageOptions } from "@/common/types/ipc"; import type { CompactionRequestData } from "@/common/types/message"; import { KNOWN_MODELS } from "@/common/constants/knownModels"; diff --git a/src/browser/utils/messages/compactionOptions.ts b/src/browser/utils/messages/compactionOptions.ts index 28241e753..eda71e44f 100644 --- a/src/browser/utils/messages/compactionOptions.ts +++ b/src/browser/utils/messages/compactionOptions.ts @@ -5,7 +5,7 @@ * Used by both ChatInput (initial send) and useResumeManager (resume after interruption). */ -import type { SendMessageOptions } from "@/common/orpc/types"; +import type { SendMessageOptions } from "@/common/types/ipc"; import type { CompactionRequestData } from "@/common/types/message"; /** diff --git a/src/browser/utils/messages/sendOptions.ts b/src/browser/utils/messages/sendOptions.ts index 7de1fbbe9..b18a2c802 100644 --- a/src/browser/utils/messages/sendOptions.ts +++ b/src/browser/utils/messages/sendOptions.ts @@ -2,7 +2,7 @@ import { getModelKey, getThinkingLevelKey, getModeKey } from "@/common/constants import { modeToToolPolicy, PLAN_MODE_INSTRUCTION } from "@/common/utils/ui/modeUtils"; import { readPersistedState } from "@/browser/hooks/usePersistedState"; import { getDefaultModel } from "@/browser/hooks/useModelLRU"; -import type { SendMessageOptions } from "@/common/orpc/types"; +import type { SendMessageOptions } from "@/common/types/ipc"; import type { UIMode } from "@/common/types/mode"; import type { ThinkingLevel } from "@/common/types/thinking"; import { enforceThinkingPolicy } from "@/browser/utils/thinking/policy"; diff --git a/src/browser/utils/tokenizer/rendererClient.ts b/src/browser/utils/tokenizer/rendererClient.ts index cf14de7fd..8e618bc84 100644 --- a/src/browser/utils/tokenizer/rendererClient.ts +++ b/src/browser/utils/tokenizer/rendererClient.ts @@ -1,4 +1,4 @@ -import type { ORPCClient } from "@/browser/orpc/react"; +import type { IPCApi } from "@/common/types/ipc"; const MAX_CACHE_ENTRIES = 256; @@ -12,6 +12,14 @@ interface CacheEntry { const tokenCache = new Map(); const keyOrder: CacheKey[] = []; +function getTokenizerApi(): IPCApi["tokenizer"] | null { + if (typeof window === "undefined") { + return null; + } + const api = window.api; + return api?.tokenizer ?? null; +} + function makeKey(model: string, text: string): CacheKey { return `${model}::${text}`; } @@ -25,11 +33,7 @@ function pruneCache(): void { } } -export function getTokenCountPromise( - client: ORPCClient, - model: string, - text: string -): Promise { +export function getTokenCountPromise(model: string, text: string): Promise { const trimmedModel = model?.trim(); if (!trimmedModel || text.length === 0) { return Promise.resolve(0); @@ -41,8 +45,13 @@ export function getTokenCountPromise( return cached.value !== null ? Promise.resolve(cached.value) : cached.promise; } - const promise = client.tokenizer - .countTokens({ model: trimmedModel, text }) + const tokenizer = getTokenizerApi(); + if (!tokenizer) { + return Promise.resolve(0); + } + + const promise = tokenizer + .countTokens(trimmedModel, text) .then((tokens) => { const entry = tokenCache.get(key); if (entry) { @@ -62,11 +71,7 @@ export function getTokenCountPromise( return promise; } -export async function countTokensBatchRenderer( - client: ORPCClient, - model: string, - texts: string[] -): Promise { +export async function countTokensBatchRenderer(model: string, texts: string[]): Promise { if (!Array.isArray(texts) || texts.length === 0) { return []; } @@ -76,6 +81,11 @@ export async function countTokensBatchRenderer( return texts.map(() => 0); } + const tokenizer = getTokenizerApi(); + if (!tokenizer) { + return texts.map(() => 0); + } + const results = new Array(texts.length).fill(0); const missingIndices: number[] = []; const missingTexts: string[] = []; @@ -97,10 +107,7 @@ export async function countTokensBatchRenderer( } try { - const rawBatchResult: unknown = await client.tokenizer.countTokensBatch({ - model: trimmedModel, - texts: missingTexts, - }); + const rawBatchResult: unknown = await tokenizer.countTokensBatch(trimmedModel, missingTexts); if (!Array.isArray(rawBatchResult)) { throw new Error("Tokenizer returned invalid batch result"); } diff --git a/src/browser/utils/ui/keybinds.test.ts b/src/browser/utils/ui/keybinds.test.ts index a67313756..e69de29bb 100644 --- a/src/browser/utils/ui/keybinds.test.ts +++ b/src/browser/utils/ui/keybinds.test.ts @@ -1,95 +0,0 @@ -import { describe, it, expect } from "bun:test"; -import { matchesKeybind, type Keybind } from "./keybinds"; - -describe("matchesKeybind", () => { - // Helper to create a minimal keyboard event - function createEvent(overrides: Partial = {}): KeyboardEvent { - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - return { - key: "a", - ctrlKey: false, - shiftKey: false, - altKey: false, - metaKey: false, - ...overrides, - } as KeyboardEvent; - } - - it("should return false when event.key is undefined", () => { - // This can happen with dead keys, modifier-only events, etc. - const event = createEvent({ key: undefined as unknown as string }); - const keybind: Keybind = { key: "a" }; - - expect(matchesKeybind(event, keybind)).toBe(false); - }); - - it("should return false when event.key is empty string", () => { - const event = createEvent({ key: "" }); - const keybind: Keybind = { key: "a" }; - - expect(matchesKeybind(event, keybind)).toBe(false); - }); - - it("should match simple key press", () => { - const event = createEvent({ key: "a" }); - const keybind: Keybind = { key: "a" }; - - expect(matchesKeybind(event, keybind)).toBe(true); - }); - - it("should match case-insensitively", () => { - const event = createEvent({ key: "A" }); - const keybind: Keybind = { key: "a" }; - - expect(matchesKeybind(event, keybind)).toBe(true); - }); - - it("should not match different key", () => { - const event = createEvent({ key: "b" }); - const keybind: Keybind = { key: "a" }; - - expect(matchesKeybind(event, keybind)).toBe(false); - }); - - it("should match Ctrl+key combination", () => { - const event = createEvent({ key: "n", ctrlKey: true }); - const keybind: Keybind = { key: "n", ctrl: true }; - - expect(matchesKeybind(event, keybind)).toBe(true); - }); - - it("should not match when Ctrl is required but not pressed", () => { - const event = createEvent({ key: "n", ctrlKey: false }); - const keybind: Keybind = { key: "n", ctrl: true }; - - expect(matchesKeybind(event, keybind)).toBe(false); - }); - - it("should not match when Ctrl is pressed but not required", () => { - const event = createEvent({ key: "n", ctrlKey: true }); - const keybind: Keybind = { key: "n" }; - - expect(matchesKeybind(event, keybind)).toBe(false); - }); - - it("should match Shift+key combination", () => { - const event = createEvent({ key: "G", shiftKey: true }); - const keybind: Keybind = { key: "G", shift: true }; - - expect(matchesKeybind(event, keybind)).toBe(true); - }); - - it("should match Alt+key combination", () => { - const event = createEvent({ key: "a", altKey: true }); - const keybind: Keybind = { key: "a", alt: true }; - - expect(matchesKeybind(event, keybind)).toBe(true); - }); - - it("should match complex multi-modifier combination", () => { - const event = createEvent({ key: "P", ctrlKey: true, shiftKey: true }); - const keybind: Keybind = { key: "P", ctrl: true, shift: true }; - - expect(matchesKeybind(event, keybind)).toBe(true); - }); -}); diff --git a/src/browser/utils/ui/keybinds.ts b/src/browser/utils/ui/keybinds.ts index 56b69765d..0a85f645b 100644 --- a/src/browser/utils/ui/keybinds.ts +++ b/src/browser/utils/ui/keybinds.ts @@ -50,11 +50,6 @@ export function matchesKeybind( event: React.KeyboardEvent | KeyboardEvent, keybind: Keybind ): boolean { - // Guard against undefined event.key (can happen with dead keys, modifier-only events, etc.) - if (!event.key) { - return false; - } - // Check key match (case-insensitive for letters) if (event.key.toLowerCase() !== keybind.key.toLowerCase()) { return false; diff --git a/src/cli/debug/agentSessionCli.ts b/src/cli/debug/agentSessionCli.ts index d58dac054..09c5726c8 100644 --- a/src/cli/debug/agentSessionCli.ts +++ b/src/cli/debug/agentSessionCli.ts @@ -23,7 +23,7 @@ import { isToolCallStart, type SendMessageOptions, type WorkspaceChatMessage, -} from "@/common/orpc/types"; +} from "@/common/types/ipc"; import { defaultModel } from "@/common/utils/ai/models"; import { ensureProvidersConfig } from "@/common/utils/providers/ensureProvidersConfig"; import { modeToToolPolicy, PLAN_MODE_INSTRUCTION } from "@/common/utils/ui/modeUtils"; diff --git a/src/cli/debug/send-message.ts b/src/cli/debug/send-message.ts index 9fb071bb4..d3018ed8c 100644 --- a/src/cli/debug/send-message.ts +++ b/src/cli/debug/send-message.ts @@ -2,7 +2,7 @@ import * as fs from "fs"; import * as path from "path"; import { defaultConfig } from "@/node/config"; import type { MuxMessage } from "@/common/types/message"; -import type { SendMessageOptions } from "@/common/orpc/types"; +import type { SendMessageOptions } from "@/common/types/ipc"; import { defaultModel } from "@/common/utils/ai/models"; import { getMuxSessionsDir } from "@/common/constants/paths"; diff --git a/src/cli/orpcServer.ts b/src/cli/orpcServer.ts deleted file mode 100644 index be2689022..000000000 --- a/src/cli/orpcServer.ts +++ /dev/null @@ -1,165 +0,0 @@ -/** - * oRPC Server factory for mux. - * Serves oRPC router over HTTP and WebSocket. - * - * This module exports the server creation logic so it can be tested. - * The CLI entry point (server.ts) uses this to start the server. - */ -import cors from "cors"; -import express, { type Express } from "express"; -import * as http from "http"; -import * as path from "path"; -import { WebSocketServer } from "ws"; -import { RPCHandler } from "@orpc/server/node"; -import { RPCHandler as ORPCWebSocketServerHandler } from "@orpc/server/ws"; -import { onError } from "@orpc/server"; -import { router } from "@/node/orpc/router"; -import type { ORPCContext } from "@/node/orpc/context"; -import { extractWsHeaders } from "@/node/orpc/authMiddleware"; -import { VERSION } from "@/version"; - -// --- Types --- - -export interface OrpcServerOptions { - /** Host to bind to (default: "127.0.0.1") */ - host?: string; - /** Port to bind to (default: 0 for random available port) */ - port?: number; - /** oRPC context with services */ - context: ORPCContext; - /** Whether to serve static files and SPA fallback (default: false) */ - serveStatic?: boolean; - /** Directory to serve static files from (default: __dirname/..) */ - staticDir?: string; - /** Custom error handler for oRPC errors */ - onOrpcError?: (error: unknown) => void; - /** Optional bearer token for HTTP auth */ - authToken?: string; -} - -export interface OrpcServer { - /** The HTTP server instance */ - httpServer: http.Server; - /** The WebSocket server instance */ - wsServer: WebSocketServer; - /** The Express app instance */ - app: Express; - /** The port the server is listening on */ - port: number; - /** Base URL for HTTP requests */ - baseUrl: string; - /** WebSocket URL for WS connections */ - wsUrl: string; - /** Close the server and cleanup resources */ - close: () => Promise; -} - -// --- Server Factory --- - -/** - * Create an oRPC server with HTTP and WebSocket endpoints. - * - * HTTP endpoint: /orpc - * WebSocket endpoint: /orpc/ws - * Health check: /health - * Version: /version - */ -export async function createOrpcServer({ - host = "127.0.0.1", - port = 0, - authToken, - context, - serveStatic = false, - staticDir = path.join(__dirname, ".."), - onOrpcError = (error) => console.error("ORPC Error:", error), -}: OrpcServerOptions): Promise { - // Express app setup - const app = express(); - app.use(cors()); - app.use(express.json({ limit: "50mb" })); - - // Static file serving (optional) - if (serveStatic) { - app.use(express.static(staticDir)); - } - - // Health check endpoint - app.get("/health", (_req, res) => { - res.json({ status: "ok" }); - }); - - // Version endpoint - app.get("/version", (_req, res) => { - res.json({ ...VERSION, mode: "server" }); - }); - - const orpcRouter = router(authToken); - - // oRPC HTTP handler - const orpcHandler = new RPCHandler(orpcRouter, { - interceptors: [onError(onOrpcError)], - }); - - // Mount ORPC handler on /orpc and all subpaths - app.use("/orpc", async (req, res, next) => { - const { matched } = await orpcHandler.handle(req, res, { - prefix: "/orpc", - context: { ...context, headers: req.headers }, - }); - if (matched) return; - next(); - }); - - // SPA fallback (optional, only for non-orpc routes) - if (serveStatic) { - app.use((req, res, next) => { - if (!req.path.startsWith("/orpc")) { - res.sendFile(path.join(staticDir, "index.html")); - } else { - next(); - } - }); - } - - // Create HTTP server - const httpServer = http.createServer(app); - - // oRPC WebSocket handler - const wsServer = new WebSocketServer({ server: httpServer, path: "/orpc/ws" }); - const orpcWsHandler = new ORPCWebSocketServerHandler(orpcRouter, { - interceptors: [onError(onOrpcError)], - }); - wsServer.on("connection", (ws, req) => { - const headers = extractWsHeaders(req); - void orpcWsHandler.upgrade(ws, { context: { ...context, headers } }); - }); - - // Start listening - await new Promise((resolve) => { - httpServer.listen(port, host, () => resolve()); - }); - - // Get actual port (useful when port=0) - const address = httpServer.address(); - if (!address || typeof address === "string") { - throw new Error("Failed to get server address"); - } - const actualPort = address.port; - - return { - httpServer, - wsServer, - app, - port: actualPort, - baseUrl: `http://${host}:${actualPort}`, - wsUrl: `ws://${host}:${actualPort}/orpc/ws`, - close: async () => { - // Close WebSocket server first - wsServer.close(); - // Then close HTTP server - await new Promise((resolve, reject) => { - httpServer.close((err) => (err ? reject(err) : resolve())); - }); - }, - }; -} diff --git a/src/cli/server.test.ts b/src/cli/server.test.ts deleted file mode 100644 index 1513d8123..000000000 --- a/src/cli/server.test.ts +++ /dev/null @@ -1,329 +0,0 @@ -/** - * Integration tests for the oRPC server endpoints (HTTP and WebSocket). - * - * These tests verify that: - * 1. HTTP endpoint (/orpc) handles RPC calls correctly - * 2. WebSocket endpoint (/orpc/ws) handles RPC calls correctly - * 3. Streaming (eventIterator) works over both transports - * - * Uses bun:test for proper module isolation. - * Tests the actual createOrpcServer function from orpcServer.ts. - */ -import { describe, test, expect, beforeAll, afterAll } from "bun:test"; -import * as os from "os"; -import * as path from "path"; -import * as fs from "fs/promises"; -import { WebSocket } from "ws"; -import { RPCLink as HTTPRPCLink } from "@orpc/client/fetch"; -import { RPCLink as WebSocketRPCLink } from "@orpc/client/websocket"; -import { createORPCClient } from "@orpc/client"; -import type { BrowserWindow, WebContents } from "electron"; - -import { type AppRouter } from "@/node/orpc/router"; -import type { ORPCContext } from "@/node/orpc/context"; -import { Config } from "@/node/config"; -import { ServiceContainer } from "@/node/services/serviceContainer"; -import type { RouterClient } from "@orpc/server"; -import { createOrpcServer, type OrpcServer } from "./orpcServer"; - -// --- Test Server Factory --- - -interface TestServerHandle { - server: OrpcServer; - tempDir: string; - close: () => Promise; -} - -/** - * Create a test server using the actual createOrpcServer function. - * Sets up services and config in a temp directory. - */ -async function createTestServer(): Promise { - // Create temp dir for config - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-server-test-")); - const config = new Config(tempDir); - - // Mock BrowserWindow - const mockWindow: BrowserWindow = { - isDestroyed: () => false, - setTitle: () => undefined, - webContents: { - send: () => undefined, - openDevTools: () => undefined, - } as unknown as WebContents, - } as unknown as BrowserWindow; - - // Initialize services - const services = new ServiceContainer(config); - await services.initialize(); - services.windowService.setMainWindow(mockWindow); - - // Build context - const context: ORPCContext = { - projectService: services.projectService, - workspaceService: services.workspaceService, - providerService: services.providerService, - terminalService: services.terminalService, - windowService: services.windowService, - updateService: services.updateService, - tokenizerService: services.tokenizerService, - serverService: services.serverService, - }; - - // Use the actual createOrpcServer function - const server = await createOrpcServer({ - context, - // port 0 = random available port - onOrpcError: () => undefined, // Silence errors in tests - }); - - return { - server, - tempDir, - close: async () => { - await server.close(); - // Cleanup temp directory - await fs.rm(tempDir, { recursive: true, force: true }).catch(() => undefined); - }, - }; -} - -// --- HTTP Client Factory --- - -function createHttpClient(baseUrl: string): RouterClient { - const link = new HTTPRPCLink({ - url: `${baseUrl}/orpc`, - }); - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion -- needed for tsgo typecheck - return createORPCClient(link) as RouterClient; -} - -// --- WebSocket Client Factory --- - -interface WebSocketClientHandle { - client: RouterClient; - close: () => void; -} - -async function createWebSocketClient(wsUrl: string): Promise { - const ws = new WebSocket(wsUrl); - - // Wait for connection to open - await new Promise((resolve, reject) => { - ws.on("open", () => resolve()); - ws.on("error", reject); - }); - - const link = new WebSocketRPCLink({ websocket: ws as unknown as globalThis.WebSocket }); - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion -- needed for tsgo typecheck - const client = createORPCClient(link) as RouterClient; - - return { - client, - close: () => ws.close(), - }; -} - -// --- Tests --- - -describe("oRPC Server Endpoints", () => { - let serverHandle: TestServerHandle; - - beforeAll(async () => { - serverHandle = await createTestServer(); - }); - - afterAll(async () => { - await serverHandle.close(); - }); - - describe("Health and Version endpoints", () => { - test("GET /health returns ok status", async () => { - const response = await fetch(`${serverHandle.server.baseUrl}/health`); - expect(response.ok).toBe(true); - const data = (await response.json()) as { status: string }; - expect(data).toEqual({ status: "ok" }); - }); - - test("GET /version returns version info with server mode", async () => { - const response = await fetch(`${serverHandle.server.baseUrl}/version`); - expect(response.ok).toBe(true); - const data = (await response.json()) as { - mode: string; - git_commit: string; - git_describe: string; - }; - expect(data.mode).toBe("server"); - // VERSION object should have these fields (from src/version.ts) - expect(typeof data.git_commit).toBe("string"); - expect(typeof data.git_describe).toBe("string"); - }); - }); - - describe("HTTP endpoint (/orpc)", () => { - test("ping returns pong response", async () => { - const client = createHttpClient(serverHandle.server.baseUrl); - const result = await client.general.ping("hello"); - expect(result).toBe("Pong: hello"); - }); - - test("ping with empty string", async () => { - const client = createHttpClient(serverHandle.server.baseUrl); - const result = await client.general.ping(""); - expect(result).toBe("Pong: "); - }); - - test("tick streaming emits correct number of events", async () => { - const client = createHttpClient(serverHandle.server.baseUrl); - const ticks: Array<{ tick: number; timestamp: number }> = []; - - const stream = await client.general.tick({ count: 3, intervalMs: 50 }); - for await (const tick of stream) { - ticks.push(tick); - } - - expect(ticks).toHaveLength(3); - expect(ticks.map((t) => t.tick)).toEqual([1, 2, 3]); - - // Verify timestamps are increasing - for (let i = 1; i < ticks.length; i++) { - expect(ticks[i].timestamp).toBeGreaterThanOrEqual(ticks[i - 1].timestamp); - } - }); - - test("tick streaming with single tick", async () => { - const client = createHttpClient(serverHandle.server.baseUrl); - const ticks: Array<{ tick: number; timestamp: number }> = []; - - const stream = await client.general.tick({ count: 1, intervalMs: 10 }); - for await (const tick of stream) { - ticks.push(tick); - } - - expect(ticks).toHaveLength(1); - expect(ticks[0].tick).toBe(1); - }); - }); - - describe("WebSocket endpoint (/orpc/ws)", () => { - test("ping returns pong response", async () => { - const { client, close } = await createWebSocketClient(serverHandle.server.wsUrl); - try { - const result = await client.general.ping("websocket-test"); - expect(result).toBe("Pong: websocket-test"); - } finally { - close(); - } - }); - - test("ping with special characters", async () => { - const { client, close } = await createWebSocketClient(serverHandle.server.wsUrl); - try { - const result = await client.general.ping("hello 🎉 world!"); - expect(result).toBe("Pong: hello 🎉 world!"); - } finally { - close(); - } - }); - - test("tick streaming emits correct number of events", async () => { - const { client, close } = await createWebSocketClient(serverHandle.server.wsUrl); - try { - const ticks: Array<{ tick: number; timestamp: number }> = []; - - const stream = await client.general.tick({ count: 3, intervalMs: 50 }); - for await (const tick of stream) { - ticks.push(tick); - } - - expect(ticks).toHaveLength(3); - expect(ticks.map((t) => t.tick)).toEqual([1, 2, 3]); - - // Verify timestamps are increasing - for (let i = 1; i < ticks.length; i++) { - expect(ticks[i].timestamp).toBeGreaterThanOrEqual(ticks[i - 1].timestamp); - } - } finally { - close(); - } - }); - - test("tick streaming with longer interval", async () => { - const { client, close } = await createWebSocketClient(serverHandle.server.wsUrl); - try { - const ticks: Array<{ tick: number; timestamp: number }> = []; - const startTime = Date.now(); - - const stream = await client.general.tick({ count: 2, intervalMs: 100 }); - for await (const tick of stream) { - ticks.push(tick); - } - - const elapsed = Date.now() - startTime; - - expect(ticks).toHaveLength(2); - // Should take at least 100ms (1 interval between 2 ticks) - expect(elapsed).toBeGreaterThanOrEqual(90); // Allow small margin - } finally { - close(); - } - }); - - test("multiple sequential requests on same connection", async () => { - const { client, close } = await createWebSocketClient(serverHandle.server.wsUrl); - try { - const result1 = await client.general.ping("first"); - const result2 = await client.general.ping("second"); - const result3 = await client.general.ping("third"); - - expect(result1).toBe("Pong: first"); - expect(result2).toBe("Pong: second"); - expect(result3).toBe("Pong: third"); - } finally { - close(); - } - }); - }); - - describe("Cross-transport consistency", () => { - test("HTTP and WebSocket return same ping result", async () => { - const httpClient = createHttpClient(serverHandle.server.baseUrl); - const { client: wsClient, close } = await createWebSocketClient(serverHandle.server.wsUrl); - - try { - const testInput = "consistency-test"; - const httpResult = await httpClient.general.ping(testInput); - const wsResult = await wsClient.general.ping(testInput); - - expect(httpResult).toBe(wsResult); - } finally { - close(); - } - }); - - test("HTTP and WebSocket streaming produce same tick sequence", async () => { - const httpClient = createHttpClient(serverHandle.server.baseUrl); - const { client: wsClient, close } = await createWebSocketClient(serverHandle.server.wsUrl); - - try { - const httpTicks: number[] = []; - const wsTicks: number[] = []; - - const httpStream = await httpClient.general.tick({ count: 3, intervalMs: 10 }); - for await (const tick of httpStream) { - httpTicks.push(tick.tick); - } - - const wsStream = await wsClient.general.tick({ count: 3, intervalMs: 10 }); - for await (const tick of wsStream) { - wsTicks.push(tick.tick); - } - - expect(httpTicks).toEqual(wsTicks); - expect(httpTicks).toEqual([1, 2, 3]); - } finally { - close(); - } - }); - }); -}); diff --git a/src/cli/server.ts b/src/cli/server.ts index 4eed057af..e6e94cae5 100644 --- a/src/cli/server.ts +++ b/src/cli/server.ts @@ -1,20 +1,28 @@ /** - * CLI entry point for the mux oRPC server. - * Uses createOrpcServer from ./orpcServer.ts for the actual server logic. + * HTTP/WebSocket Server for mux + * Allows accessing mux backend from mobile devices */ import { Config } from "@/node/config"; -import { ServiceContainer } from "@/node/services/serviceContainer"; +import { IPC_CHANNELS, getChatChannel } from "@/common/constants/ipc-constants"; +import { IpcMain } from "@/node/services/ipcMain"; import { migrateLegacyMuxHome } from "@/common/constants/paths"; -import type { BrowserWindow } from "electron"; +import cors from "cors"; +import type { BrowserWindow, IpcMain as ElectronIpcMain } from "electron"; +import express from "express"; +import * as http from "http"; +import * as path from "path"; +import type { RawData } from "ws"; +import { WebSocket, WebSocketServer } from "ws"; import { Command } from "commander"; +import { z } from "zod"; +import { VERSION } from "@/version"; +import { createAuthMiddleware, isWsAuthorized } from "@/server/auth"; import { validateProjectPath } from "@/node/utils/pathUtils"; -import { createOrpcServer } from "./orpcServer"; -import type { ORPCContext } from "@/node/orpc/context"; const program = new Command(); program .name("mux-server") - .description("HTTP/WebSocket ORPC server for mux") + .description("HTTP/WebSocket server for mux - allows accessing mux backend from mobile devices") .option("-h, --host ", "bind to specific host", "localhost") .option("-p, --port ", "bind to specific port", "3000") .option("--auth-token ", "optional bearer token for HTTP/WS auth") @@ -31,96 +39,313 @@ const ADD_PROJECT_PATH = options.addProject as string | undefined; // Track the launch project path for initial navigation let launchProjectPath: string | null = null; -// Minimal BrowserWindow stub for services that expect one -const mockWindow: BrowserWindow = { - isDestroyed: () => false, - setTitle: () => undefined, - webContents: { - send: () => undefined, - openDevTools: () => undefined, - }, -} as unknown as BrowserWindow; +class HttpIpcMainAdapter { + private handlers = new Map Promise>(); + private listeners = new Map void>>(); + + constructor(private readonly app: express.Application) {} + + getHandler( + channel: string + ): ((event: unknown, ...args: unknown[]) => Promise) | undefined { + return this.handlers.get(channel); + } + + handle(channel: string, handler: (event: unknown, ...args: unknown[]) => Promise): void { + this.handlers.set(channel, handler); + + this.app.post(`/ipc/${encodeURIComponent(channel)}`, async (req, res) => { + try { + const schema = z.object({ args: z.array(z.unknown()).optional() }); + const body = schema.parse(req.body); + const args: unknown[] = body.args ?? []; + const result = await handler(null, ...args); + + if ( + result && + typeof result === "object" && + "success" in result && + result.success === false + ) { + res.json(result); + return; + } + + res.json({ success: true, data: result }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`Error in handler ${channel}:`, error); + res.json({ success: false, error: message }); + } + }); + } + + on(channel: string, handler: (event: unknown, ...args: unknown[]) => void): void { + if (!this.listeners.has(channel)) { + this.listeners.set(channel, []); + } + this.listeners.get(channel)!.push(handler); + } + + send(channel: string, ...args: unknown[]): void { + const handlers = this.listeners.get(channel); + if (handlers) { + handlers.forEach((handler) => handler(null, ...args)); + } + } +} + +interface ClientSubscriptions { + chatSubscriptions: Set; + metadataSubscription: boolean; + activitySubscription: boolean; +} + +class MockBrowserWindow { + constructor(private readonly clients: Map) {} + + webContents = { + send: (channel: string, ...args: unknown[]) => { + const message = JSON.stringify({ channel, args }); + this.clients.forEach((clientInfo, client) => { + if (client.readyState !== WebSocket.OPEN) { + return; + } + + if (channel === IPC_CHANNELS.WORKSPACE_METADATA && clientInfo.metadataSubscription) { + client.send(message); + } else if (channel === IPC_CHANNELS.WORKSPACE_ACTIVITY && clientInfo.activitySubscription) { + client.send(message); + } else if (channel.startsWith(IPC_CHANNELS.WORKSPACE_CHAT_PREFIX)) { + const workspaceId = channel.replace(IPC_CHANNELS.WORKSPACE_CHAT_PREFIX, ""); + if (clientInfo.chatSubscriptions.has(workspaceId)) { + client.send(message); + } + } else { + client.send(message); + } + }); + }, + }; +} + +const app = express(); +app.use(cors()); +app.use(express.json({ limit: "50mb" })); + +const clients = new Map(); +const mockWindow = new MockBrowserWindow(clients); +const httpIpcMain = new HttpIpcMainAdapter(app); + +function rawDataToString(rawData: RawData): string { + if (typeof rawData === "string") { + return rawData; + } + if (Array.isArray(rawData)) { + return Buffer.concat(rawData).toString("utf-8"); + } + if (rawData instanceof ArrayBuffer) { + return Buffer.from(rawData).toString("utf-8"); + } + return (rawData as Buffer).toString("utf-8"); +} (async () => { migrateLegacyMuxHome(); const config = new Config(); - const serviceContainer = new ServiceContainer(config); - await serviceContainer.initialize(); - serviceContainer.windowService.setMainWindow(mockWindow); + const ipcMainService = new IpcMain(config); + await ipcMainService.initialize(); + + if (AUTH_TOKEN) { + app.use("/ipc", createAuthMiddleware({ token: AUTH_TOKEN })); + } + + httpIpcMain.handle("server:getLaunchProject", () => { + return Promise.resolve(launchProjectPath); + }); + + ipcMainService.register( + httpIpcMain as unknown as ElectronIpcMain, + mockWindow as unknown as BrowserWindow + ); if (ADD_PROJECT_PATH) { - await initializeProjectDirect(ADD_PROJECT_PATH, serviceContainer); + void initializeProject(ADD_PROJECT_PATH, httpIpcMain); } - // Set launch project path for clients - serviceContainer.serverService.setLaunchProject(launchProjectPath); - - // Build oRPC context from services - const context: ORPCContext = { - projectService: serviceContainer.projectService, - workspaceService: serviceContainer.workspaceService, - providerService: serviceContainer.providerService, - terminalService: serviceContainer.terminalService, - windowService: serviceContainer.windowService, - updateService: serviceContainer.updateService, - tokenizerService: serviceContainer.tokenizerService, - serverService: serviceContainer.serverService, - }; + app.use(express.static(path.join(__dirname, ".."))); - const server = await createOrpcServer({ - host: HOST, - port: PORT, - authToken: AUTH_TOKEN, - context, - serveStatic: true, + app.get("/health", (_req, res) => { + res.json({ status: "ok" }); }); - console.log(`Server is running on ${server.baseUrl}`); -})().catch((error) => { - console.error("Failed to initialize server:", error); - process.exit(1); -}); + app.get("/version", (_req, res) => { + res.json({ ...VERSION, mode: "server" }); + }); -async function initializeProjectDirect( - projectPath: string, - serviceContainer: ServiceContainer -): Promise { - try { - let normalizedPath = projectPath.replace(/\/+$/, ""); - const validation = await validateProjectPath(normalizedPath); - if (!validation.valid || !validation.expandedPath) { - console.error( - `Invalid project path provided via --add-project: ${validation.error ?? "unknown error"}` - ); - return; + app.use((req, res, next) => { + if (!req.path.startsWith("/ipc") && !req.path.startsWith("/ws")) { + res.sendFile(path.join(__dirname, "..", "index.html")); + } else { + next(); } - normalizedPath = validation.expandedPath; + }); - const projects = serviceContainer.projectService.list(); - const alreadyExists = Array.isArray(projects) - ? projects.some(([path]) => path === normalizedPath) - : false; + const server = http.createServer(app); + const wss = new WebSocketServer({ server, path: "/ws" }); - if (alreadyExists) { - console.log(`Project already exists: ${normalizedPath}`); - launchProjectPath = normalizedPath; - return; - } + async function initializeProject( + projectPath: string, + ipcAdapter: HttpIpcMainAdapter + ): Promise { + try { + // Normalize path so project metadata matches desktop behavior + let normalizedPath = projectPath.replace(/\/+$/, ""); + const validation = await validateProjectPath(normalizedPath); + if (!validation.valid || !validation.expandedPath) { + console.error( + `Invalid project path provided via --add-project: ${validation.error ?? "unknown error"}` + ); + return; + } + normalizedPath = validation.expandedPath; + + const listHandler = ipcAdapter.getHandler(IPC_CHANNELS.PROJECT_LIST); + if (!listHandler) { + console.error("PROJECT_LIST handler not found; cannot initialize project"); + return; + } + const projects = (await listHandler(null)) as Array<[string, unknown]> | undefined; + const alreadyExists = Array.isArray(projects) + ? projects.some(([path]) => path === normalizedPath) + : false; + + if (alreadyExists) { + console.log(`Project already exists: ${normalizedPath}`); + launchProjectPath = normalizedPath; + return; + } + + console.log(`Creating project via --add-project: ${normalizedPath}`); + const createHandler = ipcAdapter.getHandler(IPC_CHANNELS.PROJECT_CREATE); + if (!createHandler) { + console.error("PROJECT_CREATE handler not found; cannot add project"); + return; + } + const result = (await createHandler(null, normalizedPath)) as { + success?: boolean; + error?: unknown; + } | void; + if (result && typeof result === "object" && "success" in result) { + if (result.success) { + console.log(`Project created at ${normalizedPath}`); + launchProjectPath = normalizedPath; + return; + } + const errorMsg = + result.error instanceof Error + ? result.error.message + : typeof result.error === "string" + ? result.error + : JSON.stringify(result.error ?? "unknown error"); + console.error(`Failed to create project at ${normalizedPath}: ${errorMsg}`); + return; + } - console.log(`Creating project via --add-project: ${normalizedPath}`); - const result = await serviceContainer.projectService.create(normalizedPath); - if (result.success) { console.log(`Project created at ${normalizedPath}`); launchProjectPath = normalizedPath; - } else { - const errorMsg = - typeof result.error === "string" - ? result.error - : JSON.stringify(result.error ?? "unknown error"); - console.error(`Failed to create project at ${normalizedPath}: ${errorMsg}`); + } catch (error) { + console.error(`initializeProject failed for ${projectPath}:`, error); } - } catch (error) { - console.error(`initializeProject failed for ${projectPath}:`, error); } -} + + wss.on("connection", (ws, req) => { + if (!isWsAuthorized(req, { token: AUTH_TOKEN })) { + ws.close(1008, "Unauthorized"); + return; + } + + const clientInfo: ClientSubscriptions = { + chatSubscriptions: new Set(), + metadataSubscription: false, + activitySubscription: false, + }; + clients.set(ws, clientInfo); + + ws.on("message", (rawData: RawData) => { + try { + const payload = rawDataToString(rawData); + const message = JSON.parse(payload) as { + type: string; + channel: string; + workspaceId?: string; + }; + const { type, channel, workspaceId } = message; + + if (type === "subscribe") { + if (channel === "workspace:chat" && workspaceId) { + clientInfo.chatSubscriptions.add(workspaceId); + + // Replay history only to this specific WebSocket client (no broadcast) + // The broadcast httpIpcMain.send() was designed for Electron's single-renderer model + // and causes duplicate history + cross-client pollution in multi-client WebSocket mode + void (async () => { + const replayHandler = httpIpcMain.getHandler( + IPC_CHANNELS.WORKSPACE_CHAT_GET_FULL_REPLAY + ); + if (!replayHandler) { + return; + } + try { + const events = (await replayHandler(null, workspaceId)) as unknown[]; + const chatChannel = getChatChannel(workspaceId); + for (const event of events) { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ channel: chatChannel, args: [event] })); + } + } + } catch (error) { + console.error(`Failed to replay history for workspace ${workspaceId}:`, error); + } + })(); + } else if (channel === "workspace:metadata") { + clientInfo.metadataSubscription = true; + httpIpcMain.send(IPC_CHANNELS.WORKSPACE_METADATA_SUBSCRIBE); + } else if (channel === "workspace:activity") { + clientInfo.activitySubscription = true; + httpIpcMain.send(IPC_CHANNELS.WORKSPACE_ACTIVITY_SUBSCRIBE); + } + } else if (type === "unsubscribe") { + if (channel === "workspace:chat" && workspaceId) { + clientInfo.chatSubscriptions.delete(workspaceId); + httpIpcMain.send("workspace:chat:unsubscribe", workspaceId); + } else if (channel === "workspace:metadata") { + clientInfo.metadataSubscription = false; + httpIpcMain.send(IPC_CHANNELS.WORKSPACE_METADATA_UNSUBSCRIBE); + } else if (channel === "workspace:activity") { + clientInfo.activitySubscription = false; + httpIpcMain.send(IPC_CHANNELS.WORKSPACE_ACTIVITY_UNSUBSCRIBE); + } + } + } catch (error) { + console.error("Error handling WebSocket message:", error); + } + }); + + ws.on("close", () => { + clients.delete(ws); + }); + + ws.on("error", (error) => { + console.error("WebSocket error:", error); + }); + }); + + server.listen(PORT, HOST, () => { + console.log(`Server is running on http://${HOST}:${PORT}`); + }); +})().catch((error) => { + console.error("Failed to initialize server:", error); + process.exit(1); +}); diff --git a/src/common/constants/events.ts b/src/common/constants/events.ts index 8b91b7433..ccbd59211 100644 --- a/src/common/constants/events.ts +++ b/src/common/constants/events.ts @@ -6,7 +6,7 @@ */ import type { ThinkingLevel } from "@/common/types/thinking"; -import type { ImagePart } from "@/common/orpc/schemas"; +import type { ImagePart } from "../types/ipc"; export const CUSTOM_EVENTS = { /** diff --git a/src/common/constants/ipc-constants.ts b/src/common/constants/ipc-constants.ts new file mode 100644 index 000000000..828797a31 --- /dev/null +++ b/src/common/constants/ipc-constants.ts @@ -0,0 +1,81 @@ +/** + * IPC Channel Constants - Shared between main and preload processes + * This file contains only constants and helper functions, no Electron-specific code + */ + +export const IPC_CHANNELS = { + // Provider channels + PROVIDERS_SET_CONFIG: "providers:setConfig", + PROVIDERS_SET_MODELS: "providers:setModels", + PROVIDERS_GET_CONFIG: "providers:getConfig", + PROVIDERS_LIST: "providers:list", + + // Project channels + PROJECT_PICK_DIRECTORY: "project:pickDirectory", + PROJECT_CREATE: "project:create", + PROJECT_REMOVE: "project:remove", + PROJECT_LIST: "project:list", + PROJECT_LIST_BRANCHES: "project:listBranches", + PROJECT_SECRETS_GET: "project:secrets:get", + FS_LIST_DIRECTORY: "fs:listDirectory", + PROJECT_SECRETS_UPDATE: "project:secrets:update", + + // Workspace channels + WORKSPACE_LIST: "workspace:list", + WORKSPACE_CREATE: "workspace:create", + WORKSPACE_REMOVE: "workspace:remove", + WORKSPACE_RENAME: "workspace:rename", + WORKSPACE_FORK: "workspace:fork", + WORKSPACE_SEND_MESSAGE: "workspace:sendMessage", + WORKSPACE_RESUME_STREAM: "workspace:resumeStream", + WORKSPACE_INTERRUPT_STREAM: "workspace:interruptStream", + WORKSPACE_CLEAR_QUEUE: "workspace:clearQueue", + WORKSPACE_TRUNCATE_HISTORY: "workspace:truncateHistory", + WORKSPACE_REPLACE_HISTORY: "workspace:replaceHistory", + WORKSPACE_STREAM_HISTORY: "workspace:streamHistory", + WORKSPACE_GET_INFO: "workspace:getInfo", + WORKSPACE_EXECUTE_BASH: "workspace:executeBash", + WORKSPACE_OPEN_TERMINAL: "workspace:openTerminal", + WORKSPACE_CHAT_GET_HISTORY: "workspace:chat:getHistory", + WORKSPACE_CHAT_GET_FULL_REPLAY: "workspace:chat:getFullReplay", + + // Terminal channels + TERMINAL_CREATE: "terminal:create", + TERMINAL_CLOSE: "terminal:close", + TERMINAL_RESIZE: "terminal:resize", + TERMINAL_INPUT: "terminal:input", + TERMINAL_WINDOW_OPEN: "terminal:window:open", + TERMINAL_WINDOW_CLOSE: "terminal:window:close", + + // Window channels + WINDOW_SET_TITLE: "window:setTitle", + + // Debug channels (for testing only) + DEBUG_TRIGGER_STREAM_ERROR: "debug:triggerStreamError", + + // Update channels + UPDATE_CHECK: "update:check", + UPDATE_DOWNLOAD: "update:download", + UPDATE_INSTALL: "update:install", + UPDATE_STATUS: "update:status", + UPDATE_STATUS_SUBSCRIBE: "update:status:subscribe", + + // Tokenizer channels + TOKENIZER_CALCULATE_STATS: "tokenizer:calculateStats", + TOKENIZER_COUNT_TOKENS: "tokenizer:countTokens", + TOKENIZER_COUNT_TOKENS_BATCH: "tokenizer:countTokensBatch", + + // Dynamic channel prefixes + WORKSPACE_CHAT_PREFIX: "workspace:chat:", + WORKSPACE_METADATA: "workspace:metadata", + WORKSPACE_METADATA_SUBSCRIBE: "workspace:metadata:subscribe", + WORKSPACE_METADATA_UNSUBSCRIBE: "workspace:metadata:unsubscribe", + WORKSPACE_ACTIVITY: "workspace:activity", + WORKSPACE_ACTIVITY_SUBSCRIBE: "workspace:activity:subscribe", + WORKSPACE_ACTIVITY_UNSUBSCRIBE: "workspace:activity:unsubscribe", + WORKSPACE_ACTIVITY_LIST: "workspace:activity:list", +} as const; + +// Helper functions for dynamic channels +export const getChatChannel = (workspaceId: string): string => + `${IPC_CHANNELS.WORKSPACE_CHAT_PREFIX}${workspaceId}`; diff --git a/src/common/orpc/client.ts b/src/common/orpc/client.ts deleted file mode 100644 index a0eacfa26..000000000 --- a/src/common/orpc/client.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { createORPCClient } from "@orpc/client"; -import type { ClientContext, ClientLink } from "@orpc/client"; -import type { AppRouter } from "@/node/orpc/router"; -import type { RouterClient } from "@orpc/server"; - -export function createClient(link: ClientLink): RouterClient { - return createORPCClient(link); -} diff --git a/src/common/orpc/schemas.ts b/src/common/orpc/schemas.ts deleted file mode 100644 index fdaa244f0..000000000 --- a/src/common/orpc/schemas.ts +++ /dev/null @@ -1,104 +0,0 @@ -// Re-export all schemas from subdirectory modules -// This file serves as the single entry point for all schema imports - -// Result helper -export { ResultSchema } from "./schemas/result"; - -// Runtime schemas -export { RuntimeConfigSchema, RuntimeModeSchema } from "./schemas/runtime"; - -// Project schemas -export { ProjectConfigSchema, WorkspaceConfigSchema } from "./schemas/project"; - -// Workspace schemas -export { - FrontendWorkspaceMetadataSchema, - GitStatusSchema, - WorkspaceActivitySnapshotSchema, - WorkspaceMetadataSchema, -} from "./schemas/workspace"; - -// Chat stats schemas -export { - ChatStatsSchema, - ChatUsageComponentSchema, - ChatUsageDisplaySchema, - TokenConsumerSchema, -} from "./schemas/chatStats"; - -// Error schemas -export { SendMessageErrorSchema, StreamErrorTypeSchema } from "./schemas/errors"; - -// Tool schemas -export { BashToolResultSchema, FileTreeNodeSchema } from "./schemas/tools"; - -// Secrets schemas -export { SecretSchema } from "./schemas/secrets"; - -// Provider options schemas -export { MuxProviderOptionsSchema } from "./schemas/providerOptions"; - -// Terminal schemas -export { - TerminalCreateParamsSchema, - TerminalResizeParamsSchema, - TerminalSessionSchema, -} from "./schemas/terminal"; - -// Message schemas -export { - BranchListResultSchema, - DynamicToolPartAvailableSchema, - DynamicToolPartPendingSchema, - DynamicToolPartSchema, - ImagePartSchema, - MuxImagePartSchema, - MuxMessageSchema, - MuxReasoningPartSchema, - MuxTextPartSchema, - MuxToolPartSchema, -} from "./schemas/message"; -export type { ImagePart, MuxImagePart } from "./schemas/message"; - -// Stream event schemas -export { - CaughtUpMessageSchema, - CompletedMessagePartSchema, - DeleteMessageSchema, - ErrorEventSchema, - LanguageModelV2UsageSchema, - QueuedMessageChangedEventSchema, - ReasoningDeltaEventSchema, - ReasoningEndEventSchema, - ReasoningStartEventSchema, - RestoreToInputEventSchema, - SendMessageOptionsSchema, - StreamAbortEventSchema, - StreamDeltaEventSchema, - StreamEndEventSchema, - StreamErrorMessageSchema, - StreamStartEventSchema, - ToolCallDeltaEventSchema, - ToolCallEndEventSchema, - ToolCallStartEventSchema, - UpdateStatusSchema, - UsageDeltaEventSchema, - WorkspaceChatMessageSchema, - WorkspaceInitEventSchema, -} from "./schemas/stream"; - -// API router schemas -export { - general, - projects, - ProviderConfigInfoSchema, - providers, - ProvidersConfigMapSchema, - server, - terminal, - tokenizer, - update, - window, - workspace, -} from "./schemas/api"; -export type { WorkspaceSendMessageOutput } from "./schemas/api"; diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts deleted file mode 100644 index d08f1258f..000000000 --- a/src/common/orpc/schemas/api.ts +++ /dev/null @@ -1,371 +0,0 @@ -import { eventIterator } from "@orpc/server"; -import { z } from "zod"; -import { ChatStatsSchema } from "./chatStats"; -import { SendMessageErrorSchema } from "./errors"; -import { BranchListResultSchema, ImagePartSchema, MuxMessageSchema } from "./message"; -import { ProjectConfigSchema } from "./project"; -import { ResultSchema } from "./result"; -import { RuntimeConfigSchema } from "./runtime"; -import { SecretSchema } from "./secrets"; -import { SendMessageOptionsSchema, UpdateStatusSchema, WorkspaceChatMessageSchema } from "./stream"; -import { - TerminalCreateParamsSchema, - TerminalResizeParamsSchema, - TerminalSessionSchema, -} from "./terminal"; -import { BashToolResultSchema, FileTreeNodeSchema } from "./tools"; -import { - FrontendWorkspaceMetadataSchema, - WorkspaceActivitySnapshotSchema, - WorkspaceMetadataSchema, -} from "./workspace"; - -// --- API Router Schemas --- - -// Tokenizer -export const tokenizer = { - countTokens: { - input: z.object({ model: z.string(), text: z.string() }), - output: z.number(), - }, - countTokensBatch: { - input: z.object({ model: z.string(), texts: z.array(z.string()) }), - output: z.array(z.number()), - }, - calculateStats: { - input: z.object({ messages: z.array(MuxMessageSchema), model: z.string() }), - output: ChatStatsSchema, - }, -}; - -// Providers -export const ProviderConfigInfoSchema = z.object({ - apiKeySet: z.boolean(), - baseUrl: z.string().optional(), - models: z.array(z.string()).optional(), -}); - -export const ProvidersConfigMapSchema = z.record(z.string(), ProviderConfigInfoSchema); - -export const providers = { - setProviderConfig: { - input: z.object({ - provider: z.string(), - keyPath: z.array(z.string()), - value: z.string(), - }), - output: ResultSchema(z.void(), z.string()), - }, - getConfig: { - input: z.void(), - output: ProvidersConfigMapSchema, - }, - setModels: { - input: z.object({ - provider: z.string(), - models: z.array(z.string()), - }), - output: ResultSchema(z.void(), z.string()), - }, - list: { - input: z.void(), - output: z.array(z.string()), - }, -}; - -// Projects -export const projects = { - create: { - input: z.object({ projectPath: z.string() }), - output: ResultSchema( - z.object({ - projectConfig: ProjectConfigSchema, - normalizedPath: z.string(), - }), - z.string() - ), - }, - pickDirectory: { - input: z.void(), - output: z.string().nullable(), - }, - remove: { - input: z.object({ projectPath: z.string() }), - output: ResultSchema(z.void(), z.string()), - }, - list: { - input: z.void(), - output: z.array(z.tuple([z.string(), ProjectConfigSchema])), - }, - listBranches: { - input: z.object({ projectPath: z.string() }), - output: BranchListResultSchema, - }, - secrets: { - get: { - input: z.object({ projectPath: z.string() }), - output: z.array(SecretSchema), - }, - update: { - input: z.object({ - projectPath: z.string(), - secrets: z.array(SecretSchema), - }), - output: ResultSchema(z.void(), z.string()), - }, - }, -}; - -// Workspace -export const workspace = { - list: { - input: z.void(), - output: z.array(FrontendWorkspaceMetadataSchema), - }, - create: { - input: z.object({ - projectPath: z.string(), - branchName: z.string(), - trunkBranch: z.string(), - runtimeConfig: RuntimeConfigSchema.optional(), - }), - output: z.union([ - z.object({ success: z.literal(true), metadata: FrontendWorkspaceMetadataSchema }), - z.object({ success: z.literal(false), error: z.string() }), - ]), - }, - remove: { - input: z.object({ - workspaceId: z.string(), - options: z.object({ force: z.boolean().optional() }).optional(), - }), - output: z.object({ success: z.boolean(), error: z.string().optional() }), - }, - rename: { - input: z.object({ workspaceId: z.string(), newName: z.string() }), - output: ResultSchema(z.object({ newWorkspaceId: z.string() }), z.string()), - }, - fork: { - input: z.object({ sourceWorkspaceId: z.string(), newName: z.string() }), - output: z.union([ - z.object({ - success: z.literal(true), - metadata: WorkspaceMetadataSchema, - projectPath: z.string(), - }), - z.object({ success: z.literal(false), error: z.string() }), - ]), - }, - sendMessage: { - input: z.object({ - workspaceId: z.string().nullable(), - message: z.string(), - options: SendMessageOptionsSchema.extend({ - imageParts: z.array(ImagePartSchema).optional(), - runtimeConfig: RuntimeConfigSchema.optional(), - projectPath: z.string().optional(), - trunkBranch: z.string().optional(), - }).optional(), - }), - output: ResultSchema( - z.object({ - workspaceId: z.string().optional(), - metadata: FrontendWorkspaceMetadataSchema.optional(), - }), - SendMessageErrorSchema - ), - }, - resumeStream: { - input: z.object({ - workspaceId: z.string(), - options: SendMessageOptionsSchema, - }), - output: ResultSchema(z.void(), SendMessageErrorSchema), - }, - interruptStream: { - input: z.object({ - workspaceId: z.string(), - options: z - .object({ - soft: z.boolean().optional(), - abandonPartial: z.boolean().optional(), - }) - .optional(), - }), - output: ResultSchema(z.void(), z.string()), - }, - clearQueue: { - input: z.object({ workspaceId: z.string() }), - output: ResultSchema(z.void(), z.string()), - }, - truncateHistory: { - input: z.object({ - workspaceId: z.string(), - percentage: z.number().optional(), - }), - output: ResultSchema(z.void(), z.string()), - }, - replaceChatHistory: { - input: z.object({ - workspaceId: z.string(), - summaryMessage: MuxMessageSchema, - }), - output: ResultSchema(z.void(), z.string()), - }, - getInfo: { - input: z.object({ workspaceId: z.string() }), - output: FrontendWorkspaceMetadataSchema.nullable(), - }, - getFullReplay: { - input: z.object({ workspaceId: z.string() }), - output: z.array(WorkspaceChatMessageSchema), - }, - executeBash: { - input: z.object({ - workspaceId: z.string(), - script: z.string(), - options: z - .object({ - timeout_secs: z.number().optional(), - niceness: z.number().optional(), - }) - .optional(), - }), - output: ResultSchema(BashToolResultSchema, z.string()), - }, - // Subscriptions - onChat: { - input: z.object({ workspaceId: z.string() }), - output: eventIterator(WorkspaceChatMessageSchema), // Stream event - }, - onMetadata: { - input: z.void(), - output: eventIterator( - z.object({ - workspaceId: z.string(), - metadata: FrontendWorkspaceMetadataSchema.nullable(), - }) - ), - }, - activity: { - list: { - input: z.void(), - output: z.record(z.string(), WorkspaceActivitySnapshotSchema), - }, - subscribe: { - input: z.void(), - output: eventIterator( - z.object({ - workspaceId: z.string(), - activity: WorkspaceActivitySnapshotSchema.nullable(), - }) - ), - }, - }, -}; - -export type WorkspaceSendMessageOutput = z.infer; - -// Window -export const window = { - setTitle: { - input: z.object({ title: z.string() }), - output: z.void(), - }, -}; - -// Terminal -export const terminal = { - create: { - input: TerminalCreateParamsSchema, - output: TerminalSessionSchema, - }, - close: { - input: z.object({ sessionId: z.string() }), - output: z.void(), - }, - resize: { - input: TerminalResizeParamsSchema, - output: z.void(), - }, - sendInput: { - input: z.object({ sessionId: z.string(), data: z.string() }), - output: z.void(), - }, - onOutput: { - input: z.object({ sessionId: z.string() }), - output: eventIterator(z.string()), - }, - onExit: { - input: z.object({ sessionId: z.string() }), - output: eventIterator(z.number()), - }, - openWindow: { - input: z.object({ workspaceId: z.string() }), - output: z.void(), - }, - closeWindow: { - input: z.object({ workspaceId: z.string() }), - output: z.void(), - }, - /** - * Open the native system terminal for a workspace. - * Opens the user's preferred terminal emulator (Ghostty, Terminal.app, etc.) - * with the working directory set to the workspace path. - */ - openNative: { - input: z.object({ workspaceId: z.string() }), - output: z.void(), - }, -}; - -// Server -export const server = { - getLaunchProject: { - input: z.void(), - output: z.string().nullable(), - }, -}; - -// Update -export const update = { - check: { - input: z.void(), - output: z.void(), - }, - download: { - input: z.void(), - output: z.void(), - }, - install: { - input: z.void(), - output: z.void(), - }, - onStatus: { - input: z.void(), - output: eventIterator(UpdateStatusSchema), - }, -}; - -// General -export const general = { - listDirectory: { - input: z.object({ path: z.string() }), - output: ResultSchema(FileTreeNodeSchema), - }, - ping: { - input: z.string(), - output: z.string(), - }, - /** - * Test endpoint: emits numbered ticks at an interval. - * Useful for verifying streaming works over HTTP and WebSocket. - */ - tick: { - input: z.object({ - count: z.number().int().min(1).max(100), - intervalMs: z.number().int().min(10).max(5000), - }), - output: eventIterator(z.object({ tick: z.number(), timestamp: z.number() })), - }, -}; diff --git a/src/common/orpc/schemas/chatStats.ts b/src/common/orpc/schemas/chatStats.ts deleted file mode 100644 index 7c0fb621c..000000000 --- a/src/common/orpc/schemas/chatStats.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { z } from "zod"; - -export const TokenConsumerSchema = z.object({ - name: z.string().meta({ description: '"User", "Assistant", "bash", "readFile", etc.' }), - tokens: z.number().meta({ description: "Total token count for this consumer" }), - percentage: z.number().meta({ description: "% of total tokens" }), - fixedTokens: z - .number() - .optional() - .meta({ description: "Fixed overhead (e.g., tool definitions)" }), - variableTokens: z - .number() - .optional() - .meta({ description: "Variable usage (e.g., actual tool calls, text)" }), -}); - -export const ChatUsageComponentSchema = z.object({ - tokens: z.number(), - cost_usd: z.number().optional(), -}); - -export const ChatUsageDisplaySchema = z.object({ - input: ChatUsageComponentSchema, - cached: ChatUsageComponentSchema, - cacheCreate: ChatUsageComponentSchema, - output: ChatUsageComponentSchema, - reasoning: ChatUsageComponentSchema, - model: z.string().optional(), -}); - -export const ChatStatsSchema = z.object({ - consumers: z.array(TokenConsumerSchema).meta({ description: "Sorted descending by token count" }), - totalTokens: z.number(), - model: z.string(), - tokenizerName: z.string().meta({ description: 'e.g., "o200k_base", "claude"' }), - usageHistory: z - .array(ChatUsageDisplaySchema) - .meta({ description: "Ordered array of actual usage statistics from API responses" }), -}); diff --git a/src/common/orpc/schemas/errors.ts b/src/common/orpc/schemas/errors.ts deleted file mode 100644 index 516929f2d..000000000 --- a/src/common/orpc/schemas/errors.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { z } from "zod"; - -/** - * Discriminated union for all possible sendMessage errors - * The frontend is responsible for language and messaging for api_key_not_found and - * provider_not_supported errors. Other error types include details needed for display. - */ -export const SendMessageErrorSchema = z.discriminatedUnion("type", [ - z.object({ type: z.literal("api_key_not_found"), provider: z.string() }), - z.object({ type: z.literal("provider_not_supported"), provider: z.string() }), - z.object({ type: z.literal("invalid_model_string"), message: z.string() }), - z.object({ type: z.literal("unknown"), raw: z.string() }), -]); - -/** - * Stream error types - categorizes errors during AI streaming - * Used across backend (StreamManager) and frontend (StreamErrorMessage) - */ -export const StreamErrorTypeSchema = z.enum([ - "authentication", // API key issues, 401 errors - "rate_limit", // 429 rate limiting - "server_error", // 5xx server errors - "api", // Generic API errors - "retry_failed", // Retry exhausted - "aborted", // User aborted - "network", // Network/fetch errors - "context_exceeded", // Context length/token limit exceeded - "quota", // Usage quota/billing limits - "model_not_found", // Model does not exist - "unknown", // Catch-all -]); diff --git a/src/common/orpc/schemas/message.ts b/src/common/orpc/schemas/message.ts deleted file mode 100644 index 378c2ec7c..000000000 --- a/src/common/orpc/schemas/message.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { z } from "zod"; -import { ChatUsageDisplaySchema } from "./chatStats"; -import { StreamErrorTypeSchema } from "./errors"; - -export const ImagePartSchema = z.object({ - url: z.string(), - mediaType: z.string(), -}); - -export const MuxTextPartSchema = z.object({ - type: z.literal("text"), - text: z.string(), - timestamp: z.number().optional(), -}); - -export const MuxReasoningPartSchema = z.object({ - type: z.literal("reasoning"), - text: z.string(), - timestamp: z.number().optional(), -}); - -// Discriminated tool part schemas for proper type inference -export const DynamicToolPartAvailableSchema = z.object({ - type: z.literal("dynamic-tool"), - toolCallId: z.string(), - toolName: z.string(), - state: z.literal("output-available"), - input: z.unknown(), - output: z.unknown(), - timestamp: z.number().optional(), -}); - -export const DynamicToolPartPendingSchema = z.object({ - type: z.literal("dynamic-tool"), - toolCallId: z.string(), - toolName: z.string(), - state: z.literal("input-available"), - input: z.unknown(), - timestamp: z.number().optional(), -}); - -export const DynamicToolPartSchema = z.discriminatedUnion("state", [ - DynamicToolPartAvailableSchema, - DynamicToolPartPendingSchema, -]); - -// Alias for backward compatibility - used in message schemas -export const MuxToolPartSchema = z.object({ - type: z.literal("dynamic-tool"), - toolCallId: z.string(), - toolName: z.string(), - state: z.enum(["input-available", "output-available"]), - input: z.unknown(), - output: z.unknown().optional(), - timestamp: z.number().optional(), -}); - -export const MuxImagePartSchema = z.object({ - type: z.literal("file"), - mediaType: z.string(), - url: z.string(), - filename: z.string().optional(), -}); - -// Export types inferred from schemas for reuse across app/test code. -export type ImagePart = z.infer; -export type MuxImagePart = z.infer; - -// MuxMessage (simplified) -export const MuxMessageSchema = z.object({ - id: z.string(), - role: z.enum(["system", "user", "assistant"]), - parts: z.array( - z.discriminatedUnion("type", [ - MuxTextPartSchema, - MuxReasoningPartSchema, - MuxToolPartSchema, - MuxImagePartSchema, - ]) - ), - createdAt: z.date().optional(), - metadata: z - .object({ - historySequence: z.number().optional(), - timestamp: z.number().optional(), - model: z.string().optional(), - usage: z.any().optional(), - providerMetadata: z.record(z.string(), z.unknown()).optional(), - duration: z.number().optional(), - systemMessageTokens: z.number().optional(), - muxMetadata: z.any().optional(), - cmuxMetadata: z.any().optional(), // Legacy field for backward compatibility - compacted: z.boolean().optional(), // Marks compaction summary messages - toolPolicy: z.any().optional(), - mode: z.string().optional(), - partial: z.boolean().optional(), - synthetic: z.boolean().optional(), - error: z.string().optional(), - errorType: StreamErrorTypeSchema.optional(), - historicalUsage: ChatUsageDisplaySchema.optional(), - }) - .optional(), -}); - -export const BranchListResultSchema = z.object({ - branches: z.array(z.string()), - recommendedTrunk: z.string(), -}); diff --git a/src/common/orpc/schemas/project.ts b/src/common/orpc/schemas/project.ts deleted file mode 100644 index 317e2af04..000000000 --- a/src/common/orpc/schemas/project.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { z } from "zod"; -import { RuntimeConfigSchema } from "./runtime"; - -export const WorkspaceConfigSchema = z.object({ - path: z.string().meta({ - description: "Absolute path to workspace directory - REQUIRED for backward compatibility", - }), - id: z.string().optional().meta({ - description: "Stable workspace ID (10 hex chars for new workspaces) - optional for legacy", - }), - name: z.string().optional().meta({ - description: 'Git branch / directory name (e.g., "feature-branch") - optional for legacy', - }), - createdAt: z - .string() - .optional() - .meta({ description: "ISO 8601 creation timestamp - optional for legacy" }), - runtimeConfig: RuntimeConfigSchema.optional().meta({ - description: "Runtime configuration (local vs SSH) - optional, defaults to local", - }), -}); - -export const ProjectConfigSchema = z.object({ - workspaces: z.array(WorkspaceConfigSchema), -}); diff --git a/src/common/orpc/schemas/providerOptions.ts b/src/common/orpc/schemas/providerOptions.ts deleted file mode 100644 index a443d9b69..000000000 --- a/src/common/orpc/schemas/providerOptions.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { z } from "zod"; - -export const MuxProviderOptionsSchema = z.object({ - anthropic: z - .object({ - use1MContext: z.boolean().optional().meta({ - description: "Enable 1M context window (requires beta header)", - }), - }) - .optional(), - openai: z - .object({ - disableAutoTruncation: z - .boolean() - .optional() - .meta({ description: "Disable automatic context truncation (useful for testing)" }), - forceContextLimitError: z.boolean().optional().meta({ - description: "Force context limit error (used in integration tests to simulate overflow)", - }), - simulateToolPolicyNoop: z.boolean().optional().meta({ - description: - "Simulate successful response without executing tools (used in tool policy tests)", - }), - }) - .optional(), - google: z.record(z.string(), z.unknown()).optional(), - ollama: z.record(z.string(), z.unknown()).optional(), - openrouter: z.record(z.string(), z.unknown()).optional(), - xai: z - .object({ - searchParameters: z - .object({ - mode: z.enum(["auto", "off", "on"]), - returnCitations: z.boolean().optional(), - fromDate: z.string().optional(), - toDate: z.string().optional(), - maxSearchResults: z.number().optional(), - sources: z - .array( - z.discriminatedUnion("type", [ - z.object({ - type: z.literal("web"), - country: z.string().optional(), - excludedWebsites: z.array(z.string()).optional(), - allowedWebsites: z.array(z.string()).optional(), - safeSearch: z.boolean().optional(), - }), - z.object({ - type: z.literal("x"), - excludedXHandles: z.array(z.string()).optional(), - includedXHandles: z.array(z.string()).optional(), - postFavoriteCount: z.number().optional(), - postViewCount: z.number().optional(), - xHandles: z.array(z.string()).optional(), - }), - z.object({ - type: z.literal("news"), - country: z.string().optional(), - excludedWebsites: z.array(z.string()).optional(), - safeSearch: z.boolean().optional(), - }), - z.object({ - type: z.literal("rss"), - links: z.array(z.string()), - }), - ]) - ) - .optional(), - }) - .optional(), - }) - .optional(), -}); diff --git a/src/common/orpc/schemas/result.ts b/src/common/orpc/schemas/result.ts deleted file mode 100644 index ccab30cc8..000000000 --- a/src/common/orpc/schemas/result.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { z } from "zod"; - -/** - * Generic Result schema for success/failure discriminated unions - */ -export const ResultSchema = ( - dataSchema: T, - errorSchema: E = z.string() as unknown as E -) => - z.discriminatedUnion("success", [ - z.object({ success: z.literal(true), data: dataSchema }), - z.object({ success: z.literal(false), error: errorSchema }), - ]); diff --git a/src/common/orpc/schemas/runtime.ts b/src/common/orpc/schemas/runtime.ts deleted file mode 100644 index b5cd15291..000000000 --- a/src/common/orpc/schemas/runtime.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { z } from "zod"; - -export const RuntimeModeSchema = z.enum(["local", "ssh"]); - -export const RuntimeConfigSchema = z.discriminatedUnion("type", [ - z.object({ - type: z.literal(RuntimeModeSchema.enum.local), - srcBaseDir: z - .string() - .meta({ description: "Base directory where all workspaces are stored (e.g., ~/.mux/src)" }), - }), - z.object({ - type: z.literal(RuntimeModeSchema.enum.ssh), - host: z - .string() - .meta({ description: "SSH host (can be hostname, user@host, or SSH config alias)" }), - srcBaseDir: z - .string() - .meta({ description: "Base directory on remote host where all workspaces are stored" }), - identityFile: z - .string() - .optional() - .meta({ description: "Path to SSH private key (if not using ~/.ssh/config or ssh-agent)" }), - port: z.number().optional().meta({ description: "SSH port (default: 22)" }), - }), -]); diff --git a/src/common/orpc/schemas/secrets.ts b/src/common/orpc/schemas/secrets.ts deleted file mode 100644 index 67f374d0f..000000000 --- a/src/common/orpc/schemas/secrets.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { z } from "zod"; - -export const SecretSchema = z - .object({ - key: z.string(), - value: z.string(), - }) - .meta({ - description: "A key-value pair for storing sensitive configuration", - }); diff --git a/src/common/orpc/schemas/stream.ts b/src/common/orpc/schemas/stream.ts deleted file mode 100644 index 27cdb1df0..000000000 --- a/src/common/orpc/schemas/stream.ts +++ /dev/null @@ -1,279 +0,0 @@ -import { z } from "zod"; -import { ChatUsageDisplaySchema } from "./chatStats"; -import { StreamErrorTypeSchema } from "./errors"; -import { - ImagePartSchema, - MuxMessageSchema, - MuxReasoningPartSchema, - MuxTextPartSchema, - MuxToolPartSchema, -} from "./message"; -import { MuxProviderOptionsSchema } from "./providerOptions"; - -// Chat Events -export const CaughtUpMessageSchema = z.object({ - type: z.literal("caught-up"), -}); - -export const StreamErrorMessageSchema = z.object({ - type: z.literal("stream-error"), - messageId: z.string(), - error: z.string(), - errorType: StreamErrorTypeSchema, -}); - -export const DeleteMessageSchema = z.object({ - type: z.literal("delete"), - historySequences: z.array(z.number()), -}); - -export const StreamStartEventSchema = z.object({ - type: z.literal("stream-start"), - workspaceId: z.string(), - messageId: z.string(), - model: z.string(), - historySequence: z.number().meta({ - description: "Backend assigns global message ordering", - }), -}); - -export const StreamDeltaEventSchema = z.object({ - type: z.literal("stream-delta"), - workspaceId: z.string(), - messageId: z.string(), - delta: z.string(), - tokens: z.number().meta({ - description: "Token count for this delta", - }), - timestamp: z.number().meta({ - description: "When delta was received (Date.now())", - }), -}); - -export const CompletedMessagePartSchema = z.discriminatedUnion("type", [ - MuxReasoningPartSchema, - MuxTextPartSchema, - MuxToolPartSchema, -]); - -// Match LanguageModelV2Usage from @ai-sdk/provider exactly -// Note: inputTokens/outputTokens/totalTokens use `number | undefined` (required key, value can be undefined) -// while reasoningTokens/cachedInputTokens use `?: number | undefined` (optional key) -export const LanguageModelV2UsageSchema = z.object({ - inputTokens: z - .union([z.number(), z.undefined()]) - .meta({ description: "The number of input tokens used" }), - outputTokens: z - .union([z.number(), z.undefined()]) - .meta({ description: "The number of output tokens used" }), - totalTokens: z.union([z.number(), z.undefined()]).meta({ - description: - "Total tokens used - may differ from sum of inputTokens and outputTokens (e.g. reasoning tokens or overhead)", - }), - reasoningTokens: z - .number() - .optional() - .meta({ description: "The number of reasoning tokens used" }), - cachedInputTokens: z - .number() - .optional() - .meta({ description: "The number of cached input tokens" }), -}); - -export const StreamEndEventSchema = z.object({ - type: z.literal("stream-end"), - workspaceId: z.string(), - messageId: z.string(), - metadata: z - .object({ - model: z.string(), - usage: LanguageModelV2UsageSchema.optional(), - providerMetadata: z.record(z.string(), z.unknown()).optional(), - duration: z.number().optional(), - systemMessageTokens: z.number().optional(), - historySequence: z.number().optional().meta({ - description: "Present when loading from history", - }), - timestamp: z.number().optional().meta({ - description: "Present when loading from history", - }), - }) - .meta({ - description: "Structured metadata from backend - directly mergeable with MuxMetadata", - }), - parts: z.array(CompletedMessagePartSchema).meta({ - description: "Parts array preserves temporal ordering of reasoning, text, and tool calls", - }), -}); - -export const StreamAbortEventSchema = z.object({ - type: z.literal("stream-abort"), - workspaceId: z.string(), - messageId: z.string(), - metadata: z - .object({ - usage: LanguageModelV2UsageSchema.optional(), - duration: z.number().optional(), - }) - .optional() - .meta({ - description: "Metadata may contain usage if abort occurred after stream completed processing", - }), - abandonPartial: z.boolean().optional(), -}); - -export const ToolCallStartEventSchema = z.object({ - type: z.literal("tool-call-start"), - workspaceId: z.string(), - messageId: z.string(), - toolCallId: z.string(), - toolName: z.string(), - args: z.unknown(), - tokens: z.number().meta({ description: "Token count for tool input" }), - timestamp: z.number().meta({ description: "When tool call started (Date.now())" }), -}); - -export const ToolCallDeltaEventSchema = z.object({ - type: z.literal("tool-call-delta"), - workspaceId: z.string(), - messageId: z.string(), - toolCallId: z.string(), - toolName: z.string(), - delta: z.unknown(), - tokens: z.number().meta({ description: "Token count for this delta" }), - timestamp: z.number().meta({ description: "When delta was received (Date.now())" }), -}); - -export const ToolCallEndEventSchema = z.object({ - type: z.literal("tool-call-end"), - workspaceId: z.string(), - messageId: z.string(), - toolCallId: z.string(), - toolName: z.string(), - result: z.unknown(), -}); - -export const ReasoningStartEventSchema = z.object({ - type: z.literal("reasoning-start"), - workspaceId: z.string(), - messageId: z.string(), -}); - -export const ReasoningDeltaEventSchema = z.object({ - type: z.literal("reasoning-delta"), - workspaceId: z.string(), - messageId: z.string(), - delta: z.string(), - tokens: z.number().meta({ description: "Token count for this delta" }), - timestamp: z.number().meta({ description: "When delta was received (Date.now())" }), -}); - -export const ReasoningEndEventSchema = z.object({ - type: z.literal("reasoning-end"), - workspaceId: z.string(), - messageId: z.string(), -}); - -export const ErrorEventSchema = z.object({ - type: z.literal("error"), - workspaceId: z.string(), - messageId: z.string(), - error: z.string(), - errorType: StreamErrorTypeSchema.optional(), -}); - -export const UsageDeltaEventSchema = z.object({ - type: z.literal("usage-delta"), - workspaceId: z.string(), - messageId: z.string(), - usage: LanguageModelV2UsageSchema.meta({ - description: "This step's usage (inputTokens = full context)", - }), -}); - -export const WorkspaceInitEventSchema = z.discriminatedUnion("type", [ - z.object({ - type: z.literal("init-start"), - hookPath: z.string(), - timestamp: z.number(), - }), - z.object({ - type: z.literal("init-output"), - line: z.string(), - timestamp: z.number(), - isError: z.boolean().optional(), - }), - z.object({ - type: z.literal("init-end"), - exitCode: z.number(), - timestamp: z.number(), - }), -]); - -export const QueuedMessageChangedEventSchema = z.object({ - type: z.literal("queued-message-changed"), - workspaceId: z.string(), - queuedMessages: z.array(z.string()), - displayText: z.string(), - imageParts: z.array(ImagePartSchema).optional(), -}); - -export const RestoreToInputEventSchema = z.object({ - type: z.literal("restore-to-input"), - workspaceId: z.string(), - text: z.string(), - imageParts: z.array(ImagePartSchema).optional(), -}); - -// Order matters: z.union() tries schemas in order until one passes. -// Put discriminatedUnion first since streaming events (stream-delta, etc.) -// are most frequent and have a `type` field for O(1) lookup. -// MuxMessageSchema lacks `type`, so trying it first caused validation overhead. -export const WorkspaceChatMessageSchema = z.union([ - z.discriminatedUnion("type", [ - CaughtUpMessageSchema, - StreamErrorMessageSchema, - DeleteMessageSchema, - StreamStartEventSchema, - StreamDeltaEventSchema, - StreamEndEventSchema, - StreamAbortEventSchema, - ToolCallStartEventSchema, - ToolCallDeltaEventSchema, - ToolCallEndEventSchema, - ReasoningDeltaEventSchema, - ReasoningEndEventSchema, - UsageDeltaEventSchema, - QueuedMessageChangedEventSchema, - RestoreToInputEventSchema, - ]), - WorkspaceInitEventSchema, - MuxMessageSchema, -]); - -// Update Status -export const UpdateStatusSchema = z.discriminatedUnion("type", [ - z.object({ type: z.literal("idle") }), - z.object({ type: z.literal("checking") }), - z.object({ type: z.literal("available"), info: z.object({ version: z.string() }) }), - z.object({ type: z.literal("up-to-date") }), - z.object({ type: z.literal("downloading"), percent: z.number() }), - z.object({ type: z.literal("downloaded"), info: z.object({ version: z.string() }) }), - z.object({ type: z.literal("error"), message: z.string() }), -]); - -// SendMessage options -export const SendMessageOptionsSchema = z.object({ - editMessageId: z.string().optional(), - thinkingLevel: z.enum(["off", "low", "medium", "high"]).optional(), - model: z.string("No model specified"), - toolPolicy: z.any().optional(), // Complex recursive type, skipping for now - additionalSystemInstructions: z.string().optional(), - maxOutputTokens: z.number().optional(), - providerOptions: MuxProviderOptionsSchema.optional(), - mode: z.string().optional(), - muxMetadata: z.any().optional(), // Black box -}); - -// Re-export ChatUsageDisplaySchema for convenience -export { ChatUsageDisplaySchema }; diff --git a/src/common/orpc/schemas/terminal.ts b/src/common/orpc/schemas/terminal.ts deleted file mode 100644 index e6ca2fbd3..000000000 --- a/src/common/orpc/schemas/terminal.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { z } from "zod"; - -export const TerminalSessionSchema = z.object({ - sessionId: z.string(), - workspaceId: z.string(), - cols: z.number(), - rows: z.number(), -}); - -export const TerminalCreateParamsSchema = z.object({ - workspaceId: z.string(), - cols: z.number(), - rows: z.number(), -}); - -export const TerminalResizeParamsSchema = z.object({ - sessionId: z.string(), - cols: z.number(), - rows: z.number(), -}); diff --git a/src/common/orpc/schemas/tools.ts b/src/common/orpc/schemas/tools.ts deleted file mode 100644 index 1007dfb92..000000000 --- a/src/common/orpc/schemas/tools.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { z } from "zod"; - -export const BashToolResultSchema = z.discriminatedUnion("success", [ - z.object({ - success: z.literal(true), - wall_duration_ms: z.number(), - output: z.string(), - exitCode: z.literal(0), - note: z.string().optional(), - truncated: z - .object({ - reason: z.string(), - totalLines: z.number(), - }) - .optional(), - }), - z.object({ - success: z.literal(false), - wall_duration_ms: z.number(), - output: z.string().optional(), - exitCode: z.number(), - error: z.string(), - note: z.string().optional(), - truncated: z - .object({ - reason: z.string(), - totalLines: z.number(), - }) - .optional(), - }), -]); - -export const FileTreeNodeSchema = z.object({ - name: z.string(), - path: z.string(), - isDirectory: z.boolean(), - get children() { - return z.array(FileTreeNodeSchema); - }, - stats: z - .object({ - filePath: z.string(), - additions: z.number(), - deletions: z.number(), - }) - .optional(), - totalStats: z - .object({ - filePath: z.string(), - additions: z.number(), - deletions: z.number(), - }) - .optional(), -}); diff --git a/src/common/orpc/schemas/workspace.ts b/src/common/orpc/schemas/workspace.ts deleted file mode 100644 index 8201eda50..000000000 --- a/src/common/orpc/schemas/workspace.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { z } from "zod"; -import { RuntimeConfigSchema } from "./runtime"; - -export const WorkspaceMetadataSchema = z.object({ - id: z.string().meta({ - description: - "Stable unique identifier (10 hex chars for new workspaces, legacy format for old)", - }), - name: z.string().meta({ - description: 'Git branch / directory name (e.g., "feature-branch") - used for path computation', - }), - projectName: z - .string() - .meta({ description: "Project name extracted from project path (for display)" }), - projectPath: z - .string() - .meta({ description: "Absolute path to the project (needed to compute workspace path)" }), - createdAt: z.string().optional().meta({ - description: - "ISO 8601 timestamp of when workspace was created (optional for backward compatibility)", - }), - runtimeConfig: RuntimeConfigSchema.meta({ - description: "Runtime configuration for this workspace (always set, defaults to local on load)", - }), -}); - -export const FrontendWorkspaceMetadataSchema = WorkspaceMetadataSchema.extend({ - namedWorkspacePath: z - .string() - .meta({ description: "Worktree path (uses workspace name as directory)" }), -}); - -export const WorkspaceActivitySnapshotSchema = z.object({ - recency: z.number().meta({ description: "Unix ms timestamp of last user interaction" }), - streaming: z.boolean().meta({ description: "Whether workspace currently has an active stream" }), - lastModel: z.string().nullable().meta({ description: "Last model sent from this workspace" }), -}); - -export const GitStatusSchema = z.object({ - ahead: z.number(), - behind: z.number(), - dirty: z - .boolean() - .meta({ description: "Whether there are uncommitted changes (staged or unstaged)" }), -}); diff --git a/src/common/orpc/types.ts b/src/common/orpc/types.ts deleted file mode 100644 index 0b3ab6cdb..000000000 --- a/src/common/orpc/types.ts +++ /dev/null @@ -1,116 +0,0 @@ -import type { z } from "zod"; -import type * as schemas from "./schemas"; - -import type { MuxMessage } from "@/common/types/message"; -import type { - StreamStartEvent, - StreamDeltaEvent, - StreamEndEvent, - StreamAbortEvent, - ToolCallStartEvent, - ToolCallDeltaEvent, - ToolCallEndEvent, - ReasoningDeltaEvent, - ReasoningEndEvent, - UsageDeltaEvent, -} from "@/common/types/stream"; - -export type BranchListResult = z.infer; -export type SendMessageOptions = z.infer; -export type ImagePart = z.infer; -export type WorkspaceChatMessage = z.infer; -export type StreamErrorMessage = z.infer; -export type DeleteMessage = z.infer; -export type WorkspaceInitEvent = z.infer; -export type UpdateStatus = z.infer; -export type WorkspaceActivitySnapshot = z.infer; -export type FrontendWorkspaceMetadataSchemaType = z.infer< - typeof schemas.FrontendWorkspaceMetadataSchema ->; - -// Type guards for common chat message variants -export function isCaughtUpMessage(msg: WorkspaceChatMessage): msg is { type: "caught-up" } { - return (msg as { type?: string }).type === "caught-up"; -} - -export function isStreamError(msg: WorkspaceChatMessage): msg is StreamErrorMessage { - return (msg as { type?: string }).type === "stream-error"; -} - -export function isDeleteMessage(msg: WorkspaceChatMessage): msg is DeleteMessage { - return (msg as { type?: string }).type === "delete"; -} - -export function isStreamStart(msg: WorkspaceChatMessage): msg is StreamStartEvent { - return (msg as { type?: string }).type === "stream-start"; -} - -export function isStreamDelta(msg: WorkspaceChatMessage): msg is StreamDeltaEvent { - return (msg as { type?: string }).type === "stream-delta"; -} - -export function isStreamEnd(msg: WorkspaceChatMessage): msg is StreamEndEvent { - return (msg as { type?: string }).type === "stream-end"; -} - -export function isStreamAbort(msg: WorkspaceChatMessage): msg is StreamAbortEvent { - return (msg as { type?: string }).type === "stream-abort"; -} - -export function isToolCallStart(msg: WorkspaceChatMessage): msg is ToolCallStartEvent { - return (msg as { type?: string }).type === "tool-call-start"; -} - -export function isToolCallDelta(msg: WorkspaceChatMessage): msg is ToolCallDeltaEvent { - return (msg as { type?: string }).type === "tool-call-delta"; -} - -export function isToolCallEnd(msg: WorkspaceChatMessage): msg is ToolCallEndEvent { - return (msg as { type?: string }).type === "tool-call-end"; -} - -export function isReasoningDelta(msg: WorkspaceChatMessage): msg is ReasoningDeltaEvent { - return (msg as { type?: string }).type === "reasoning-delta"; -} - -export function isReasoningEnd(msg: WorkspaceChatMessage): msg is ReasoningEndEvent { - return (msg as { type?: string }).type === "reasoning-end"; -} - -export function isUsageDelta(msg: WorkspaceChatMessage): msg is UsageDeltaEvent { - return (msg as { type?: string }).type === "usage-delta"; -} - -export function isMuxMessage(msg: WorkspaceChatMessage): msg is MuxMessage { - return "role" in msg && !("type" in (msg as { type?: string })); -} - -export function isInitStart( - msg: WorkspaceChatMessage -): msg is Extract { - return (msg as { type?: string }).type === "init-start"; -} - -export function isInitOutput( - msg: WorkspaceChatMessage -): msg is Extract { - return (msg as { type?: string }).type === "init-output"; -} - -export function isInitEnd( - msg: WorkspaceChatMessage -): msg is Extract { - return (msg as { type?: string }).type === "init-end"; -} - -export function isQueuedMessageChanged( - msg: WorkspaceChatMessage -): msg is Extract { - return (msg as { type?: string }).type === "queued-message-changed"; -} - -export function isRestoreToInput( - msg: WorkspaceChatMessage -): msg is Extract { - return (msg as { type?: string }).type === "restore-to-input"; -} diff --git a/src/common/telemetry/client.test.ts b/src/common/telemetry/client.test.ts index 06af9afc0..cb1b02359 100644 --- a/src/common/telemetry/client.test.ts +++ b/src/common/telemetry/client.test.ts @@ -8,10 +8,6 @@ jest.mock("posthog-js", () => ({ }, })); -// Ensure NODE_ENV is set to test for telemetry detection -// Must be set before importing the client module -process.env.NODE_ENV = "test"; - import { initTelemetry, trackEvent, isTelemetryInitialized } from "./client"; describe("Telemetry", () => { @@ -42,7 +38,7 @@ describe("Telemetry", () => { }); it("should correctly detect test environment", () => { - // Verify NODE_ENV is set to test (we set it above for telemetry detection) + // Verify we're in a test environment expect(process.env.NODE_ENV).toBe("test"); }); }); diff --git a/src/common/telemetry/utils.ts b/src/common/telemetry/utils.ts index 439f7e149..b6f847bfc 100644 --- a/src/common/telemetry/utils.ts +++ b/src/common/telemetry/utils.ts @@ -18,8 +18,8 @@ export function getBaseTelemetryProperties(): BaseTelemetryProperties { return { version: gitDescribe, - platform: window.api?.platform ?? "unknown", - electronVersion: window.api?.versions?.electron ?? "unknown", + platform: window.api?.platform || "unknown", + electronVersion: window.api?.versions?.electron || "unknown", }; } diff --git a/src/common/types/chatStats.ts b/src/common/types/chatStats.ts index 0794306cc..6d8ea7ef7 100644 --- a/src/common/types/chatStats.ts +++ b/src/common/types/chatStats.ts @@ -1,6 +1,17 @@ -import type z from "zod"; -import type { ChatStatsSchema, TokenConsumerSchema } from "../orpc/schemas"; +import type { ChatUsageDisplay } from "@/common/utils/tokens/usageAggregator"; -export type TokenConsumer = z.infer; +export interface TokenConsumer { + name: string; // "User", "Assistant", "bash", "readFile", etc. + tokens: number; // Total token count for this consumer + percentage: number; // % of total tokens + fixedTokens?: number; // Fixed overhead (e.g., tool definitions) + variableTokens?: number; // Variable usage (e.g., actual tool calls, text) +} -export type ChatStats = z.infer; +export interface ChatStats { + consumers: TokenConsumer[]; // Sorted descending by token count + totalTokens: number; + model: string; + tokenizerName: string; // e.g., "o200k_base", "claude" + usageHistory: ChatUsageDisplay[]; // Ordered array of actual usage statistics from API responses +} diff --git a/src/common/types/errors.ts b/src/common/types/errors.ts index a69b4329b..1231ec4dc 100644 --- a/src/common/types/errors.ts +++ b/src/common/types/errors.ts @@ -3,18 +3,30 @@ * This discriminated union allows the frontend to handle different error cases appropriately. */ -import type z from "zod"; -import type { SendMessageErrorSchema, StreamErrorTypeSchema } from "../orpc/schemas"; - /** * Discriminated union for all possible sendMessage errors * The frontend is responsible for language and messaging for api_key_not_found and * provider_not_supported errors. Other error types include details needed for display. */ -export type SendMessageError = z.infer; +export type SendMessageError = + | { type: "api_key_not_found"; provider: string } + | { type: "provider_not_supported"; provider: string } + | { type: "invalid_model_string"; message: string } + | { type: "unknown"; raw: string }; /** * Stream error types - categorizes errors during AI streaming * Used across backend (StreamManager) and frontend (StreamErrorMessage) */ -export type StreamErrorType = z.infer; +export type StreamErrorType = + | "authentication" // API key issues, 401 errors + | "rate_limit" // 429 rate limiting + | "server_error" // 5xx server errors + | "api" // Generic API errors + | "retry_failed" // Retry exhausted + | "aborted" // User aborted + | "network" // Network/fetch errors + | "context_exceeded" // Context length/token limit exceeded + | "quota" // Usage quota/billing limits + | "model_not_found" // Model does not exist + | "unknown"; // Catch-all diff --git a/src/common/types/global.d.ts b/src/common/types/global.d.ts index c20cc0973..c0d92b710 100644 --- a/src/common/types/global.d.ts +++ b/src/common/types/global.d.ts @@ -1,5 +1,4 @@ -import type { RouterClient } from "@orpc/server"; -import type { AppRouter } from "@/node/orpc/router"; +import type { IPCApi } from "./ipc"; // Our simplified permission modes for UI export type UIPermissionMode = "plan" | "edit"; @@ -8,31 +7,14 @@ export type UIPermissionMode = "plan" | "edit"; export type SDKPermissionMode = "default" | "acceptEdits" | "bypassPermissions" | "plan"; declare global { - interface WindowApi { - platform: string; - versions: { - node?: string; - chrome?: string; - electron?: string; - }; - // E2E test mode flag - used to adjust UI behavior (e.g., longer toast durations) - isE2E?: boolean; - // Optional ORPC-backed API surfaces populated in tests/storybook mocks - tokenizer?: unknown; - providers?: unknown; - workspace?: unknown; - projects?: unknown; - window?: unknown; - terminal?: unknown; - update?: unknown; - server?: unknown; - } - interface Window { - api: WindowApi; - __ORPC_CLIENT__?: RouterClient; - process?: { - env?: Record; + api: IPCApi & { + platform: string; + versions: { + node: string; + chrome: string; + electron: string; + }; }; } } diff --git a/src/common/types/ipc.ts b/src/common/types/ipc.ts new file mode 100644 index 000000000..d25760081 --- /dev/null +++ b/src/common/types/ipc.ts @@ -0,0 +1,404 @@ +import type { Result } from "./result"; +import type { + FrontendWorkspaceMetadata, + WorkspaceMetadata, + WorkspaceActivitySnapshot, +} from "./workspace"; +import type { MuxMessage, MuxFrontendMetadata } from "./message"; +import type { ChatStats } from "./chatStats"; +import type { ProjectConfig } from "@/node/config"; +import type { SendMessageError, StreamErrorType } from "./errors"; +import type { ThinkingLevel } from "./thinking"; +import type { ToolPolicy } from "@/common/utils/tools/toolPolicy"; +import type { BashToolResult } from "./tools"; +import type { Secret } from "./secrets"; +import type { MuxProviderOptions } from "./providerOptions"; +import type { RuntimeConfig } from "./runtime"; +import type { FileTreeNode } from "@/common/utils/git/numstatParser"; +import type { TerminalSession, TerminalCreateParams, TerminalResizeParams } from "./terminal"; +import type { + StreamStartEvent, + StreamDeltaEvent, + StreamEndEvent, + StreamAbortEvent, + UsageDeltaEvent, + ToolCallStartEvent, + ToolCallDeltaEvent, + ToolCallEndEvent, + ReasoningDeltaEvent, + ReasoningEndEvent, +} from "./stream"; + +// Import constants from constants module (single source of truth) +import { IPC_CHANNELS, getChatChannel } from "@/common/constants/ipc-constants"; + +// Re-export for TypeScript consumers +export { IPC_CHANNELS, getChatChannel }; + +// Type for all channel names +export type IPCChannel = string; + +export interface BranchListResult { + branches: string[]; + recommendedTrunk: string; +} + +// Caught up message type +export interface CaughtUpMessage { + type: "caught-up"; +} + +// Stream error message type (for async streaming errors) +export interface StreamErrorMessage { + type: "stream-error"; + messageId: string; + error: string; + errorType: StreamErrorType; +} + +// Delete message type (for truncating history) +export interface DeleteMessage { + type: "delete"; + historySequences: number[]; +} + +// Workspace init hook events (persisted to init-status.json, not chat.jsonl) +export type WorkspaceInitEvent = + | { + type: "init-start"; + hookPath: string; + timestamp: number; + } + | { + type: "init-output"; + line: string; + timestamp: number; + isError?: boolean; + } + | { + type: "init-end"; + exitCode: number; + timestamp: number; + }; + +export interface QueuedMessageChangedEvent { + type: "queued-message-changed"; + workspaceId: string; + queuedMessages: string[]; // Raw messages for editing/restoration + displayText: string; // Display text (handles slash commands) + imageParts?: ImagePart[]; // Optional image attachments +} + +// Restore to input event (when stream ends/aborts with queued messages) +export interface RestoreToInputEvent { + type: "restore-to-input"; + workspaceId: string; + text: string; + imageParts?: ImagePart[]; // Optional image attachments to restore +} +// Union type for workspace chat messages +export type WorkspaceChatMessage = + | MuxMessage + | CaughtUpMessage + | StreamErrorMessage + | DeleteMessage + | StreamStartEvent + | StreamDeltaEvent + | UsageDeltaEvent + | StreamEndEvent + | StreamAbortEvent + | ToolCallStartEvent + | ToolCallDeltaEvent + | ToolCallEndEvent + | ReasoningDeltaEvent + | ReasoningEndEvent + | WorkspaceInitEvent + | QueuedMessageChangedEvent + | RestoreToInputEvent; + +// Type guard for caught up messages +export function isCaughtUpMessage(msg: WorkspaceChatMessage): msg is CaughtUpMessage { + return "type" in msg && msg.type === "caught-up"; +} + +// Type guard for stream error messages +export function isStreamError(msg: WorkspaceChatMessage): msg is StreamErrorMessage { + return "type" in msg && msg.type === "stream-error"; +} + +// Type guard for delete messages +export function isDeleteMessage(msg: WorkspaceChatMessage): msg is DeleteMessage { + return "type" in msg && msg.type === "delete"; +} + +// Type guard for stream start events +export function isStreamStart(msg: WorkspaceChatMessage): msg is StreamStartEvent { + return "type" in msg && msg.type === "stream-start"; +} + +// Type guard for stream delta events +export function isStreamDelta(msg: WorkspaceChatMessage): msg is StreamDeltaEvent { + return "type" in msg && msg.type === "stream-delta"; +} + +// Type guard for stream end events +export function isStreamEnd(msg: WorkspaceChatMessage): msg is StreamEndEvent { + return "type" in msg && msg.type === "stream-end"; +} + +// Type guard for stream abort events +export function isStreamAbort(msg: WorkspaceChatMessage): msg is StreamAbortEvent { + return "type" in msg && msg.type === "stream-abort"; +} + +// Type guard for usage delta events +export function isUsageDelta(msg: WorkspaceChatMessage): msg is UsageDeltaEvent { + return "type" in msg && msg.type === "usage-delta"; +} + +// Type guard for tool call start events +export function isToolCallStart(msg: WorkspaceChatMessage): msg is ToolCallStartEvent { + return "type" in msg && msg.type === "tool-call-start"; +} + +// Type guard for tool call delta events +export function isToolCallDelta(msg: WorkspaceChatMessage): msg is ToolCallDeltaEvent { + return "type" in msg && msg.type === "tool-call-delta"; +} + +// Type guard for tool call end events +export function isToolCallEnd(msg: WorkspaceChatMessage): msg is ToolCallEndEvent { + return "type" in msg && msg.type === "tool-call-end"; +} + +// Type guard for reasoning delta events +export function isReasoningDelta(msg: WorkspaceChatMessage): msg is ReasoningDeltaEvent { + return "type" in msg && msg.type === "reasoning-delta"; +} + +// Type guard for reasoning end events +export function isReasoningEnd(msg: WorkspaceChatMessage): msg is ReasoningEndEvent { + return "type" in msg && msg.type === "reasoning-end"; +} + +// Type guard for MuxMessage (messages with role but no type field) +export function isMuxMessage(msg: WorkspaceChatMessage): msg is MuxMessage { + return "role" in msg && !("type" in msg); +} + +// Type guards for init events +export function isInitStart( + msg: WorkspaceChatMessage +): msg is Extract { + return "type" in msg && msg.type === "init-start"; +} + +export function isInitOutput( + msg: WorkspaceChatMessage +): msg is Extract { + return "type" in msg && msg.type === "init-output"; +} + +export function isInitEnd( + msg: WorkspaceChatMessage +): msg is Extract { + return "type" in msg && msg.type === "init-end"; +} + +// Type guard for queued message changed events +export function isQueuedMessageChanged( + msg: WorkspaceChatMessage +): msg is QueuedMessageChangedEvent { + return "type" in msg && msg.type === "queued-message-changed"; +} + +// Type guard for restore to input events +export function isRestoreToInput(msg: WorkspaceChatMessage): msg is RestoreToInputEvent { + return "type" in msg && msg.type === "restore-to-input"; +} + +// Type guard for stream stats events + +// Options for sendMessage and resumeStream +export interface SendMessageOptions { + editMessageId?: string; + thinkingLevel?: ThinkingLevel; + model: string; + toolPolicy?: ToolPolicy; + additionalSystemInstructions?: string; + maxOutputTokens?: number; + providerOptions?: MuxProviderOptions; + mode?: string; // Mode name - frontend narrows to specific values, backend accepts any string + muxMetadata?: MuxFrontendMetadata; // Frontend-defined metadata, backend treats as black-box +} + +// API method signatures (shared between main and preload) +// We strive to have a small, tight interface between main and the renderer +// to promote good SoC and testing. +// +// Design principle: IPC methods should be idempotent when possible. +// For example, calling resumeStream on an already-active stream should +// return success (not error), making client code simpler and more resilient. +// +// Minimize the number of methods - use optional parameters for operation variants +// (e.g. remove(id, force?) not remove(id) + removeForce(id)). +export interface IPCApi { + tokenizer: { + countTokens(model: string, text: string): Promise; + countTokensBatch(model: string, texts: string[]): Promise; + calculateStats(messages: MuxMessage[], model: string): Promise; + }; + providers: { + setProviderConfig( + provider: string, + keyPath: string[], + value: string + ): Promise>; + setModels(provider: string, models: string[]): Promise>; + getConfig(): Promise< + Record + >; + list(): Promise; + }; + fs?: { + listDirectory(root: string): Promise; + }; + projects: { + create( + projectPath: string + ): Promise>; + pickDirectory(): Promise; + remove(projectPath: string): Promise>; + list(): Promise>; + listBranches(projectPath: string): Promise; + secrets: { + get(projectPath: string): Promise; + update(projectPath: string, secrets: Secret[]): Promise>; + }; + }; + workspace: { + list(): Promise; + create( + projectPath: string, + branchName: string, + trunkBranch: string, + runtimeConfig?: RuntimeConfig + ): Promise< + { success: true; metadata: FrontendWorkspaceMetadata } | { success: false; error: string } + >; + remove( + workspaceId: string, + options?: { force?: boolean } + ): Promise<{ success: boolean; error?: string }>; + rename( + workspaceId: string, + newName: string + ): Promise>; + fork( + sourceWorkspaceId: string, + newName: string + ): Promise< + | { success: true; metadata: WorkspaceMetadata; projectPath: string } + | { success: false; error: string } + >; + sendMessage( + workspaceId: string | null, + message: string, + options?: SendMessageOptions & { + imageParts?: ImagePart[]; + runtimeConfig?: RuntimeConfig; + projectPath?: string; // Required when workspaceId is null + trunkBranch?: string; // Optional - trunk branch to branch from (when workspaceId is null) + } + ): Promise< + | Result + | { success: true; workspaceId: string; metadata: FrontendWorkspaceMetadata } + >; + resumeStream( + workspaceId: string, + options: SendMessageOptions + ): Promise>; + interruptStream( + workspaceId: string, + options?: { abandonPartial?: boolean } + ): Promise>; + clearQueue(workspaceId: string): Promise>; + truncateHistory(workspaceId: string, percentage?: number): Promise>; + replaceChatHistory( + workspaceId: string, + summaryMessage: MuxMessage + ): Promise>; + getInfo(workspaceId: string): Promise; + executeBash( + workspaceId: string, + script: string, + options?: { + timeout_secs?: number; + niceness?: number; + } + ): Promise>; + openTerminal(workspacePath: string): Promise; + + // Event subscriptions (renderer-only) + // These methods are designed to send current state immediately upon subscription, + // followed by real-time updates. We deliberately don't provide one-off getters + // to encourage the renderer to maintain an always up-to-date view of the state + // through continuous subscriptions rather than polling patterns. + onChat(workspaceId: string, callback: (data: WorkspaceChatMessage) => void): () => void; + onMetadata( + callback: (data: { workspaceId: string; metadata: FrontendWorkspaceMetadata }) => void + ): () => void; + activity: { + list(): Promise>; + subscribe( + callback: (payload: { + workspaceId: string; + activity: WorkspaceActivitySnapshot | null; + }) => void + ): () => void; + }; + }; + window: { + setTitle(title: string): Promise; + }; + terminal: { + create(params: TerminalCreateParams): Promise; + close(sessionId: string): Promise; + resize(params: TerminalResizeParams): Promise; + sendInput(sessionId: string, data: string): void; + onOutput(sessionId: string, callback: (data: string) => void): () => void; + onExit(sessionId: string, callback: (exitCode: number) => void): () => void; + openWindow(workspaceId: string): Promise; + closeWindow(workspaceId: string): Promise; + }; + update: { + check(): Promise; + download(): Promise; + install(): void; + onStatus(callback: (status: UpdateStatus) => void): () => void; + }; + server?: { + getLaunchProject(): Promise; + }; + platform?: "electron" | "browser"; + versions?: { + node?: string; + chrome?: string; + electron?: string; + }; +} + +// Update status type (matches updater service) +export type UpdateStatus = + | { type: "idle" } // Initial state, no check performed yet + | { type: "checking" } + | { type: "available"; info: { version: string } } + | { type: "up-to-date" } // Explicitly checked, no updates available + | { type: "downloading"; percent: number } + | { type: "downloaded"; info: { version: string } } + | { type: "error"; message: string }; + +export interface ImagePart { + url: string; // Data URL (e.g., "data:image/png;base64,...") + mediaType: string; // MIME type (e.g., "image/png", "image/jpeg") +} diff --git a/src/common/types/message.ts b/src/common/types/message.ts index 617e3d698..cfb11bea7 100644 --- a/src/common/types/message.ts +++ b/src/common/types/message.ts @@ -3,7 +3,7 @@ import type { LanguageModelV2Usage } from "@ai-sdk/provider"; import type { StreamErrorType } from "./errors"; import type { ToolPolicy } from "@/common/utils/tools/toolPolicy"; import type { ChatUsageDisplay } from "@/common/utils/tokens/usageAggregator"; -import type { ImagePart } from "@/common/orpc/schemas"; +import type { ImagePart } from "./ipc"; // Message to continue with after compaction export interface ContinueMessage { diff --git a/src/common/types/project.ts b/src/common/types/project.ts index a38c63b33..29ca5d449 100644 --- a/src/common/types/project.ts +++ b/src/common/types/project.ts @@ -3,12 +3,47 @@ * Kept lightweight for preload script usage. */ -import type { z } from "zod"; -import type { ProjectConfigSchema, WorkspaceConfigSchema } from "../orpc/schemas"; +import type { RuntimeConfig } from "./runtime"; -export type Workspace = z.infer; +/** + * Workspace configuration in config.json. + * + * NEW FORMAT (preferred, used for all new workspaces): + * { + * "path": "~/.mux/src/project/workspace-id", // Kept for backward compat + * "id": "a1b2c3d4e5", // Stable workspace ID + * "name": "feature-branch", // User-facing name + * "createdAt": "2024-01-01T00:00:00Z", // Creation timestamp + * "runtimeConfig": { ... } // Runtime config (local vs SSH) + * } + * + * LEGACY FORMAT (old workspaces, still supported): + * { + * "path": "~/.mux/src/project/workspace-id" // Only field present + * } + * + * For legacy entries, metadata is read from ~/.mux/sessions/{workspaceId}/metadata.json + */ +export interface Workspace { + /** Absolute path to workspace directory - REQUIRED for backward compatibility */ + path: string; + + /** Stable workspace ID (10 hex chars for new workspaces) - optional for legacy */ + id?: string; + + /** Git branch / directory name (e.g., "feature-branch") - optional for legacy */ + name?: string; -export type ProjectConfig = z.infer; + /** ISO 8601 creation timestamp - optional for legacy */ + createdAt?: string; + + /** Runtime configuration (local vs SSH) - optional, defaults to local */ + runtimeConfig?: RuntimeConfig; +} + +export interface ProjectConfig { + workspaces: Workspace[]; +} export interface ProjectsConfig { projects: Map; diff --git a/src/common/types/providerOptions.ts b/src/common/types/providerOptions.ts index 6fae67870..86a4d4802 100644 --- a/src/common/types/providerOptions.ts +++ b/src/common/types/providerOptions.ts @@ -1,5 +1,4 @@ -import type z from "zod"; -import type { MuxProviderOptionsSchema } from "../orpc/schemas"; +import type { XaiProviderOptions } from "@ai-sdk/xai"; /** * Mux provider-specific options that get passed through the stack. @@ -12,4 +11,65 @@ import type { MuxProviderOptionsSchema } from "../orpc/schemas"; * configuration level (e.g., custom headers, beta features). */ -export type MuxProviderOptions = z.infer; +/** + * Anthropic-specific options + */ +export interface AnthropicProviderOptions { + /** Enable 1M context window (requires beta header) */ + use1MContext?: boolean; +} + +/** + * OpenAI-specific options + */ +export interface OpenAIProviderOptions { + /** Disable automatic context truncation (useful for testing) */ + disableAutoTruncation?: boolean; + /** Force context limit error (used in integration tests to simulate overflow) */ + forceContextLimitError?: boolean; + /** Simulate successful response without executing tools (used in tool policy tests) */ + simulateToolPolicyNoop?: boolean; +} + +/** + * Google-specific options + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface GoogleProviderOptions {} + +/** + * Ollama-specific options + * Currently empty - Ollama is a local service and doesn't require special options. + * This interface is provided for future extensibility. + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface OllamaProviderOptions {} + +/** + * OpenRouter-specific options + * Transparently passes through options to the OpenRouter provider + * @see https://openrouter.ai/docs + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface OpenRouterProviderOptions {} + +/** + * Mux provider options - used by both frontend and backend + */ +/** + * xAI-specific options + */ +export interface XaiProviderOverrides { + /** Override Grok search parameters (defaults to auto search with citations) */ + searchParameters?: XaiProviderOptions["searchParameters"]; +} + +export interface MuxProviderOptions { + /** Provider-specific options */ + anthropic?: AnthropicProviderOptions; + openai?: OpenAIProviderOptions; + google?: GoogleProviderOptions; + ollama?: OllamaProviderOptions; + openrouter?: OpenRouterProviderOptions; + xai?: XaiProviderOverrides; +} diff --git a/src/common/types/runtime.ts b/src/common/types/runtime.ts index eef678210..085b702b9 100644 --- a/src/common/types/runtime.ts +++ b/src/common/types/runtime.ts @@ -2,12 +2,8 @@ * Runtime configuration types for workspace execution environments */ -import type { z } from "zod"; -import type { RuntimeConfigSchema } from "../orpc/schemas"; -import { RuntimeModeSchema } from "../orpc/schemas"; - /** Runtime mode type - used in UI and runtime string parsing */ -export type RuntimeMode = z.infer; +export type RuntimeMode = "local" | "ssh"; /** Runtime mode constants */ export const RUNTIME_MODE = { @@ -18,7 +14,23 @@ export const RUNTIME_MODE = { /** Runtime string prefix for SSH mode (e.g., "ssh hostname") */ export const SSH_RUNTIME_PREFIX = "ssh "; -export type RuntimeConfig = z.infer; +export type RuntimeConfig = + | { + type: "local"; + /** Base directory where all workspaces are stored (e.g., ~/.mux/src) */ + srcBaseDir: string; + } + | { + type: "ssh"; + /** SSH host (can be hostname, user@host, or SSH config alias) */ + host: string; + /** Base directory on remote host where all workspaces are stored */ + srcBaseDir: string; + /** Optional: Path to SSH private key (if not using ~/.ssh/config or ssh-agent) */ + identityFile?: string; + /** Optional: SSH port (default: 22) */ + port?: number; + }; /** * Parse runtime string from localStorage or UI input into mode and host @@ -39,27 +51,17 @@ export function parseRuntimeModeAndHost(runtime: string | null | undefined): { const trimmed = runtime.trim(); const lowerTrimmed = trimmed.toLowerCase(); - // Check for "ssh " format first (before trying to parse as plain mode) - if (lowerTrimmed.startsWith(SSH_RUNTIME_PREFIX)) { - const host = trimmed.substring(SSH_RUNTIME_PREFIX.length).trim(); - return { mode: RUNTIME_MODE.SSH, host }; - } - - // Try to parse as a plain mode ("ssh" or "local") - const modeResult = RuntimeModeSchema.safeParse(lowerTrimmed); - if (!modeResult.success) { - // Default to local for unrecognized strings + if (lowerTrimmed === RUNTIME_MODE.LOCAL) { return { mode: RUNTIME_MODE.LOCAL, host: "" }; } - const mode = modeResult.data; - - if (mode === RUNTIME_MODE.SSH) { - // Plain "ssh" without host - return { mode, host: "" }; + // Handle both "ssh" and "ssh " + if (lowerTrimmed === RUNTIME_MODE.SSH || lowerTrimmed.startsWith(SSH_RUNTIME_PREFIX)) { + const host = trimmed.substring(SSH_RUNTIME_PREFIX.length).trim(); + return { mode: RUNTIME_MODE.SSH, host }; } - // Local mode or default + // Default to local for unrecognized strings return { mode: RUNTIME_MODE.LOCAL, host: "" }; } diff --git a/src/common/types/secrets.ts b/src/common/types/secrets.ts index ead9739a6..ed6fd958f 100644 --- a/src/common/types/secrets.ts +++ b/src/common/types/secrets.ts @@ -1,7 +1,10 @@ -import type z from "zod"; -import type { SecretSchema } from "../orpc/schemas"; - -export type Secret = z.infer; +/** + * Secret - A key-value pair for storing sensitive configuration + */ +export interface Secret { + key: string; + value: string; +} /** * SecretsConfig - Maps project paths to their secrets diff --git a/src/common/types/stream.ts b/src/common/types/stream.ts index 2d6db63a5..e667f7a7e 100644 --- a/src/common/types/stream.ts +++ b/src/common/types/stream.ts @@ -2,22 +2,9 @@ * Event types emitted by AIService */ -import type { z } from "zod"; +import type { LanguageModelV2Usage } from "@ai-sdk/provider"; import type { MuxReasoningPart, MuxTextPart, MuxToolPart } from "./message"; -import type { - ErrorEventSchema, - ReasoningDeltaEventSchema, - ReasoningEndEventSchema, - ReasoningStartEventSchema, - StreamAbortEventSchema, - StreamDeltaEventSchema, - StreamEndEventSchema, - StreamStartEventSchema, - ToolCallDeltaEventSchema, - ToolCallEndEventSchema, - ToolCallStartEventSchema, - UsageDeltaEventSchema, -} from "../orpc/schemas"; +import type { StreamErrorType } from "./errors"; /** * Completed message part (reasoning, text, or tool) suitable for serialization @@ -25,26 +12,125 @@ import type { */ export type CompletedMessagePart = MuxReasoningPart | MuxTextPart | MuxToolPart; -export type StreamStartEvent = z.infer; -export type StreamDeltaEvent = z.infer; -export type StreamEndEvent = z.infer; -export type StreamAbortEvent = z.infer; +export interface StreamStartEvent { + type: "stream-start"; + workspaceId: string; + messageId: string; + model: string; + historySequence: number; // Backend assigns global message ordering +} -export type ErrorEvent = z.infer; +export interface StreamDeltaEvent { + type: "stream-delta"; + workspaceId: string; + messageId: string; + delta: string; + tokens: number; // Token count for this delta + timestamp: number; // When delta was received (Date.now()) +} -export type ToolCallStartEvent = z.infer; -export type ToolCallDeltaEvent = z.infer; -export type ToolCallEndEvent = z.infer; +export interface StreamEndEvent { + type: "stream-end"; + workspaceId: string; + messageId: string; + // Structured metadata from backend - directly mergeable with MuxMetadata + metadata: { + model: string; + usage?: LanguageModelV2Usage; + providerMetadata?: Record; + duration?: number; + systemMessageTokens?: number; + historySequence?: number; // Present when loading from history + timestamp?: number; // Present when loading from history + }; + // Parts array preserves temporal ordering of reasoning, text, and tool calls + parts: CompletedMessagePart[]; +} -export type ReasoningStartEvent = z.infer; -export type ReasoningDeltaEvent = z.infer; -export type ReasoningEndEvent = z.infer; +export interface StreamAbortEvent { + type: "stream-abort"; + workspaceId: string; + messageId: string; + // Metadata may contain usage if abort occurred after stream completed processing + metadata?: { + usage?: LanguageModelV2Usage; + duration?: number; + }; + abandonPartial?: boolean; +} + +export interface ErrorEvent { + type: "error"; + workspaceId: string; + messageId: string; + error: string; + errorType?: StreamErrorType; +} + +// Tool call events +export interface ToolCallStartEvent { + type: "tool-call-start"; + workspaceId: string; + messageId: string; + toolCallId: string; + toolName: string; + args: unknown; + tokens: number; // Token count for tool input + timestamp: number; // When tool call started (Date.now()) +} + +export interface ToolCallDeltaEvent { + type: "tool-call-delta"; + workspaceId: string; + messageId: string; + toolCallId: string; + toolName: string; + delta: unknown; + tokens: number; // Token count for this delta + timestamp: number; // When delta was received (Date.now()) +} + +export interface ToolCallEndEvent { + type: "tool-call-end"; + workspaceId: string; + messageId: string; + toolCallId: string; + toolName: string; + result: unknown; +} + +// Reasoning events +export interface ReasoningStartEvent { + type: "reasoning-start"; + workspaceId: string; + messageId: string; +} + +export interface ReasoningDeltaEvent { + type: "reasoning-delta"; + workspaceId: string; + messageId: string; + delta: string; + tokens: number; // Token count for this delta + timestamp: number; // When delta was received (Date.now()) +} + +export interface ReasoningEndEvent { + type: "reasoning-end"; + workspaceId: string; + messageId: string; +} /** * Emitted on each AI SDK finish-step event, providing incremental usage updates. * Allows UI to update token display as steps complete (after each tool call or at stream end). */ -export type UsageDeltaEvent = z.infer; +export interface UsageDeltaEvent { + type: "usage-delta"; + workspaceId: string; + messageId: string; + usage: LanguageModelV2Usage; // This step's usage (inputTokens = full context) +} export type AIServiceEvent = | StreamStartEvent diff --git a/src/common/types/terminal.ts b/src/common/types/terminal.ts index ad8feb578..ebe674aaa 100644 --- a/src/common/types/terminal.ts +++ b/src/common/types/terminal.ts @@ -2,13 +2,21 @@ * Terminal session types */ -import type { z } from "zod"; -import type { - TerminalCreateParamsSchema, - TerminalResizeParamsSchema, - TerminalSessionSchema, -} from "../orpc/schemas"; +export interface TerminalSession { + sessionId: string; + workspaceId: string; + cols: number; + rows: number; +} -export type TerminalSession = z.infer; -export type TerminalCreateParams = z.infer; -export type TerminalResizeParams = z.infer; +export interface TerminalCreateParams { + workspaceId: string; + cols: number; + rows: number; +} + +export interface TerminalResizeParams { + sessionId: string; + cols: number; + rows: number; +} diff --git a/src/common/types/toolParts.ts b/src/common/types/toolParts.ts index 1ab591105..ed71b8e17 100644 --- a/src/common/types/toolParts.ts +++ b/src/common/types/toolParts.ts @@ -2,16 +2,26 @@ * Type definitions for dynamic tool parts */ -import type { z } from "zod"; -import type { - DynamicToolPartAvailableSchema, - DynamicToolPartPendingSchema, - DynamicToolPartSchema, -} from "../orpc/schemas"; +export interface DynamicToolPartAvailable { + type: "dynamic-tool"; + toolCallId: string; + toolName: string; + state: "output-available"; + input: unknown; + output: unknown; + timestamp?: number; +} + +export interface DynamicToolPartPending { + type: "dynamic-tool"; + toolCallId: string; + toolName: string; + state: "input-available"; + input: unknown; + timestamp?: number; +} -export type DynamicToolPartAvailable = z.infer; -export type DynamicToolPartPending = z.infer; -export type DynamicToolPart = z.infer; +export type DynamicToolPart = DynamicToolPartAvailable | DynamicToolPartPending; export function isDynamicToolPart(part: unknown): part is DynamicToolPart { return ( diff --git a/src/common/types/workspace.ts b/src/common/types/workspace.ts index d5559cf84..465cd38d7 100644 --- a/src/common/types/workspace.ts +++ b/src/common/types/workspace.ts @@ -1,3 +1,18 @@ +import { z } from "zod"; + +/** + * Zod schema for workspace metadata validation + */ +export const WorkspaceMetadataSchema = z.object({ + id: z.string().min(1, "Workspace ID is required"), + name: z.string().min(1, "Workspace name is required"), + projectName: z.string().min(1, "Project name is required"), + projectPath: z.string().min(1, "Project path is required"), + createdAt: z.string().optional(), // ISO 8601 timestamp (optional for backward compatibility) + // Legacy field - ignored on load, removed on save + workspacePath: z.string().optional(), +}); + /** * Unified workspace metadata type used throughout the application. * This is the single source of truth for workspace information. @@ -19,30 +34,56 @@ * - Directory name uses workspace.name (the branch name) * - This avoids storing redundant derived data */ +import type { RuntimeConfig } from "./runtime"; + +export interface WorkspaceMetadata { + /** Stable unique identifier (10 hex chars for new workspaces, legacy format for old) */ + id: string; + + /** Git branch / directory name (e.g., "feature-branch") - used for path computation */ + name: string; + + /** Project name extracted from project path (for display) */ + projectName: string; + + /** Absolute path to the project (needed to compute workspace path) */ + projectPath: string; -import type { z } from "zod"; -import type { - FrontendWorkspaceMetadataSchema, - GitStatusSchema, - WorkspaceActivitySnapshotSchema, - WorkspaceMetadataSchema, -} from "../orpc/schemas"; + /** ISO 8601 timestamp of when workspace was created (optional for backward compatibility) */ + createdAt?: string; -export type WorkspaceMetadata = z.infer; + /** Runtime configuration for this workspace (always set, defaults to local on load) */ + runtimeConfig: RuntimeConfig; +} /** * Git status for a workspace (ahead/behind relative to origin's primary branch) */ -export type GitStatus = z.infer; +export interface GitStatus { + ahead: number; + behind: number; + /** Whether there are uncommitted changes (staged or unstaged) */ + dirty: boolean; +} /** * Frontend workspace metadata enriched with computed paths. * Backend computes these paths to avoid duplication of path construction logic. * Follows naming convention: Backend types vs Frontend types. */ -export type FrontendWorkspaceMetadata = z.infer; +export interface FrontendWorkspaceMetadata extends WorkspaceMetadata { + /** Worktree path (uses workspace name as directory) */ + namedWorkspacePath: string; +} -export type WorkspaceActivitySnapshot = z.infer; +export interface WorkspaceActivitySnapshot { + /** Unix ms timestamp of last user interaction */ + recency: number; + /** Whether workspace currently has an active stream */ + streaming: boolean; + /** Last model sent from this workspace */ + lastModel: string | null; +} /** * @deprecated Use FrontendWorkspaceMetadata instead diff --git a/src/common/utils/tools/toolDefinitions.ts b/src/common/utils/tools/toolDefinitions.ts index fe388737a..66e180ab3 100644 --- a/src/common/utils/tools/toolDefinitions.ts +++ b/src/common/utils/tools/toolDefinitions.ts @@ -254,8 +254,7 @@ export function getToolSchemas(): Record { { name, description: def.description, - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument - inputSchema: zodToJsonSchema(def.schema as any) as ToolSchema["inputSchema"], + inputSchema: zodToJsonSchema(def.schema) as ToolSchema["inputSchema"], }, ]) ); diff --git a/src/common/utils/tools/tools.ts b/src/common/utils/tools/tools.ts index c837e9bb6..873e6a8c3 100644 --- a/src/common/utils/tools/tools.ts +++ b/src/common/utils/tools/tools.ts @@ -125,8 +125,7 @@ export async function getToolsForModel( const { anthropic } = await import("@ai-sdk/anthropic"); allTools = { ...baseTools, - // Provider-specific tool types are compatible with Tool at runtime - web_search: anthropic.tools.webSearch_20250305({ maxUses: 1000 }) as Tool, + web_search: anthropic.tools.webSearch_20250305({ maxUses: 1000 }), }; break; } @@ -137,10 +136,9 @@ export async function getToolsForModel( const { openai } = await import("@ai-sdk/openai"); allTools = { ...baseTools, - // Provider-specific tool types are compatible with Tool at runtime web_search: openai.tools.webSearch({ searchContextSize: "high", - }) as Tool, + }), }; } break; diff --git a/src/desktop/main.ts b/src/desktop/main.ts index e4006c9e3..4eb8f39d9 100644 --- a/src/desktop/main.ts +++ b/src/desktop/main.ts @@ -1,11 +1,8 @@ // Enable source map support for better error stack traces in production import "source-map-support/register"; -import { RPCHandler } from "@orpc/server/message-port"; -import { onError } from "@orpc/server"; -import { router } from "@/node/orpc/router"; import "disposablestack/auto"; -import type { MenuItemConstructorOptions } from "electron"; +import type { IpcMainInvokeEvent, MenuItemConstructorOptions } from "electron"; import { app, BrowserWindow, @@ -18,10 +15,12 @@ import { import * as fs from "fs"; import * as path from "path"; import type { Config } from "@/node/config"; -import type { ServiceContainer } from "@/node/services/serviceContainer"; +import type { IpcMain } from "@/node/services/ipcMain"; import { VERSION } from "@/version"; +import { IPC_CHANNELS } from "@/common/constants/ipc-constants"; import { getMuxHome, migrateLegacyMuxHome } from "@/common/constants/paths"; - +import { log } from "@/node/services/log"; +import { parseDebugUpdater } from "@/common/utils/env"; import assert from "@/common/utils/assert"; import { loadTokenizerModules } from "@/node/utils/main/tokenizer"; @@ -39,10 +38,12 @@ import { loadTokenizerModules } from "@/node/utils/main/tokenizer"; // // Enforcement: scripts/check_eager_imports.sh validates this in CI // -// Lazy-load Config and ServiceContainer to avoid loading heavy AI SDK dependencies at startup +// Lazy-load Config and IpcMain to avoid loading heavy AI SDK dependencies at startup // These will be loaded on-demand when createWindow() is called let config: Config | null = null; -let services: ServiceContainer | null = null; +let ipcMain: IpcMain | null = null; +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +let updaterService: typeof import("@/desktop/updater").UpdaterService.prototype | null = null; const isE2ETest = process.env.MUX_E2E === "1"; const forceDistLoad = process.env.MUX_E2E_LOAD_DIST === "1"; @@ -260,67 +261,43 @@ function closeSplashScreen() { } /** - * Load backend services (Config, ServiceContainer, AI SDK, tokenizer) + * Load backend services (Config, IpcMain, AI SDK, tokenizer) * * Heavy initialization (~100ms) happens here while splash is visible. * Note: Spinner may freeze briefly during this phase. This is acceptable since * the splash still provides visual feedback that the app is loading. */ async function loadServices(): Promise { - if (config && services) return; // Already loaded + if (config && ipcMain) return; // Already loaded const startTime = Date.now(); console.log(`[${timestamp()}] Loading services...`); /* eslint-disable no-restricted-syntax */ // Dynamic imports are justified here for performance: - // - ServiceContainer transitively imports the entire AI SDK (ai, @ai-sdk/anthropic, etc.) + // - IpcMain transitively imports the entire AI SDK (ai, @ai-sdk/anthropic, etc.) // - These are large modules (~100ms load time) that would block splash from appearing // - Loading happens once, then cached const [ { Config: ConfigClass }, - { ServiceContainer: ServiceContainerClass }, + { IpcMain: IpcMainClass }, + { UpdaterService: UpdaterServiceClass }, { TerminalWindowManager: TerminalWindowManagerClass }, ] = await Promise.all([ import("@/node/config"), - import("@/node/services/serviceContainer"), + import("@/node/services/ipcMain"), + import("@/desktop/updater"), import("@/desktop/terminalWindowManager"), ]); /* eslint-enable no-restricted-syntax */ config = new ConfigClass(); - - services = new ServiceContainerClass(config); - await services.initialize(); - - const orpcHandler = new RPCHandler(router(), { - interceptors: [ - onError((error) => { - console.error("ORPC Error:", error); - }), - ], - }); - - electronIpcMain.on("start-orpc-server", (event) => { - const [serverPort] = event.ports; - orpcHandler.upgrade(serverPort, { - context: { - projectService: services!.projectService, - workspaceService: services!.workspaceService, - providerService: services!.providerService, - terminalService: services!.terminalService, - windowService: services!.windowService, - updateService: services!.updateService, - tokenizerService: services!.tokenizerService, - serverService: services!.serverService, - }, - }); - serverPort.start(); - }); + ipcMain = new IpcMainClass(config); + await ipcMain.initialize(); // Set TerminalWindowManager for desktop mode (pop-out terminal windows) const terminalWindowManager = new TerminalWindowManagerClass(config); - services.setProjectDirectoryPicker(async () => { - const win = BrowserWindow.getFocusedWindow(); + ipcMain.setProjectDirectoryPicker(async (event: IpcMainInvokeEvent) => { + const win = BrowserWindow.fromWebContents(event.sender); if (!win) return null; const res = await dialog.showOpenDialog(win, { @@ -332,21 +309,35 @@ async function loadServices(): Promise { return res.canceled || res.filePaths.length === 0 ? null : res.filePaths[0]; }); - services.setTerminalWindowManager(terminalWindowManager); + ipcMain.setTerminalWindowManager(terminalWindowManager); loadTokenizerModules().catch((error) => { console.error("Failed to preload tokenizer modules:", error); }); // Initialize updater service in packaged builds or when DEBUG_UPDATER is set - // Moved to UpdateService (services.updateService) + const debugConfig = parseDebugUpdater(process.env.DEBUG_UPDATER); + + if (app.isPackaged || debugConfig.enabled) { + updaterService = new UpdaterServiceClass(); + const debugInfo = debugConfig.fakeVersion + ? `debug with fake version ${debugConfig.fakeVersion}` + : `debug enabled`; + console.log( + `[${timestamp()}] Updater service initialized (packaged: ${app.isPackaged}, ${debugConfig.enabled ? debugInfo : ""})` + ); + } else { + console.log( + `[${timestamp()}] Updater service disabled in dev mode (set DEBUG_UPDATER=1 or DEBUG_UPDATER= to enable)` + ); + } const loadTime = Date.now() - startTime; console.log(`[${timestamp()}] Services loaded in ${loadTime}ms`); } function createWindow() { - assert(services, "Services must be loaded before creating window"); + assert(ipcMain, "Services must be loaded before creating window"); // Calculate window size based on screen dimensions (80% of available space) const primaryDisplay = screen.getPrimaryDisplay(); @@ -372,9 +363,52 @@ function createWindow() { show: false, // Don't show until ready-to-show event }); - // Register window service with the main window - console.log(`[${timestamp()}] [window] Registering window service...`); - services.windowService.setMainWindow(mainWindow); + // Register IPC handlers with the main window + console.log(`[${timestamp()}] [window] Registering IPC handlers...`); + ipcMain.register(electronIpcMain, mainWindow); + + // Register updater IPC handlers (available in both dev and prod) + electronIpcMain.handle(IPC_CHANNELS.UPDATE_CHECK, () => { + // Note: log interface already includes timestamp and file location + log.debug(`UPDATE_CHECK called (updaterService: ${updaterService ? "available" : "null"})`); + if (!updaterService) { + // Send "idle" status if updater not initialized (dev mode without DEBUG_UPDATER) + if (mainWindow) { + mainWindow.webContents.send(IPC_CHANNELS.UPDATE_STATUS, { + type: "idle" as const, + }); + } + return; + } + log.debug("Calling updaterService.checkForUpdates()"); + updaterService.checkForUpdates(); + }); + + electronIpcMain.handle(IPC_CHANNELS.UPDATE_DOWNLOAD, async () => { + if (!updaterService) throw new Error("Updater not available in development"); + await updaterService.downloadUpdate(); + }); + + electronIpcMain.handle(IPC_CHANNELS.UPDATE_INSTALL, () => { + if (!updaterService) throw new Error("Updater not available in development"); + updaterService.installUpdate(); + }); + + // Handle status subscription requests + // Note: React StrictMode in dev causes components to mount twice, resulting in duplicate calls + electronIpcMain.on(IPC_CHANNELS.UPDATE_STATUS_SUBSCRIBE, () => { + log.debug("UPDATE_STATUS_SUBSCRIBE called"); + if (!mainWindow) return; + const status = updaterService ? updaterService.getStatus() : { type: "idle" }; + log.debug("Sending current status to renderer:", status); + mainWindow.webContents.send(IPC_CHANNELS.UPDATE_STATUS, status); + }); + + // Set up updater service with the main window (only in production) + if (updaterService) { + updaterService.setMainWindow(mainWindow); + // Note: Checks are initiated by frontend to respect telemetry preference + } // Show window once it's ready and close splash console.time("main window startup"); diff --git a/src/desktop/preload.ts b/src/desktop/preload.ts index e4fd8529f..8b5cd86e3 100644 --- a/src/desktop/preload.ts +++ b/src/desktop/preload.ts @@ -1,36 +1,224 @@ /** - * Electron Preload Script + * Electron Preload Script with Bundled Constants * - * This script bridges the renderer process with the main process via ORPC over MessagePort. + * This file demonstrates a sophisticated solution to a complex problem in Electron development: + * how to share constants between main and preload processes while respecting Electron's security + * sandbox restrictions. The challenge is that preload scripts run in a heavily sandboxed environment + * where they cannot import custom modules using standard Node.js `require()` or ES6 `import` syntax. * - * Key responsibilities: - * 1) Forward MessagePort from renderer to main process for ORPC transport setup - * 2) Expose minimal platform info to renderer via contextBridge + * Our solution uses Bun's bundler with the `--external=electron` flag to create a hybrid approach: + * 1) Constants from `./constants/ipc-constants.ts` are inlined directly into this compiled script + * 2) The `electron` module remains external and is safely required at runtime by Electron's sandbox + * 3) This gives us a single source of truth for IPC constants while avoiding the fragile text + * parsing and complex inline replacement scripts that other approaches require. * - * The ORPC connection flow: - * - Renderer creates MessageChannel, posts "start-orpc-client" with serverPort - * - Preload intercepts, forwards serverPort to main via ipcRenderer.postMessage - * - Main process upgrades the port with RPCHandler for bidirectional RPC - * - * Build: `bun build src/desktop/preload.ts --format=cjs --target=node --external=electron` + * The build command `bun build src/preload.ts --format=cjs --target=node --external=electron --outfile=dist/preload.js` + * produces a self-contained script where IPC_CHANNELS, getOutputChannel, and getClearChannel are + * literal values with no runtime imports needed, while contextBridge and ipcRenderer remain as + * clean `require("electron")` calls that work perfectly in the sandbox environment. */ import { contextBridge, ipcRenderer } from "electron"; +import type { IPCApi, WorkspaceChatMessage, UpdateStatus } from "@/common/types/ipc"; +import type { + FrontendWorkspaceMetadata, + WorkspaceActivitySnapshot, +} from "@/common/types/workspace"; +import type { ProjectConfig } from "@/common/types/project"; +import { IPC_CHANNELS, getChatChannel } from "@/common/constants/ipc-constants"; -// Handle ORPC connection setup -window.addEventListener("message", (event) => { - if (event.data === "start-orpc-client") { - const [serverPort] = event.ports; - ipcRenderer.postMessage("start-orpc-server", null, [serverPort]); - } -}); +// Build the API implementation using the shared interface +const api: IPCApi = { + tokenizer: { + countTokens: (model, text) => + ipcRenderer.invoke(IPC_CHANNELS.TOKENIZER_COUNT_TOKENS, model, text), + countTokensBatch: (model, texts) => + ipcRenderer.invoke(IPC_CHANNELS.TOKENIZER_COUNT_TOKENS_BATCH, model, texts), + calculateStats: (messages, model) => + ipcRenderer.invoke(IPC_CHANNELS.TOKENIZER_CALCULATE_STATS, messages, model), + }, + providers: { + setProviderConfig: (provider, keyPath, value) => + ipcRenderer.invoke(IPC_CHANNELS.PROVIDERS_SET_CONFIG, provider, keyPath, value), + setModels: (provider, models) => + ipcRenderer.invoke(IPC_CHANNELS.PROVIDERS_SET_MODELS, provider, models), + getConfig: () => ipcRenderer.invoke(IPC_CHANNELS.PROVIDERS_GET_CONFIG), + list: () => ipcRenderer.invoke(IPC_CHANNELS.PROVIDERS_LIST), + }, + fs: { + listDirectory: (root: string) => ipcRenderer.invoke(IPC_CHANNELS.FS_LIST_DIRECTORY, root), + }, + projects: { + create: (projectPath) => ipcRenderer.invoke(IPC_CHANNELS.PROJECT_CREATE, projectPath), + pickDirectory: () => ipcRenderer.invoke(IPC_CHANNELS.PROJECT_PICK_DIRECTORY), + remove: (projectPath) => ipcRenderer.invoke(IPC_CHANNELS.PROJECT_REMOVE, projectPath), + list: (): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.PROJECT_LIST), + listBranches: (projectPath: string) => + ipcRenderer.invoke(IPC_CHANNELS.PROJECT_LIST_BRANCHES, projectPath), + secrets: { + get: (projectPath) => ipcRenderer.invoke(IPC_CHANNELS.PROJECT_SECRETS_GET, projectPath), + update: (projectPath, secrets) => + ipcRenderer.invoke(IPC_CHANNELS.PROJECT_SECRETS_UPDATE, projectPath, secrets), + }, + }, + workspace: { + list: () => ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_LIST), + create: (projectPath, branchName, trunkBranch: string, runtimeConfig?) => + ipcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_CREATE, + projectPath, + branchName, + trunkBranch, + runtimeConfig + ), + remove: (workspaceId: string, options?: { force?: boolean }) => + ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_REMOVE, workspaceId, options), + rename: (workspaceId: string, newName: string) => + ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_RENAME, workspaceId, newName), + fork: (sourceWorkspaceId: string, newName: string) => + ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_FORK, sourceWorkspaceId, newName), + sendMessage: (workspaceId, message, options) => + ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_SEND_MESSAGE, workspaceId, message, options), + resumeStream: (workspaceId, options) => + ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_RESUME_STREAM, workspaceId, options), + interruptStream: (workspaceId: string, options?: { abandonPartial?: boolean }) => + ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_INTERRUPT_STREAM, workspaceId, options), + clearQueue: (workspaceId: string) => + ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_CLEAR_QUEUE, workspaceId), + truncateHistory: (workspaceId, percentage) => + ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_TRUNCATE_HISTORY, workspaceId, percentage), + replaceChatHistory: (workspaceId, summaryMessage) => + ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_REPLACE_HISTORY, workspaceId, summaryMessage), + getInfo: (workspaceId) => ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_GET_INFO, workspaceId), + executeBash: (workspaceId, script, options) => + ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_EXECUTE_BASH, workspaceId, script, options), + openTerminal: (workspaceId) => { + return ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_OPEN_TERMINAL, workspaceId); + }, + + onChat: (workspaceId: string, callback) => { + const channel = getChatChannel(workspaceId); + const handler = (_event: unknown, data: WorkspaceChatMessage) => { + callback(data); + }; + + // Subscribe to the channel + ipcRenderer.on(channel, handler); + + // Send subscription request with workspace ID as parameter + // This allows main process to fetch history for the specific workspace + ipcRenderer.send(`workspace:chat:subscribe`, workspaceId); + + return () => { + ipcRenderer.removeListener(channel, handler); + ipcRenderer.send(`workspace:chat:unsubscribe`, workspaceId); + }; + }, + onMetadata: ( + callback: (data: { workspaceId: string; metadata: FrontendWorkspaceMetadata }) => void + ) => { + const handler = ( + _event: unknown, + data: { workspaceId: string; metadata: FrontendWorkspaceMetadata } + ) => callback(data); + + // Subscribe to metadata events + ipcRenderer.on(IPC_CHANNELS.WORKSPACE_METADATA, handler); + + // Request current metadata state - consistent subscription pattern + ipcRenderer.send(`workspace:metadata:subscribe`); + + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.WORKSPACE_METADATA, handler); + ipcRenderer.send(`workspace:metadata:unsubscribe`); + }; + }, + activity: { + list: () => ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_ACTIVITY_LIST), + subscribe: ( + callback: (payload: { + workspaceId: string; + activity: WorkspaceActivitySnapshot | null; + }) => void + ) => { + const handler = ( + _event: unknown, + data: { workspaceId: string; activity: WorkspaceActivitySnapshot | null } + ) => callback(data); + + ipcRenderer.on(IPC_CHANNELS.WORKSPACE_ACTIVITY, handler); + ipcRenderer.send(IPC_CHANNELS.WORKSPACE_ACTIVITY_SUBSCRIBE); + + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.WORKSPACE_ACTIVITY, handler); + ipcRenderer.send(IPC_CHANNELS.WORKSPACE_ACTIVITY_UNSUBSCRIBE); + }; + }, + }, + }, + window: { + setTitle: (title: string) => ipcRenderer.invoke(IPC_CHANNELS.WINDOW_SET_TITLE, title), + }, + update: { + check: () => ipcRenderer.invoke(IPC_CHANNELS.UPDATE_CHECK), + download: () => ipcRenderer.invoke(IPC_CHANNELS.UPDATE_DOWNLOAD), + install: () => { + void ipcRenderer.invoke(IPC_CHANNELS.UPDATE_INSTALL); + }, + onStatus: (callback: (status: UpdateStatus) => void) => { + const handler = (_event: unknown, status: UpdateStatus) => { + callback(status); + }; + + // Subscribe to status updates + ipcRenderer.on(IPC_CHANNELS.UPDATE_STATUS, handler); + + // Request current status - consistent subscription pattern + ipcRenderer.send(IPC_CHANNELS.UPDATE_STATUS_SUBSCRIBE); + + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.UPDATE_STATUS, handler); + }; + }, + }, + terminal: { + create: (params) => ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_CREATE, params), + close: (sessionId) => ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_CLOSE, sessionId), + resize: (params) => ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_RESIZE, params), + sendInput: (sessionId: string, data: string) => { + void ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_INPUT, sessionId, data); + }, + onOutput: (sessionId: string, callback: (data: string) => void) => { + const channel = `terminal:output:${sessionId}`; + const handler = (_event: unknown, data: string) => callback(data); + ipcRenderer.on(channel, handler); + return () => ipcRenderer.removeListener(channel, handler); + }, + onExit: (sessionId: string, callback: (exitCode: number) => void) => { + const channel = `terminal:exit:${sessionId}`; + const handler = (_event: unknown, exitCode: number) => callback(exitCode); + ipcRenderer.on(channel, handler); + return () => ipcRenderer.removeListener(channel, handler); + }, + openWindow: (workspaceId: string) => { + console.log( + `[Preload] terminal.openWindow called with workspaceId: ${workspaceId}, channel: ${IPC_CHANNELS.TERMINAL_WINDOW_OPEN}` + ); + return ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_WINDOW_OPEN, workspaceId); + }, + closeWindow: (workspaceId: string) => + ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_WINDOW_CLOSE, workspaceId), + }, +}; +// Expose the API along with platform/versions contextBridge.exposeInMainWorld("api", { + ...api, platform: process.platform, versions: { node: process.versions.node, chrome: process.versions.chrome, electron: process.versions.electron, }, - isE2E: process.env.MUX_E2E === "1", }); diff --git a/src/desktop/updater.test.ts b/src/desktop/updater.test.ts index 234febe7f..705d3a971 100644 --- a/src/desktop/updater.test.ts +++ b/src/desktop/updater.test.ts @@ -1,43 +1,52 @@ -import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test"; -import { EventEmitter } from "events"; -import { UpdaterService, type UpdateStatus } from "./updater"; - -// Create a mock autoUpdater that's an EventEmitter with the required methods -const mockAutoUpdater = Object.assign(new EventEmitter(), { - autoDownload: false, - autoInstallOnAppQuit: true, - checkForUpdates: mock(() => Promise.resolve()), - downloadUpdate: mock(() => Promise.resolve()), - quitAndInstall: mock(() => { - // Mock implementation - does nothing in tests - }), +/* eslint-disable @typescript-eslint/no-require-imports */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +/* eslint-disable @typescript-eslint/no-empty-function */ +/* eslint-disable @typescript-eslint/unbound-method */ + +import { UpdaterService } from "./updater"; +import { autoUpdater } from "electron-updater"; +import type { BrowserWindow } from "electron"; + +// Mock electron-updater +jest.mock("electron-updater", () => { + const EventEmitter = require("events"); + const mockAutoUpdater = new EventEmitter(); + return { + autoUpdater: Object.assign(mockAutoUpdater, { + autoDownload: false, + autoInstallOnAppQuit: true, + checkForUpdates: jest.fn(), + downloadUpdate: jest.fn(), + quitAndInstall: jest.fn(), + }), + }; }); -// Mock electron-updater module -void mock.module("electron-updater", () => ({ - autoUpdater: mockAutoUpdater, -})); - describe("UpdaterService", () => { let service: UpdaterService; - let statusUpdates: UpdateStatus[]; + let mockWindow: jest.Mocked; let originalDebugUpdater: string | undefined; beforeEach(() => { - // Reset mocks - mockAutoUpdater.checkForUpdates.mockClear(); - mockAutoUpdater.downloadUpdate.mockClear(); - mockAutoUpdater.quitAndInstall.mockClear(); - mockAutoUpdater.removeAllListeners(); - + jest.clearAllMocks(); // Save and clear DEBUG_UPDATER to ensure clean test environment originalDebugUpdater = process.env.DEBUG_UPDATER; delete process.env.DEBUG_UPDATER; service = new UpdaterService(); - // Capture status updates via subscriber pattern (ORPC model) - statusUpdates = []; - service.subscribe((status) => statusUpdates.push(status)); + // Create mock window + mockWindow = { + isDestroyed: jest.fn(() => false), + webContents: { + send: jest.fn(), + }, + } as any; + + service.setMainWindow(mockWindow); }); afterEach(() => { @@ -50,27 +59,29 @@ describe("UpdaterService", () => { }); describe("checkForUpdates", () => { - it("should set status to 'checking' immediately and notify subscribers", () => { + it("should set status to 'checking' immediately and notify renderer", () => { // Setup - mockAutoUpdater.checkForUpdates.mockReturnValue(Promise.resolve()); + const checkForUpdatesMock = autoUpdater.checkForUpdates as jest.Mock; + checkForUpdatesMock.mockReturnValue(Promise.resolve()); // Act service.checkForUpdates(); // Assert - should immediately notify with 'checking' status - expect(statusUpdates).toContainEqual({ type: "checking" }); + expect(mockWindow.webContents.send).toHaveBeenCalledWith("update:status", { + type: "checking", + }); }); it("should transition to 'up-to-date' when no update found", async () => { // Setup - mockAutoUpdater.checkForUpdates.mockImplementation(() => { + const checkForUpdatesMock = autoUpdater.checkForUpdates as jest.Mock; + checkForUpdatesMock.mockImplementation(() => { // Simulate electron-updater behavior: emit event, return unresolved promise setImmediate(() => { - mockAutoUpdater.emit("update-not-available"); - }); - return new Promise(() => { - // Intentionally never resolves to simulate hanging promise + (autoUpdater as any).emit("update-not-available"); }); + return new Promise(() => {}); // Never resolves }); // Act @@ -80,12 +91,14 @@ describe("UpdaterService", () => { await new Promise((resolve) => setImmediate(resolve)); // Assert - should notify with 'up-to-date' status - expect(statusUpdates).toContainEqual({ type: "checking" }); - expect(statusUpdates).toContainEqual({ type: "up-to-date" }); + const calls = (mockWindow.webContents.send as jest.Mock).mock.calls; + expect(calls).toContainEqual(["update:status", { type: "checking" }]); + expect(calls).toContainEqual(["update:status", { type: "up-to-date" }]); }); it("should transition to 'available' when update found", async () => { // Setup + const checkForUpdatesMock = autoUpdater.checkForUpdates as jest.Mock; const updateInfo = { version: "1.0.0", files: [], @@ -94,13 +107,11 @@ describe("UpdaterService", () => { releaseDate: "2025-01-01", }; - mockAutoUpdater.checkForUpdates.mockImplementation(() => { + checkForUpdatesMock.mockImplementation(() => { setImmediate(() => { - mockAutoUpdater.emit("update-available", updateInfo); - }); - return new Promise(() => { - // Intentionally never resolves to simulate hanging promise + (autoUpdater as any).emit("update-available", updateInfo); }); + return new Promise(() => {}); // Never resolves }); // Act @@ -110,15 +121,17 @@ describe("UpdaterService", () => { await new Promise((resolve) => setImmediate(resolve)); // Assert - expect(statusUpdates).toContainEqual({ type: "checking" }); - expect(statusUpdates).toContainEqual({ type: "available", info: updateInfo }); + const calls = (mockWindow.webContents.send as jest.Mock).mock.calls; + expect(calls).toContainEqual(["update:status", { type: "checking" }]); + expect(calls).toContainEqual(["update:status", { type: "available", info: updateInfo }]); }); it("should handle errors from checkForUpdates", async () => { // Setup + const checkForUpdatesMock = autoUpdater.checkForUpdates as jest.Mock; const error = new Error("Network error"); - mockAutoUpdater.checkForUpdates.mockImplementation(() => { + checkForUpdatesMock.mockImplementation(() => { return Promise.reject(error); }); @@ -129,12 +142,16 @@ describe("UpdaterService", () => { await new Promise((resolve) => setImmediate(resolve)); // Assert - expect(statusUpdates).toContainEqual({ type: "checking" }); + const calls = (mockWindow.webContents.send as jest.Mock).mock.calls; + expect(calls).toContainEqual(["update:status", { type: "checking" }]); // Should eventually get error status - const errorStatus = statusUpdates.find((s) => s.type === "error"); - expect(errorStatus).toBeDefined(); - expect(errorStatus).toEqual({ type: "error", message: "Network error" }); + const errorCall = calls.find((call) => call[1].type === "error"); + expect(errorCall).toBeDefined(); + expect(errorCall[1]).toEqual({ + type: "error", + message: "Network error", + }); }); it("should timeout if no events fire within 30 seconds", () => { @@ -144,32 +161,33 @@ describe("UpdaterService", () => { let timeoutCallback: (() => void) | null = null; // Mock setTimeout to capture the timeout callback - const globalObj = global as { setTimeout: typeof setTimeout }; - globalObj.setTimeout = ((cb: () => void, _delay: number) => { + (global as any).setTimeout = ((cb: () => void, _delay: number) => { timeoutCallback = cb; - return 123 as unknown as ReturnType; - }) as typeof setTimeout; + return 123 as any; // Return fake timer ID + }) as any; // Setup - checkForUpdates returns promise that never resolves and emits no events - mockAutoUpdater.checkForUpdates.mockImplementation(() => { - return new Promise(() => { - // Intentionally never resolves to simulate hanging promise - }); + const checkForUpdatesMock = autoUpdater.checkForUpdates as jest.Mock; + checkForUpdatesMock.mockImplementation(() => { + return new Promise(() => {}); // Hangs forever, no events }); // Act service.checkForUpdates(); // Should be in checking state - expect(statusUpdates).toContainEqual({ type: "checking" }); + expect(mockWindow.webContents.send).toHaveBeenCalledWith("update:status", { + type: "checking", + }); // Manually trigger the timeout callback expect(timeoutCallback).toBeTruthy(); timeoutCallback!(); // Should have timed out and returned to idle - const lastStatus = statusUpdates[statusUpdates.length - 1]; - expect(lastStatus).toEqual({ type: "idle" }); + const calls = (mockWindow.webContents.send as jest.Mock).mock.calls; + const lastCall = calls[calls.length - 1]; + expect(lastCall).toEqual(["update:status", { type: "idle" }]); // Restore original setTimeout global.setTimeout = originalSetTimeout; @@ -183,7 +201,8 @@ describe("UpdaterService", () => { }); it("should return current status after check starts", () => { - mockAutoUpdater.checkForUpdates.mockReturnValue(Promise.resolve()); + const checkForUpdatesMock = autoUpdater.checkForUpdates as jest.Mock; + checkForUpdatesMock.mockReturnValue(Promise.resolve()); service.checkForUpdates(); diff --git a/src/desktop/updater.ts b/src/desktop/updater.ts index f903840b2..d03468f02 100644 --- a/src/desktop/updater.ts +++ b/src/desktop/updater.ts @@ -1,5 +1,7 @@ import { autoUpdater } from "electron-updater"; import type { UpdateInfo } from "electron-updater"; +import type { BrowserWindow } from "electron"; +import { IPC_CHANNELS } from "@/common/constants/ipc-constants"; import { log } from "@/node/services/log"; import { parseDebugUpdater } from "@/common/utils/env"; @@ -26,10 +28,10 @@ export type UpdateStatus = * - Install updates when requested by the user */ export class UpdaterService { + private mainWindow: BrowserWindow | null = null; private updateStatus: UpdateStatus = { type: "idle" }; - private checkTimeout: ReturnType | null = null; + private checkTimeout: NodeJS.Timeout | null = null; private readonly fakeVersion: string | undefined; - private subscribers = new Set<(status: UpdateStatus) => void>(); constructor() { // Configure auto-updater @@ -105,6 +107,16 @@ export class UpdaterService { } } + /** + * Set the main window for sending status updates + */ + setMainWindow(window: BrowserWindow) { + log.debug("setMainWindow() called"); + this.mainWindow = window; + // Send current status to newly connected window + this.notifyRenderer(); + } + /** * Check for updates manually * @@ -228,16 +240,6 @@ export class UpdaterService { /** * Get the current update status */ - - /** - * Subscribe to status updates - */ - subscribe(callback: (status: UpdateStatus) => void): () => void { - this.subscribers.add(callback); - return () => { - this.subscribers.delete(callback); - }; - } getStatus(): UpdateStatus { return this.updateStatus; } @@ -247,13 +249,11 @@ export class UpdaterService { */ private notifyRenderer() { log.debug("notifyRenderer() called, status:", this.updateStatus); - // Notify subscribers (ORPC) - for (const subscriber of this.subscribers) { - try { - subscriber(this.updateStatus); - } catch (err) { - log.error("Error notifying subscriber:", err); - } + if (this.mainWindow && !this.mainWindow.isDestroyed()) { + log.debug("Sending status to renderer via IPC"); + this.mainWindow.webContents.send(IPC_CHANNELS.UPDATE_STATUS, this.updateStatus); + } else { + log.debug("Cannot send - mainWindow is null or destroyed"); } } } diff --git a/src/node/bench/headlessEnvironment.ts b/src/node/bench/headlessEnvironment.ts index bd6792016..29828c843 100644 --- a/src/node/bench/headlessEnvironment.ts +++ b/src/node/bench/headlessEnvironment.ts @@ -4,7 +4,7 @@ import * as fs from "fs/promises"; import createIPCMock from "electron-mock-ipc"; import type { BrowserWindow, IpcMain as ElectronIpcMain, WebContents } from "electron"; import { Config } from "@/node/config"; -import { ServiceContainer } from "@/node/services/serviceContainer"; +import { IpcMain } from "@/node/services/ipcMain"; type MockedElectron = ReturnType; @@ -17,7 +17,7 @@ interface CreateHeadlessEnvironmentOptions { export interface HeadlessEnvironment { config: Config; - services: ServiceContainer; + ipcMain: IpcMain; mockIpcMain: ElectronIpcMain; mockIpcRenderer: Electron.IpcRenderer; mockWindow: BrowserWindow; @@ -104,9 +104,9 @@ export async function createHeadlessEnvironment( const mockIpcMainModule = mockedElectron.ipcMain; const mockIpcRendererModule = mockedElectron.ipcRenderer; - const services = new ServiceContainer(config); - await services.initialize(); - services.windowService.setMainWindow(mockWindow); + const ipcMain = new IpcMain(config); + await ipcMain.initialize(); + ipcMain.register(mockIpcMainModule, mockWindow); const dispose = async () => { sentEvents.length = 0; @@ -115,7 +115,7 @@ export async function createHeadlessEnvironment( return { config, - services, + ipcMain, mockIpcMain: mockIpcMainModule, mockIpcRenderer: mockIpcRendererModule, mockWindow, diff --git a/src/node/config.ts b/src/node/config.ts index bc6aa13a4..be51f3bdf 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -402,32 +402,6 @@ export class Config { }); } - /** - * Remove a workspace from config.json - * - * @param workspaceId ID of the workspace to remove - */ - async removeWorkspace(workspaceId: string): Promise { - await this.editConfig((config) => { - let workspaceFound = false; - - for (const [_projectPath, project] of config.projects) { - const index = project.workspaces.findIndex((w) => w.id === workspaceId); - if (index !== -1) { - project.workspaces.splice(index, 1); - workspaceFound = true; - // We don't break here in case duplicates exist (though they shouldn't) - } - } - - if (!workspaceFound) { - console.warn(`Workspace ${workspaceId} not found in config during removal`); - } - - return config; - }); - } - /** * Update workspace metadata fields (e.g., regenerate missing title/branch) * Used to fix incomplete metadata after errors or restarts diff --git a/src/node/orpc/authMiddleware.test.ts b/src/node/orpc/authMiddleware.test.ts deleted file mode 100644 index 1d29a03ae..000000000 --- a/src/node/orpc/authMiddleware.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { safeEq } from "./authMiddleware"; - -describe("safeEq", () => { - it("returns true for equal strings", () => { - expect(safeEq("secret", "secret")).toBe(true); - expect(safeEq("", "")).toBe(true); - expect(safeEq("a", "a")).toBe(true); - }); - - it("returns false for different strings of same length", () => { - expect(safeEq("secret", "secreT")).toBe(false); - expect(safeEq("aaaaaa", "aaaaab")).toBe(false); - expect(safeEq("a", "b")).toBe(false); - }); - - it("returns false for different length strings", () => { - expect(safeEq("short", "longer")).toBe(false); - expect(safeEq("", "a")).toBe(false); - expect(safeEq("abc", "ab")).toBe(false); - }); - - it("handles unicode strings", () => { - expect(safeEq("héllo", "héllo")).toBe(true); - expect(safeEq("héllo", "hello")).toBe(false); - expect(safeEq("🔐", "🔐")).toBe(true); - }); - - describe("timing consistency", () => { - const ITERATIONS = 10000; - const secret = "supersecrettoken123456789"; - - function measureAvgTime(fn: () => void, iterations: number): number { - const start = process.hrtime.bigint(); - for (let i = 0; i < iterations; i++) { - fn(); - } - const end = process.hrtime.bigint(); - return Number(end - start) / iterations; - } - - it("takes similar time for matching vs non-matching strings of same length", () => { - const matching = secret; - const nonMatching = "Xupersecrettoken123456789"; // differs at first char - - const matchTime = measureAvgTime(() => safeEq(secret, matching), ITERATIONS); - const nonMatchTime = measureAvgTime(() => safeEq(secret, nonMatching), ITERATIONS); - - // Allow up to 50% variance (timing tests are inherently noisy) - const ratio = Math.max(matchTime, nonMatchTime) / Math.min(matchTime, nonMatchTime); - expect(ratio).toBeLessThan(1.5); - }); - - it("takes similar time regardless of where mismatch occurs", () => { - const earlyMismatch = "Xupersecrettoken123456789"; // first char - const lateMismatch = "supersecrettoken12345678X"; // last char - - const earlyTime = measureAvgTime(() => safeEq(secret, earlyMismatch), ITERATIONS); - const lateTime = measureAvgTime(() => safeEq(secret, lateMismatch), ITERATIONS); - - const ratio = Math.max(earlyTime, lateTime) / Math.min(earlyTime, lateTime); - expect(ratio).toBeLessThan(1.5); - }); - - it("length mismatch takes comparable time to same-length comparison", () => { - const sameLength = "Xupersecrettoken123456789"; - const diffLength = "short"; - - const sameLenTime = measureAvgTime(() => safeEq(secret, sameLength), ITERATIONS); - const diffLenTime = measureAvgTime(() => safeEq(secret, diffLength), ITERATIONS); - - // Length mismatch should not be significantly faster due to dummy comparison - const ratio = Math.max(sameLenTime, diffLenTime) / Math.min(sameLenTime, diffLenTime); - expect(ratio).toBeLessThan(2.0); - }); - }); -}); diff --git a/src/node/orpc/authMiddleware.ts b/src/node/orpc/authMiddleware.ts deleted file mode 100644 index 93ed94284..000000000 --- a/src/node/orpc/authMiddleware.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { timingSafeEqual } from "crypto"; -import { os } from "@orpc/server"; -import type { IncomingHttpHeaders, IncomingMessage } from "http"; -import { URL } from "url"; - -// Time-constant string comparison using Node's crypto module -export function safeEq(a: string, b: string): boolean { - const bufA = Buffer.from(a); - const bufB = Buffer.from(b); - if (bufA.length !== bufB.length) { - // Perform a dummy comparison to maintain constant time - timingSafeEqual(bufA, bufA); - return false; - } - return timingSafeEqual(bufA, bufB); -} - -function extractBearerToken(header: string | string[] | undefined): string | null { - const h = Array.isArray(header) ? header[0] : header; - if (!h?.toLowerCase().startsWith("bearer ")) return null; - return h.slice(7).trim() || null; -} - -/** Create auth middleware that validates Authorization header from context */ -export function createAuthMiddleware(authToken?: string) { - if (!authToken?.trim()) { - return os.middleware(({ next }) => next()); - } - - const expectedToken = authToken.trim(); - - return os - .$context<{ headers?: IncomingHttpHeaders }>() - .errors({ - UNAUTHORIZED: { - message: "Invalid or missing auth token", - }, - }) - .middleware(({ context, errors, next }) => { - const presentedToken = extractBearerToken(context.headers?.authorization); - - if (!presentedToken || !safeEq(presentedToken, expectedToken)) { - throw errors.UNAUTHORIZED(); - } - - return next(); - }); -} - -/** Extract auth token from WS upgrade request and build headers object with synthetic Authorization */ -export function extractWsHeaders(req: IncomingMessage): IncomingHttpHeaders { - // Start with actual headers - const headers = { ...req.headers }; - - // If no Authorization header, try fallback methods - if (!headers.authorization) { - // 1) Query param: ?token=... - try { - const url = new URL(req.url ?? "", "http://localhost"); - const qp = url.searchParams.get("token"); - if (qp?.trim()) { - headers.authorization = `Bearer ${qp.trim()}`; - return headers; - } - } catch { - /* ignore */ - } - - // 2) Sec-WebSocket-Protocol (first value as token) - const proto = req.headers["sec-websocket-protocol"]; - if (typeof proto === "string") { - const first = proto - .split(",") - .map((s) => s.trim()) - .find((s) => s); - if (first) { - headers.authorization = `Bearer ${first}`; - } - } - } - - return headers; -} diff --git a/src/node/orpc/context.ts b/src/node/orpc/context.ts deleted file mode 100644 index 5cb46ef11..000000000 --- a/src/node/orpc/context.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { IncomingHttpHeaders } from "http"; -import type { ProjectService } from "@/node/services/projectService"; -import type { WorkspaceService } from "@/node/services/workspaceService"; -import type { ProviderService } from "@/node/services/providerService"; -import type { TerminalService } from "@/node/services/terminalService"; -import type { WindowService } from "@/node/services/windowService"; -import type { UpdateService } from "@/node/services/updateService"; -import type { TokenizerService } from "@/node/services/tokenizerService"; -import type { ServerService } from "@/node/services/serverService"; - -export interface ORPCContext { - projectService: ProjectService; - workspaceService: WorkspaceService; - providerService: ProviderService; - terminalService: TerminalService; - windowService: WindowService; - updateService: UpdateService; - tokenizerService: TokenizerService; - serverService: ServerService; - headers?: IncomingHttpHeaders; -} diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts deleted file mode 100644 index 5c1c7e997..000000000 --- a/src/node/orpc/router.ts +++ /dev/null @@ -1,684 +0,0 @@ -import { os } from "@orpc/server"; -import * as schemas from "@/common/orpc/schemas"; -import type { ORPCContext } from "./context"; -import type { - UpdateStatus, - WorkspaceActivitySnapshot, - WorkspaceChatMessage, - FrontendWorkspaceMetadataSchemaType, -} from "@/common/orpc/types"; -import { createAuthMiddleware } from "./authMiddleware"; - -export const router = (authToken?: string) => { - const t = os.$context().use(createAuthMiddleware(authToken)); - - return t.router({ - tokenizer: { - countTokens: t - .input(schemas.tokenizer.countTokens.input) - .output(schemas.tokenizer.countTokens.output) - .handler(async ({ context, input }) => { - return context.tokenizerService.countTokens(input.model, input.text); - }), - countTokensBatch: t - .input(schemas.tokenizer.countTokensBatch.input) - .output(schemas.tokenizer.countTokensBatch.output) - .handler(async ({ context, input }) => { - return context.tokenizerService.countTokensBatch(input.model, input.texts); - }), - calculateStats: t - .input(schemas.tokenizer.calculateStats.input) - .output(schemas.tokenizer.calculateStats.output) - .handler(async ({ context, input }) => { - return context.tokenizerService.calculateStats(input.messages, input.model); - }), - }, - server: { - getLaunchProject: t - .input(schemas.server.getLaunchProject.input) - .output(schemas.server.getLaunchProject.output) - .handler(async ({ context }) => { - return context.serverService.getLaunchProject(); - }), - }, - providers: { - list: t - .input(schemas.providers.list.input) - .output(schemas.providers.list.output) - .handler(({ context }) => context.providerService.list()), - getConfig: t - .input(schemas.providers.getConfig.input) - .output(schemas.providers.getConfig.output) - .handler(({ context }) => context.providerService.getConfig()), - setProviderConfig: t - .input(schemas.providers.setProviderConfig.input) - .output(schemas.providers.setProviderConfig.output) - .handler(({ context, input }) => - context.providerService.setConfig(input.provider, input.keyPath, input.value) - ), - setModels: t - .input(schemas.providers.setModels.input) - .output(schemas.providers.setModels.output) - .handler(({ context, input }) => - context.providerService.setModels(input.provider, input.models) - ), - }, - general: { - listDirectory: t - .input(schemas.general.listDirectory.input) - .output(schemas.general.listDirectory.output) - .handler(async ({ context, input }) => { - return context.projectService.listDirectory(input.path); - }), - ping: t - .input(schemas.general.ping.input) - .output(schemas.general.ping.output) - .handler(({ input }) => { - return `Pong: ${input}`; - }), - tick: t - .input(schemas.general.tick.input) - .output(schemas.general.tick.output) - .handler(async function* ({ input }) { - for (let i = 1; i <= input.count; i++) { - yield { tick: i, timestamp: Date.now() }; - if (i < input.count) { - await new Promise((r) => setTimeout(r, input.intervalMs)); - } - } - }), - }, - projects: { - list: t - .input(schemas.projects.list.input) - .output(schemas.projects.list.output) - .handler(({ context }) => { - return context.projectService.list(); - }), - create: t - .input(schemas.projects.create.input) - .output(schemas.projects.create.output) - .handler(async ({ context, input }) => { - return context.projectService.create(input.projectPath); - }), - pickDirectory: t - .input(schemas.projects.pickDirectory.input) - .output(schemas.projects.pickDirectory.output) - .handler(async ({ context }) => { - return context.projectService.pickDirectory(); - }), - listBranches: t - .input(schemas.projects.listBranches.input) - .output(schemas.projects.listBranches.output) - .handler(async ({ context, input }) => { - return context.projectService.listBranches(input.projectPath); - }), - remove: t - .input(schemas.projects.remove.input) - .output(schemas.projects.remove.output) - .handler(async ({ context, input }) => { - return context.projectService.remove(input.projectPath); - }), - secrets: { - get: t - .input(schemas.projects.secrets.get.input) - .output(schemas.projects.secrets.get.output) - .handler(({ context, input }) => { - return context.projectService.getSecrets(input.projectPath); - }), - update: t - .input(schemas.projects.secrets.update.input) - .output(schemas.projects.secrets.update.output) - .handler(async ({ context, input }) => { - return context.projectService.updateSecrets(input.projectPath, input.secrets); - }), - }, - }, - workspace: { - list: t - .input(schemas.workspace.list.input) - .output(schemas.workspace.list.output) - .handler(({ context }) => { - return context.workspaceService.list(); - }), - create: t - .input(schemas.workspace.create.input) - .output(schemas.workspace.create.output) - .handler(async ({ context, input }) => { - const result = await context.workspaceService.create( - input.projectPath, - input.branchName, - input.trunkBranch, - input.runtimeConfig - ); - if (!result.success) { - return { success: false, error: result.error }; - } - return { success: true, metadata: result.data.metadata }; - }), - remove: t - .input(schemas.workspace.remove.input) - .output(schemas.workspace.remove.output) - .handler(async ({ context, input }) => { - const result = await context.workspaceService.remove( - input.workspaceId, - input.options?.force - ); - if (!result.success) { - return { success: false, error: result.error }; - } - return { success: true }; - }), - rename: t - .input(schemas.workspace.rename.input) - .output(schemas.workspace.rename.output) - .handler(async ({ context, input }) => { - return context.workspaceService.rename(input.workspaceId, input.newName); - }), - fork: t - .input(schemas.workspace.fork.input) - .output(schemas.workspace.fork.output) - .handler(async ({ context, input }) => { - const result = await context.workspaceService.fork( - input.sourceWorkspaceId, - input.newName - ); - if (!result.success) { - return { success: false, error: result.error }; - } - return { - success: true, - metadata: result.data.metadata, - projectPath: result.data.projectPath, - }; - }), - sendMessage: t - .input(schemas.workspace.sendMessage.input) - .output(schemas.workspace.sendMessage.output) - .handler(async ({ context, input }) => { - const result = await context.workspaceService.sendMessage( - input.workspaceId, - input.message, - input.options - ); - - if (!result.success) { - const error = - typeof result.error === "string" - ? { type: "unknown" as const, raw: result.error } - : result.error; - return { success: false, error }; - } - - // Check if this is a workspace creation result - if ("workspaceId" in result) { - return { - success: true, - data: { - workspaceId: result.workspaceId, - metadata: result.metadata, - }, - }; - } - - // Regular message send (no workspace creation) - return { success: true, data: {} }; - }), - resumeStream: t - .input(schemas.workspace.resumeStream.input) - .output(schemas.workspace.resumeStream.output) - .handler(async ({ context, input }) => { - const result = await context.workspaceService.resumeStream( - input.workspaceId, - input.options - ); - if (!result.success) { - const error = - typeof result.error === "string" - ? { type: "unknown" as const, raw: result.error } - : result.error; - return { success: false, error }; - } - return { success: true, data: undefined }; - }), - interruptStream: t - .input(schemas.workspace.interruptStream.input) - .output(schemas.workspace.interruptStream.output) - .handler(async ({ context, input }) => { - const result = await context.workspaceService.interruptStream( - input.workspaceId, - input.options - ); - if (!result.success) { - return { success: false, error: result.error }; - } - return { success: true, data: undefined }; - }), - clearQueue: t - .input(schemas.workspace.clearQueue.input) - .output(schemas.workspace.clearQueue.output) - .handler(({ context, input }) => { - const result = context.workspaceService.clearQueue(input.workspaceId); - if (!result.success) { - return { success: false, error: result.error }; - } - return { success: true, data: undefined }; - }), - truncateHistory: t - .input(schemas.workspace.truncateHistory.input) - .output(schemas.workspace.truncateHistory.output) - .handler(async ({ context, input }) => { - const result = await context.workspaceService.truncateHistory( - input.workspaceId, - input.percentage - ); - if (!result.success) { - return { success: false, error: result.error }; - } - return { success: true, data: undefined }; - }), - replaceChatHistory: t - .input(schemas.workspace.replaceChatHistory.input) - .output(schemas.workspace.replaceChatHistory.output) - .handler(async ({ context, input }) => { - const result = await context.workspaceService.replaceHistory( - input.workspaceId, - input.summaryMessage - ); - if (!result.success) { - return { success: false, error: result.error }; - } - return { success: true, data: undefined }; - }), - getInfo: t - .input(schemas.workspace.getInfo.input) - .output(schemas.workspace.getInfo.output) - .handler(async ({ context, input }) => { - return context.workspaceService.getInfo(input.workspaceId); - }), - getFullReplay: t - .input(schemas.workspace.getFullReplay.input) - .output(schemas.workspace.getFullReplay.output) - .handler(async ({ context, input }) => { - return context.workspaceService.getFullReplay(input.workspaceId); - }), - executeBash: t - .input(schemas.workspace.executeBash.input) - .output(schemas.workspace.executeBash.output) - .handler(async ({ context, input }) => { - const result = await context.workspaceService.executeBash( - input.workspaceId, - input.script, - input.options - ); - if (!result.success) { - return { success: false, error: result.error }; - } - return { success: true, data: result.data }; - }), - onChat: t - .input(schemas.workspace.onChat.input) - .output(schemas.workspace.onChat.output) - .handler(async function* ({ context, input }) { - const session = context.workspaceService.getOrCreateSession(input.workspaceId); - - let resolveNext: ((value: WorkspaceChatMessage) => void) | null = null; - const queue: WorkspaceChatMessage[] = []; - let ended = false; - - const push = (msg: WorkspaceChatMessage) => { - if (ended) return; - if (resolveNext) { - const resolve = resolveNext; - resolveNext = null; - resolve(msg); - } else { - queue.push(msg); - } - }; - - // 1. Subscribe to new events (including those triggered by replay) - const unsubscribe = session.onChatEvent(({ message }) => { - push(message); - }); - - // 2. Replay history - await session.replayHistory(({ message }) => { - push(message); - }); - - try { - while (!ended) { - if (queue.length > 0) { - yield queue.shift()!; - } else { - const msg = await new Promise((resolve) => { - resolveNext = resolve; - }); - yield msg; - } - } - } finally { - ended = true; - unsubscribe(); - } - }), - onMetadata: t - .input(schemas.workspace.onMetadata.input) - .output(schemas.workspace.onMetadata.output) - .handler(async function* ({ context }) { - const service = context.workspaceService; - - let resolveNext: - | ((value: { - workspaceId: string; - metadata: FrontendWorkspaceMetadataSchemaType | null; - }) => void) - | null = null; - const queue: Array<{ - workspaceId: string; - metadata: FrontendWorkspaceMetadataSchemaType | null; - }> = []; - let ended = false; - - const push = (event: { - workspaceId: string; - metadata: FrontendWorkspaceMetadataSchemaType | null; - }) => { - if (ended) return; - if (resolveNext) { - const resolve = resolveNext; - resolveNext = null; - resolve(event); - } else { - queue.push(event); - } - }; - - const onMetadata = (event: { - workspaceId: string; - metadata: FrontendWorkspaceMetadataSchemaType | null; - }) => { - push(event); - }; - - service.on("metadata", onMetadata); - - try { - while (!ended) { - if (queue.length > 0) { - yield queue.shift()!; - } else { - const event = await new Promise<{ - workspaceId: string; - metadata: FrontendWorkspaceMetadataSchemaType | null; - }>((resolve) => { - resolveNext = resolve; - }); - yield event; - } - } - } finally { - ended = true; - service.off("metadata", onMetadata); - } - }), - activity: { - list: t - .input(schemas.workspace.activity.list.input) - .output(schemas.workspace.activity.list.output) - .handler(async ({ context }) => { - return context.workspaceService.getActivityList(); - }), - subscribe: t - .input(schemas.workspace.activity.subscribe.input) - .output(schemas.workspace.activity.subscribe.output) - .handler(async function* ({ context }) { - const service = context.workspaceService; - - let resolveNext: - | ((value: { - workspaceId: string; - activity: WorkspaceActivitySnapshot | null; - }) => void) - | null = null; - const queue: Array<{ - workspaceId: string; - activity: WorkspaceActivitySnapshot | null; - }> = []; - let ended = false; - - const push = (event: { - workspaceId: string; - activity: WorkspaceActivitySnapshot | null; - }) => { - if (ended) return; - if (resolveNext) { - const resolve = resolveNext; - resolveNext = null; - resolve(event); - } else { - queue.push(event); - } - }; - - const onActivity = (event: { - workspaceId: string; - activity: WorkspaceActivitySnapshot | null; - }) => { - push(event); - }; - - service.on("activity", onActivity); - - try { - while (!ended) { - if (queue.length > 0) { - yield queue.shift()!; - } else { - const event = await new Promise<{ - workspaceId: string; - activity: WorkspaceActivitySnapshot | null; - }>((resolve) => { - resolveNext = resolve; - }); - yield event; - } - } - } finally { - ended = true; - service.off("activity", onActivity); - } - }), - }, - }, - window: { - setTitle: t - .input(schemas.window.setTitle.input) - .output(schemas.window.setTitle.output) - .handler(({ context, input }) => { - return context.windowService.setTitle(input.title); - }), - }, - terminal: { - create: t - .input(schemas.terminal.create.input) - .output(schemas.terminal.create.output) - .handler(async ({ context, input }) => { - return context.terminalService.create(input); - }), - close: t - .input(schemas.terminal.close.input) - .output(schemas.terminal.close.output) - .handler(({ context, input }) => { - return context.terminalService.close(input.sessionId); - }), - resize: t - .input(schemas.terminal.resize.input) - .output(schemas.terminal.resize.output) - .handler(({ context, input }) => { - return context.terminalService.resize(input); - }), - sendInput: t - .input(schemas.terminal.sendInput.input) - .output(schemas.terminal.sendInput.output) - .handler(({ context, input }) => { - context.terminalService.sendInput(input.sessionId, input.data); - }), - onOutput: t - .input(schemas.terminal.onOutput.input) - .output(schemas.terminal.onOutput.output) - .handler(async function* ({ context, input }) { - let resolveNext: ((value: string) => void) | null = null; - const queue: string[] = []; - let ended = false; - - const push = (data: string) => { - if (ended) return; - if (resolveNext) { - const resolve = resolveNext; - resolveNext = null; - resolve(data); - } else { - queue.push(data); - } - }; - - const unsubscribe = context.terminalService.onOutput(input.sessionId, push); - - try { - while (!ended) { - if (queue.length > 0) { - yield queue.shift()!; - } else { - const data = await new Promise((resolve) => { - resolveNext = resolve; - }); - yield data; - } - } - } finally { - ended = true; - unsubscribe(); - } - }), - onExit: t - .input(schemas.terminal.onExit.input) - .output(schemas.terminal.onExit.output) - .handler(async function* ({ context, input }) { - let resolveNext: ((value: number) => void) | null = null; - const queue: number[] = []; - let ended = false; - - const push = (code: number) => { - if (ended) return; - if (resolveNext) { - const resolve = resolveNext; - resolveNext = null; - resolve(code); - } else { - queue.push(code); - } - }; - - const unsubscribe = context.terminalService.onExit(input.sessionId, push); - - try { - while (!ended) { - if (queue.length > 0) { - yield queue.shift()!; - // Terminal only exits once, so we can finish the stream - break; - } else { - const code = await new Promise((resolve) => { - resolveNext = resolve; - }); - yield code; - break; - } - } - } finally { - ended = true; - unsubscribe(); - } - }), - openWindow: t - .input(schemas.terminal.openWindow.input) - .output(schemas.terminal.openWindow.output) - .handler(async ({ context, input }) => { - return context.terminalService.openWindow(input.workspaceId); - }), - closeWindow: t - .input(schemas.terminal.closeWindow.input) - .output(schemas.terminal.closeWindow.output) - .handler(({ context, input }) => { - return context.terminalService.closeWindow(input.workspaceId); - }), - openNative: t - .input(schemas.terminal.openNative.input) - .output(schemas.terminal.openNative.output) - .handler(async ({ context, input }) => { - return context.terminalService.openNative(input.workspaceId); - }), - }, - update: { - check: t - .input(schemas.update.check.input) - .output(schemas.update.check.output) - .handler(async ({ context }) => { - return context.updateService.check(); - }), - download: t - .input(schemas.update.download.input) - .output(schemas.update.download.output) - .handler(async ({ context }) => { - return context.updateService.download(); - }), - install: t - .input(schemas.update.install.input) - .output(schemas.update.install.output) - .handler(({ context }) => { - return context.updateService.install(); - }), - onStatus: t - .input(schemas.update.onStatus.input) - .output(schemas.update.onStatus.output) - .handler(async function* ({ context }) { - let resolveNext: ((value: UpdateStatus) => void) | null = null; - const queue: UpdateStatus[] = []; - let ended = false; - - const push = (status: UpdateStatus) => { - if (ended) return; - if (resolveNext) { - const resolve = resolveNext; - resolveNext = null; - resolve(status); - } else { - queue.push(status); - } - }; - - const unsubscribe = context.updateService.onStatus(push); - - try { - while (!ended) { - if (queue.length > 0) { - yield queue.shift()!; - } else { - const status = await new Promise((resolve) => { - resolveNext = resolve; - }); - yield status; - } - } - } finally { - ended = true; - unsubscribe(); - } - }), - }, - }); -}; - -export type AppRouter = ReturnType; diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 6c1c9a187..94697c46d 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -15,35 +15,17 @@ import type { StreamErrorMessage, SendMessageOptions, ImagePart, -} from "@/common/orpc/types"; +} from "@/common/types/ipc"; import type { SendMessageError } from "@/common/types/errors"; import { createUnknownSendMessageError } from "@/node/services/utils/sendMessageError"; import type { Result } from "@/common/types/result"; import { Ok, Err } from "@/common/types/result"; import { enforceThinkingPolicy } from "@/browser/utils/thinking/policy"; -import type { ToolPolicy } from "@/common/utils/tools/toolPolicy"; -import type { MuxFrontendMetadata } from "@/common/types/message"; import { createRuntime } from "@/node/runtime/runtimeFactory"; import { MessageQueue } from "./messageQueue"; import type { StreamEndEvent } from "@/common/types/stream"; import { CompactionHandler } from "./compactionHandler"; -// Type guard for compaction request metadata -interface CompactionRequestMetadata { - type: "compaction-request"; - parsed: { - continueMessage?: string; - }; -} - -function isCompactionRequestMetadata(meta: unknown): meta is CompactionRequestMetadata { - if (typeof meta !== "object" || meta === null) return false; - const obj = meta as Record; - if (obj.type !== "compaction-request") return false; - if (typeof obj.parsed !== "object" || obj.parsed === null) return false; - return true; -} - export interface AgentSessionChatEvent { workspaceId: string; message: WorkspaceChatMessage; @@ -293,7 +275,7 @@ export class AgentSession { if (this.aiService.isStreaming(this.workspaceId)) { // MUST use abandonPartial=true to prevent handleAbort from performing partial compaction // with mismatched history (since we're about to truncate it) - const stopResult = await this.interruptStream({ abandonPartial: true }); + const stopResult = await this.interruptStream(/* abandonPartial */ true); if (!stopResult.success) { return Err(createUnknownSendMessageError(stopResult.error)); } @@ -331,18 +313,14 @@ export class AgentSession { }) : undefined; - // Cast from z.any() schema types to proper types (schema uses any for complex recursive types) - const typedToolPolicy = options?.toolPolicy as ToolPolicy | undefined; - const typedMuxMetadata = options?.muxMetadata as MuxFrontendMetadata | undefined; - const userMessage = createMuxMessage( messageId, "user", message, { timestamp: Date.now(), - toolPolicy: typedToolPolicy, - muxMetadata: typedMuxMetadata, // Pass through frontend metadata as black-box + toolPolicy: options?.toolPolicy, + muxMetadata: options?.muxMetadata, // Pass through frontend metadata as black-box }, additionalParts ); @@ -355,30 +333,20 @@ export class AgentSession { this.emitChatEvent(userMessage); // If this is a compaction request with a continue message, queue it for auto-send after compaction - if ( - isCompactionRequestMetadata(typedMuxMetadata) && - typedMuxMetadata.parsed.continueMessage && - options - ) { + const muxMeta = options?.muxMetadata; + if (muxMeta?.type === "compaction-request" && muxMeta.parsed.continueMessage && options) { // Strip out compaction-specific fields so the queued message is a fresh user message - // Use Omit to avoid unsafe destructuring of any-typed muxMetadata - const continueMessage = typedMuxMetadata.parsed.continueMessage; - const sanitizedOptions: Omit< - SendMessageOptions, - "muxMetadata" | "mode" | "editMessageId" | "imageParts" | "maxOutputTokens" - > & { imageParts?: typeof continueMessage.imageParts } = { - model: continueMessage.model ?? options.model, - thinkingLevel: options.thinkingLevel, - toolPolicy: options.toolPolicy as ToolPolicy | undefined, - additionalSystemInstructions: options.additionalSystemInstructions, - providerOptions: options.providerOptions, + const { muxMetadata, mode, editMessageId, imageParts, maxOutputTokens, ...rest } = options; + const sanitizedOptions: SendMessageOptions = { + ...rest, + model: muxMeta.parsed.continueMessage.model ?? rest.model, }; - const continueImageParts = continueMessage.imageParts; + const continueImageParts = muxMeta.parsed.continueMessage.imageParts; const continuePayload = continueImageParts && continueImageParts.length > 0 ? { ...sanitizedOptions, imageParts: continueImageParts } : sanitizedOptions; - this.messageQueue.add(continueMessage.text, continuePayload); + this.messageQueue.add(muxMeta.parsed.continueMessage.text, continuePayload); this.emitQueuedMessageChanged(); } @@ -405,27 +373,24 @@ export class AgentSession { return this.streamWithHistory(model, options); } - async interruptStream(options?: { - soft?: boolean; - abandonPartial?: boolean; - }): Promise> { + async interruptStream(abandonPartial?: boolean): Promise> { this.assertNotDisposed("interruptStream"); if (!this.aiService.isStreaming(this.workspaceId)) { return Ok(undefined); } - // For hard interrupts, delete partial BEFORE stopping to prevent abort handler - // from committing it. For soft interrupts, defer to stream-abort handler since - // the stream continues running and would recreate the partial. - if (options?.abandonPartial && !options?.soft) { + // Delete partial BEFORE stopping to prevent abort handler from committing it + // The abort handler in aiService.ts runs immediately when stopStream is called, + // so we must delete first to ensure it finds no partial to commit + if (abandonPartial) { const deleteResult = await this.partialService.deletePartial(this.workspaceId); if (!deleteResult.success) { return Err(deleteResult.error); } } - const stopResult = await this.aiService.stopStream(this.workspaceId, options); + const stopResult = await this.aiService.stopStream(this.workspaceId, abandonPartial); if (!stopResult.success) { return Err(stopResult.error); } @@ -458,7 +423,7 @@ export class AgentSession { this.workspaceId, modelString, effectiveThinkingLevel, - options?.toolPolicy as ToolPolicy | undefined, + options?.toolPolicy, undefined, options?.additionalSystemInstructions, options?.maxOutputTokens, diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index 309c6a7e3..4c59caa61 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -70,7 +70,6 @@ const defaultFetchWithUnlimitedTimeout = (async ( input: RequestInfo | URL, init?: RequestInit ): Promise => { - // dispatcher is a Node.js undici-specific property for custom HTTP agents const requestInit: RequestInit = { ...(init ?? {}), dispatcher: unlimitedTimeoutAgent, @@ -197,20 +196,17 @@ export class AIService extends EventEmitter { this.streamManager.on("stream-delta", (data) => this.emit("stream-delta", data)); this.streamManager.on("stream-end", (data) => this.emit("stream-end", data)); - // Handle stream-abort: dispose of partial based on abandonPartial flag + // Handle stream-abort: commit partial to history before forwarding + // Note: If abandonPartial option was used, partial is already deleted by IPC handler this.streamManager.on("stream-abort", (data: StreamAbortEvent) => { void (async () => { - if (data.abandonPartial) { - // Caller requested discarding partial - delete without committing - await this.partialService.deletePartial(data.workspaceId); - } else { + // Check if partial still exists (not abandoned) + const partial = await this.partialService.readPartial(data.workspaceId); + if (partial) { // Commit interrupted message to history with partial:true metadata // This ensures /clear and /truncate can clean up interrupted messages - const partial = await this.partialService.readPartial(data.workspaceId); - if (partial) { - await this.partialService.commitToHistory(data.workspaceId); - await this.partialService.deletePartial(data.workspaceId); - } + await this.partialService.commitToHistory(data.workspaceId); + await this.partialService.deletePartial(data.workspaceId); } // Forward abort event to consumers @@ -1087,15 +1083,12 @@ export class AIService extends EventEmitter { } } - async stopStream( - workspaceId: string, - options?: { soft?: boolean; abandonPartial?: boolean } - ): Promise> { + async stopStream(workspaceId: string, abandonPartial?: boolean): Promise> { if (this.mockModeEnabled && this.mockScenarioPlayer) { this.mockScenarioPlayer.stop(workspaceId); return Ok(undefined); } - return this.streamManager.stopStream(workspaceId, options); + return this.streamManager.stopStream(workspaceId, abandonPartial); } /** diff --git a/src/node/services/compactionHandler.ts b/src/node/services/compactionHandler.ts index 5afc20602..351f6ca5c 100644 --- a/src/node/services/compactionHandler.ts +++ b/src/node/services/compactionHandler.ts @@ -1,7 +1,7 @@ import type { EventEmitter } from "events"; import type { HistoryService } from "./historyService"; import type { StreamEndEvent } from "@/common/types/stream"; -import type { WorkspaceChatMessage, DeleteMessage } from "@/common/orpc/types"; +import type { WorkspaceChatMessage, DeleteMessage } from "@/common/types/ipc"; import type { Result } from "@/common/types/result"; import { Ok, Err } from "@/common/types/result"; import type { LanguageModelV2Usage } from "@ai-sdk/provider"; diff --git a/src/node/services/initStateManager.test.ts b/src/node/services/initStateManager.test.ts index d92a87d9a..b520b3347 100644 --- a/src/node/services/initStateManager.test.ts +++ b/src/node/services/initStateManager.test.ts @@ -4,7 +4,7 @@ import * as os from "os"; import { describe, it, expect, beforeEach, afterEach } from "bun:test"; import { Config } from "@/node/config"; import { InitStateManager } from "./initStateManager"; -import type { WorkspaceInitEvent } from "@/common/orpc/types"; +import type { WorkspaceInitEvent } from "@/common/types/ipc"; describe("InitStateManager", () => { let tempDir: string; diff --git a/src/node/services/initStateManager.ts b/src/node/services/initStateManager.ts index 1190630f3..336521a84 100644 --- a/src/node/services/initStateManager.ts +++ b/src/node/services/initStateManager.ts @@ -1,7 +1,7 @@ import { EventEmitter } from "events"; import type { Config } from "@/node/config"; import { EventStore } from "@/node/utils/eventStore"; -import type { WorkspaceInitEvent } from "@/common/orpc/types"; +import type { WorkspaceInitEvent } from "@/common/types/ipc"; import { log } from "@/node/services/log"; /** diff --git a/src/node/services/ipcMain.ts b/src/node/services/ipcMain.ts new file mode 100644 index 000000000..53c16cb1b --- /dev/null +++ b/src/node/services/ipcMain.ts @@ -0,0 +1,2164 @@ +import assert from "@/common/utils/assert"; +import type { BrowserWindow, IpcMain as ElectronIpcMain, IpcMainInvokeEvent } from "electron"; +import { spawn, spawnSync } from "child_process"; +import * as fsPromises from "fs/promises"; +import * as path from "path"; +import type { Config, ProjectConfig } from "@/node/config"; +import { listLocalBranches, detectDefaultTrunkBranch } from "@/node/git"; +import { AIService } from "@/node/services/aiService"; +import { HistoryService } from "@/node/services/historyService"; +import { PartialService } from "@/node/services/partialService"; +import { AgentSession } from "@/node/services/agentSession"; +import type { MuxMessage } from "@/common/types/message"; +import { log } from "@/node/services/log"; +import { countTokens, countTokensBatch } from "@/node/utils/main/tokenizer"; +import { calculateTokenStats } from "@/common/utils/tokens/tokenStatsCalculator"; +import { IPC_CHANNELS, getChatChannel } from "@/common/constants/ipc-constants"; +import { SUPPORTED_PROVIDERS } from "@/common/constants/providers"; +import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace"; +import type { SendMessageError } from "@/common/types/errors"; +import type { + SendMessageOptions, + DeleteMessage, + ImagePart, + WorkspaceChatMessage, +} from "@/common/types/ipc"; +import { Ok, Err, type Result } from "@/common/types/result"; +import { validateWorkspaceName } from "@/common/utils/validation/workspaceValidation"; +import type { + WorkspaceMetadata, + FrontendWorkspaceMetadata, + WorkspaceActivitySnapshot, +} from "@/common/types/workspace"; +import type { StreamEndEvent, StreamAbortEvent } from "@/common/types/stream"; +import { createBashTool } from "@/node/services/tools/bash"; +import type { BashToolResult } from "@/common/types/tools"; +import { secretsToRecord } from "@/common/types/secrets"; +import { DisposableTempDir } from "@/node/services/tempDir"; +import { InitStateManager } from "@/node/services/initStateManager"; +import { createRuntime } from "@/node/runtime/runtimeFactory"; +import type { RuntimeConfig } from "@/common/types/runtime"; +import { isSSHRuntime } from "@/common/types/runtime"; +import { validateProjectPath } from "@/node/utils/pathUtils"; +import { PTYService } from "@/node/services/ptyService"; +import type { TerminalWindowManager } from "@/desktop/terminalWindowManager"; +import type { TerminalCreateParams, TerminalResizeParams } from "@/common/types/terminal"; +import { ExtensionMetadataService } from "@/node/services/ExtensionMetadataService"; +import { generateWorkspaceName } from "./workspaceTitleGenerator"; +/** + * IpcMain - Manages all IPC handlers and service coordination + * + * This class encapsulates: + * - All ipcMain handler registration + * - Service lifecycle management (AIService, HistoryService, PartialService, InitStateManager) + * - Event forwarding from services to renderer + * + * Design: + * - Constructor accepts only Config for dependency injection + * - Services are created internally from Config + * - register() accepts ipcMain and BrowserWindow for handler setup + */ +export class IpcMain { + private readonly config: Config; + private readonly historyService: HistoryService; + private readonly partialService: PartialService; + private readonly aiService: AIService; + private readonly initStateManager: InitStateManager; + private readonly extensionMetadata: ExtensionMetadataService; + private readonly ptyService: PTYService; + private terminalWindowManager?: TerminalWindowManager; + private readonly sessions = new Map(); + private projectDirectoryPicker?: (event: IpcMainInvokeEvent) => Promise; + + private readonly sessionSubscriptions = new Map< + string, + { chat: () => void; metadata: () => void } + >(); + private mainWindow: BrowserWindow | null = null; + + private registered = false; + + constructor(config: Config) { + this.config = config; + this.historyService = new HistoryService(config); + this.partialService = new PartialService(config, this.historyService); + this.initStateManager = new InitStateManager(config); + this.extensionMetadata = new ExtensionMetadataService( + path.join(config.rootDir, "extensionMetadata.json") + ); + this.aiService = new AIService( + config, + this.historyService, + this.partialService, + this.initStateManager + ); + // Terminal services - PTYService is cross-platform + this.ptyService = new PTYService(); + + // Listen to AIService events to update metadata + this.setupMetadataListeners(); + } + + /** + * Initialize the service. Call this after construction. + * This is separate from the constructor to support async initialization. + */ + async initialize(): Promise { + await this.extensionMetadata.initialize(); + } + + /** + * Configure a picker used to select project directories (desktop mode only). + * Server mode does not provide a native directory picker. + */ + setProjectDirectoryPicker(picker: (event: IpcMainInvokeEvent) => Promise): void { + this.projectDirectoryPicker = picker; + } + + /** + * Set the terminal window manager (desktop mode only). + * Server mode doesn't use pop-out terminal windows. + */ + setTerminalWindowManager(manager: TerminalWindowManager): void { + this.terminalWindowManager = manager; + } + + /** + * Setup listeners to update metadata store based on AIService events. + * This tracks workspace recency and streaming status for VS Code extension integration. + */ + private setupMetadataListeners(): void { + const isObj = (v: unknown): v is Record => typeof v === "object" && v !== null; + const isWorkspaceEvent = (v: unknown): v is { workspaceId: string } => + isObj(v) && "workspaceId" in v && typeof v.workspaceId === "string"; + const isStreamStartEvent = (v: unknown): v is { workspaceId: string; model: string } => + isWorkspaceEvent(v) && "model" in v && typeof v.model === "string"; + const isStreamEndEvent = (v: unknown): v is StreamEndEvent => + isWorkspaceEvent(v) && + (!("metadata" in (v as Record)) || isObj((v as StreamEndEvent).metadata)); + const isStreamAbortEvent = (v: unknown): v is StreamAbortEvent => isWorkspaceEvent(v); + const extractTimestamp = (event: StreamEndEvent | { metadata?: { timestamp?: number } }) => { + const raw = event.metadata?.timestamp; + return typeof raw === "number" && Number.isFinite(raw) ? raw : Date.now(); + }; + + // Update streaming status and recency on stream start + this.aiService.on("stream-start", (data: unknown) => { + if (isStreamStartEvent(data)) { + void this.updateStreamingStatus(data.workspaceId, true, data.model); + } + }); + + this.aiService.on("stream-end", (data: unknown) => { + if (isStreamEndEvent(data)) { + void this.handleStreamCompletion(data.workspaceId, extractTimestamp(data)); + } + }); + + this.aiService.on("stream-abort", (data: unknown) => { + if (isStreamAbortEvent(data)) { + void this.updateStreamingStatus(data.workspaceId, false); + } + }); + } + + private emitWorkspaceActivity( + workspaceId: string, + snapshot: WorkspaceActivitySnapshot | null + ): void { + if (!this.mainWindow) { + return; + } + this.mainWindow.webContents.send(IPC_CHANNELS.WORKSPACE_ACTIVITY, { + workspaceId, + activity: snapshot, + }); + } + + private async updateRecencyTimestamp(workspaceId: string, timestamp?: number): Promise { + try { + const snapshot = await this.extensionMetadata.updateRecency( + workspaceId, + timestamp ?? Date.now() + ); + this.emitWorkspaceActivity(workspaceId, snapshot); + } catch (error) { + log.error("Failed to update workspace recency", { workspaceId, error }); + } + } + + private async updateStreamingStatus( + workspaceId: string, + streaming: boolean, + model?: string + ): Promise { + try { + const snapshot = await this.extensionMetadata.setStreaming(workspaceId, streaming, model); + this.emitWorkspaceActivity(workspaceId, snapshot); + } catch (error) { + log.error("Failed to update workspace streaming status", { workspaceId, error }); + } + } + + private async handleStreamCompletion(workspaceId: string, timestamp: number): Promise { + await this.updateRecencyTimestamp(workspaceId, timestamp); + await this.updateStreamingStatus(workspaceId, false); + } + + /** + * Create InitLogger that bridges to InitStateManager + * Extracted helper to avoid duplication across workspace creation paths + */ + private createInitLogger(workspaceId: string) { + return { + logStep: (message: string) => { + this.initStateManager.appendOutput(workspaceId, message, false); + }, + logStdout: (line: string) => { + this.initStateManager.appendOutput(workspaceId, line, false); + }, + logStderr: (line: string) => { + this.initStateManager.appendOutput(workspaceId, line, true); + }, + logComplete: (exitCode: number) => { + void this.initStateManager.endInit(workspaceId, exitCode); + }, + }; + } + + /** + * Create a new workspace with AI-generated title and branch name + * Extracted from sendMessage handler to reduce complexity + */ + private async createWorkspaceForFirstMessage( + message: string, + projectPath: string, + options: SendMessageOptions & { + imageParts?: Array<{ url: string; mediaType: string }>; + runtimeConfig?: RuntimeConfig; + trunkBranch?: string; + } + ): Promise< + | { success: true; workspaceId: string; metadata: FrontendWorkspaceMetadata } + | Result + > { + try { + // 1. Generate workspace branch name using AI (use same model as message) + let branchName: string; + { + const isErrLike = (v: unknown): v is { type: string } => + typeof v === "object" && v !== null && "type" in v; + const nameResult = await generateWorkspaceName(message, options.model, this.aiService); + if (!nameResult.success) { + const err = nameResult.error; + if (isErrLike(err)) { + return Err(err); + } + const toSafeString = (v: unknown): string => { + if (v instanceof Error) return v.message; + try { + return JSON.stringify(v); + } catch { + return String(v); + } + }; + const msg = toSafeString(err); + return Err({ type: "unknown", raw: `Failed to generate workspace name: ${msg}` }); + } + branchName = nameResult.data; + } + + log.debug("Generated workspace name", { branchName }); + + // 2. Get trunk branch (use provided trunkBranch or auto-detect) + const branches = await listLocalBranches(projectPath); + const recommendedTrunk = + options.trunkBranch ?? (await detectDefaultTrunkBranch(projectPath, branches)) ?? "main"; + + // 3. Create workspace + const finalRuntimeConfig: RuntimeConfig = options.runtimeConfig ?? { + type: "local", + srcBaseDir: this.config.srcDir, + }; + + const workspaceId = this.config.generateStableId(); + + let runtime; + let resolvedSrcBaseDir: string; + try { + runtime = createRuntime(finalRuntimeConfig); + resolvedSrcBaseDir = await runtime.resolvePath(finalRuntimeConfig.srcBaseDir); + + if (resolvedSrcBaseDir !== finalRuntimeConfig.srcBaseDir) { + const resolvedRuntimeConfig: RuntimeConfig = { + ...finalRuntimeConfig, + srcBaseDir: resolvedSrcBaseDir, + }; + runtime = createRuntime(resolvedRuntimeConfig); + finalRuntimeConfig.srcBaseDir = resolvedSrcBaseDir; + } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + return Err({ type: "unknown", raw: `Failed to prepare runtime: ${errorMsg}` }); + } + + const session = this.getOrCreateSession(workspaceId); + this.initStateManager.startInit(workspaceId, projectPath); + + const initLogger = this.createInitLogger(workspaceId); + + const createResult = await runtime.createWorkspace({ + projectPath, + branchName, + trunkBranch: recommendedTrunk, + directoryName: branchName, + initLogger, + }); + + if (!createResult.success || !createResult.workspacePath) { + return Err({ type: "unknown", raw: createResult.error ?? "Failed to create workspace" }); + } + + const projectName = + projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "unknown"; + + const metadata = { + id: workspaceId, + name: branchName, + projectName, + projectPath, + createdAt: new Date().toISOString(), + }; + + await this.config.editConfig((config) => { + let projectConfig = config.projects.get(projectPath); + if (!projectConfig) { + projectConfig = { workspaces: [] }; + config.projects.set(projectPath, projectConfig); + } + projectConfig.workspaces.push({ + path: createResult.workspacePath!, + id: workspaceId, + name: branchName, + createdAt: metadata.createdAt, + runtimeConfig: finalRuntimeConfig, + }); + return config; + }); + + const allMetadata = await this.config.getAllWorkspaceMetadata(); + const completeMetadata = allMetadata.find((m) => m.id === workspaceId); + if (!completeMetadata) { + return Err({ type: "unknown", raw: "Failed to retrieve workspace metadata" }); + } + + session.emitMetadata(completeMetadata); + + void runtime + .initWorkspace({ + projectPath, + branchName, + trunkBranch: recommendedTrunk, + workspacePath: createResult.workspacePath, + initLogger, + }) + .catch((error: unknown) => { + const errorMsg = error instanceof Error ? error.message : String(error); + log.error(`initWorkspace failed for ${workspaceId}:`, error); + initLogger.logStderr(`Initialization failed: ${errorMsg}`); + initLogger.logComplete(-1); + }); + + // Send message to new workspace + void session.sendMessage(message, options); + + return { + success: true, + workspaceId, + metadata: completeMetadata, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log.error("Unexpected error in createWorkspaceForFirstMessage:", error); + return Err({ type: "unknown", raw: `Failed to create workspace: ${errorMessage}` }); + } + } + + private getOrCreateSession(workspaceId: string): AgentSession { + assert(typeof workspaceId === "string", "workspaceId must be a string"); + const trimmed = workspaceId.trim(); + assert(trimmed.length > 0, "workspaceId must not be empty"); + + let session = this.sessions.get(trimmed); + if (session) { + return session; + } + + session = new AgentSession({ + workspaceId: trimmed, + config: this.config, + historyService: this.historyService, + partialService: this.partialService, + aiService: this.aiService, + initStateManager: this.initStateManager, + }); + + const chatUnsubscribe = session.onChatEvent((event) => { + if (!this.mainWindow) { + return; + } + const channel = getChatChannel(event.workspaceId); + this.mainWindow.webContents.send(channel, event.message); + }); + + const metadataUnsubscribe = session.onMetadataEvent((event) => { + if (!this.mainWindow) { + return; + } + this.mainWindow.webContents.send(IPC_CHANNELS.WORKSPACE_METADATA, { + workspaceId: event.workspaceId, + metadata: event.metadata, + }); + }); + + this.sessions.set(trimmed, session); + this.sessionSubscriptions.set(trimmed, { + chat: chatUnsubscribe, + metadata: metadataUnsubscribe, + }); + + return session; + } + + private disposeSession(workspaceId: string): void { + const session = this.sessions.get(workspaceId); + if (!session) { + return; + } + + const subscriptions = this.sessionSubscriptions.get(workspaceId); + if (subscriptions) { + subscriptions.chat(); + subscriptions.metadata(); + this.sessionSubscriptions.delete(workspaceId); + } + + session.dispose(); + this.sessions.delete(workspaceId); + } + + /** + * Register all IPC handlers and setup event forwarding + * @param ipcMain - Electron's ipcMain module + * @param mainWindow - The main BrowserWindow for sending events + */ + private registerFsHandlers(ipcMain: ElectronIpcMain): void { + ipcMain.handle(IPC_CHANNELS.FS_LIST_DIRECTORY, async (_event, root: string) => { + try { + const normalizedRoot = path.resolve(root || "."); + const entries = await fsPromises.readdir(normalizedRoot, { withFileTypes: true }); + + const children = entries + .filter((entry) => entry.isDirectory()) + .map((entry) => { + const entryPath = path.join(normalizedRoot, entry.name); + return { + name: entry.name, + path: entryPath, + isDirectory: true, + children: [], + }; + }); + + return { + name: normalizedRoot, + path: normalizedRoot, + isDirectory: true, + children, + }; + } catch (error) { + log.error("FS_LIST_DIRECTORY failed:", error); + throw error instanceof Error ? error : new Error(String(error)); + } + }); + } + + register(ipcMain: ElectronIpcMain, mainWindow: BrowserWindow): void { + // Always update the window reference (windows can be recreated on macOS) + this.mainWindow = mainWindow; + + // Skip registration if handlers are already registered + // This prevents "handler already registered" errors when windows are recreated + if (this.registered) { + return; + } + + // Terminal server starts lazily when first terminal is opened + this.registerWindowHandlers(ipcMain); + this.registerTokenizerHandlers(ipcMain); + this.registerWorkspaceHandlers(ipcMain); + this.registerProviderHandlers(ipcMain); + this.registerFsHandlers(ipcMain); + this.registerProjectHandlers(ipcMain); + this.registerTerminalHandlers(ipcMain, mainWindow); + this.registerSubscriptionHandlers(ipcMain); + this.registered = true; + } + + private registerWindowHandlers(ipcMain: ElectronIpcMain): void { + ipcMain.handle(IPC_CHANNELS.WINDOW_SET_TITLE, (_event, title: string) => { + if (!this.mainWindow) return; + this.mainWindow.setTitle(title); + }); + } + + private registerTokenizerHandlers(ipcMain: ElectronIpcMain): void { + ipcMain.handle( + IPC_CHANNELS.TOKENIZER_COUNT_TOKENS, + async (_event, model: string, input: string) => { + assert( + typeof model === "string" && model.length > 0, + "Tokenizer countTokens requires model name" + ); + assert(typeof input === "string", "Tokenizer countTokens requires text"); + return countTokens(model, input); + } + ); + + ipcMain.handle( + IPC_CHANNELS.TOKENIZER_COUNT_TOKENS_BATCH, + async (_event, model: string, texts: unknown[]) => { + assert( + typeof model === "string" && model.length > 0, + "Tokenizer countTokensBatch requires model name" + ); + assert(Array.isArray(texts), "Tokenizer countTokensBatch requires an array of strings"); + return countTokensBatch(model, texts as string[]); + } + ); + + ipcMain.handle( + IPC_CHANNELS.TOKENIZER_CALCULATE_STATS, + async (_event, messages: MuxMessage[], model: string) => { + assert(Array.isArray(messages), "Tokenizer IPC requires an array of messages"); + assert(typeof model === "string" && model.length > 0, "Tokenizer IPC requires model name"); + + try { + return await calculateTokenStats(messages, model); + } catch (error) { + log.error("[IpcMain] Token stats calculation failed", error); + throw error; + } + } + ); + } + + private registerWorkspaceHandlers(ipcMain: ElectronIpcMain): void { + ipcMain.handle( + IPC_CHANNELS.WORKSPACE_CREATE, + async ( + _event, + projectPath: string, + branchName: string, + trunkBranch: string, + runtimeConfig?: RuntimeConfig + ) => { + // Validate workspace name + const validation = validateWorkspaceName(branchName); + if (!validation.valid) { + return { success: false, error: validation.error }; + } + + if (typeof trunkBranch !== "string" || trunkBranch.trim().length === 0) { + return { success: false, error: "Trunk branch is required" }; + } + + const normalizedTrunkBranch = trunkBranch.trim(); + + // Generate stable workspace ID (stored in config, not used for directory name) + const workspaceId = this.config.generateStableId(); + + // Create runtime for workspace creation (defaults to local with srcDir as base) + const finalRuntimeConfig: RuntimeConfig = runtimeConfig ?? { + type: "local", + srcBaseDir: this.config.srcDir, + }; + + // Create temporary runtime to resolve srcBaseDir path + // This allows tilde paths to work for both local and SSH runtimes + let runtime; + let resolvedSrcBaseDir: string; + try { + runtime = createRuntime(finalRuntimeConfig); + + // Resolve srcBaseDir to absolute path (expanding tildes, etc.) + resolvedSrcBaseDir = await runtime.resolvePath(finalRuntimeConfig.srcBaseDir); + + // If path was resolved to something different, recreate runtime with resolved path + if (resolvedSrcBaseDir !== finalRuntimeConfig.srcBaseDir) { + const resolvedRuntimeConfig: RuntimeConfig = { + ...finalRuntimeConfig, + srcBaseDir: resolvedSrcBaseDir, + }; + runtime = createRuntime(resolvedRuntimeConfig); + // Update finalRuntimeConfig to store resolved path in config + finalRuntimeConfig.srcBaseDir = resolvedSrcBaseDir; + } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + return { success: false, error: errorMsg }; + } + + // Create session BEFORE starting init so events can be forwarded + const session = this.getOrCreateSession(workspaceId); + + // Start init tracking (creates in-memory state + emits init-start event) + // This MUST complete before workspace creation returns so replayInit() finds state + this.initStateManager.startInit(workspaceId, projectPath); + + const initLogger = this.createInitLogger(workspaceId); + + // Phase 1: Create workspace structure (FAST - returns immediately) + const createResult = await runtime.createWorkspace({ + projectPath, + branchName, + trunkBranch: normalizedTrunkBranch, + directoryName: branchName, // Use branch name as directory name + initLogger, + }); + + if (!createResult.success || !createResult.workspacePath) { + return { success: false, error: createResult.error ?? "Failed to create workspace" }; + } + + const projectName = + projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "unknown"; + + // Initialize workspace metadata with stable ID and name + const metadata = { + id: workspaceId, + name: branchName, // Name is separate from ID + projectName, + projectPath, // Full project path for computing worktree path + createdAt: new Date().toISOString(), + }; + // Note: metadata.json no longer written - config is the only source of truth + + // Update config to include the new workspace (with full metadata) + await this.config.editConfig((config) => { + let projectConfig = config.projects.get(projectPath); + if (!projectConfig) { + // Create project config if it doesn't exist + projectConfig = { + workspaces: [], + }; + config.projects.set(projectPath, projectConfig); + } + // Add workspace to project config with full metadata + projectConfig.workspaces.push({ + path: createResult.workspacePath!, + id: workspaceId, + name: branchName, + createdAt: metadata.createdAt, + runtimeConfig: finalRuntimeConfig, // Save runtime config for exec operations + }); + return config; + }); + + // No longer creating symlinks - directory name IS the workspace name + + // Get complete metadata from config (includes paths) + const allMetadata = await this.config.getAllWorkspaceMetadata(); + const completeMetadata = allMetadata.find((m) => m.id === workspaceId); + if (!completeMetadata) { + return { success: false, error: "Failed to retrieve workspace metadata" }; + } + + // Emit metadata event for new workspace (session already created above) + session.emitMetadata(completeMetadata); + + // Phase 2: Initialize workspace asynchronously (SLOW - runs in background) + // This streams progress via initLogger and doesn't block the IPC return + void runtime + .initWorkspace({ + projectPath, + branchName, + trunkBranch: normalizedTrunkBranch, + workspacePath: createResult.workspacePath, + initLogger, + }) + .catch((error: unknown) => { + const errorMsg = error instanceof Error ? error.message : String(error); + log.error(`initWorkspace failed for ${workspaceId}:`, error); + initLogger.logStderr(`Initialization failed: ${errorMsg}`); + initLogger.logComplete(-1); + }); + + // Return immediately - init streams separately via initLogger events + return { + success: true, + metadata: completeMetadata, + }; + } + ); + + // Provide chat history and replay helpers for server mode + ipcMain.handle(IPC_CHANNELS.WORKSPACE_CHAT_GET_HISTORY, async (_event, workspaceId: string) => { + return await this.getWorkspaceChatHistory(workspaceId); + }); + ipcMain.handle( + IPC_CHANNELS.WORKSPACE_CHAT_GET_FULL_REPLAY, + async (_event, workspaceId: string) => { + return await this.getFullReplayEvents(workspaceId); + } + ); + ipcMain.handle(IPC_CHANNELS.WORKSPACE_ACTIVITY_LIST, async () => { + const snapshots = await this.extensionMetadata.getAllSnapshots(); + return Object.fromEntries(snapshots.entries()); + }); + + ipcMain.handle( + IPC_CHANNELS.WORKSPACE_REMOVE, + async (_event, workspaceId: string, options?: { force?: boolean }) => { + return this.removeWorkspaceInternal(workspaceId, { force: options?.force ?? false }); + } + ); + + ipcMain.handle( + IPC_CHANNELS.WORKSPACE_RENAME, + async (_event, workspaceId: string, newName: string) => { + try { + // Block rename during active streaming to prevent race conditions + // (bash processes would have stale cwd, system message would be wrong) + if (this.aiService.isStreaming(workspaceId)) { + return Err( + "Cannot rename workspace while AI stream is active. Please wait for the stream to complete." + ); + } + + // Validate workspace name + const validation = validateWorkspaceName(newName); + if (!validation.valid) { + return Err(validation.error ?? "Invalid workspace name"); + } + + // Get current metadata + const metadataResult = await this.aiService.getWorkspaceMetadata(workspaceId); + if (!metadataResult.success) { + return Err(`Failed to get workspace metadata: ${metadataResult.error}`); + } + const oldMetadata = metadataResult.data; + const oldName = oldMetadata.name; + + // If renaming to itself, just return success (no-op) + if (newName === oldName) { + return Ok({ newWorkspaceId: workspaceId }); + } + + // Check if new name collides with existing workspace name or ID + const allWorkspaces = await this.config.getAllWorkspaceMetadata(); + const collision = allWorkspaces.find( + (ws) => (ws.name === newName || ws.id === newName) && ws.id !== workspaceId + ); + if (collision) { + return Err(`Workspace with name "${newName}" already exists`); + } + + // Find project path from config + const workspace = this.config.findWorkspace(workspaceId); + if (!workspace) { + return Err("Failed to find workspace in config"); + } + const { projectPath } = workspace; + + // Create runtime instance for this workspace + // For local runtimes, workdir should be srcDir, not the individual workspace path + const runtime = createRuntime( + oldMetadata.runtimeConfig ?? { type: "local", srcBaseDir: this.config.srcDir } + ); + + // Delegate rename to runtime (handles both local and SSH) + // Runtime computes workspace paths internally from workdir + projectPath + workspace names + const renameResult = await runtime.renameWorkspace(projectPath, oldName, newName); + + if (!renameResult.success) { + return Err(renameResult.error); + } + + const { oldPath, newPath } = renameResult; + + // Update config with new name and path + await this.config.editConfig((config) => { + const projectConfig = config.projects.get(projectPath); + if (projectConfig) { + const workspaceEntry = projectConfig.workspaces.find((w) => w.path === oldPath); + if (workspaceEntry) { + workspaceEntry.name = newName; + workspaceEntry.path = newPath; // Update path to reflect new directory name + + // Note: We don't need to update runtimeConfig.srcBaseDir on rename + // because srcBaseDir is the base directory, not the individual workspace path + // The workspace path is computed dynamically via runtime.getWorkspacePath() + } + } + return config; + }); + + // Get updated metadata from config (includes updated name and paths) + const allMetadata = await this.config.getAllWorkspaceMetadata(); + const updatedMetadata = allMetadata.find((m) => m.id === workspaceId); + if (!updatedMetadata) { + return Err("Failed to retrieve updated workspace metadata"); + } + + // Emit metadata event with updated metadata (same workspace ID) + const session = this.sessions.get(workspaceId); + if (session) { + session.emitMetadata(updatedMetadata); + } else if (this.mainWindow) { + this.mainWindow.webContents.send(IPC_CHANNELS.WORKSPACE_METADATA, { + workspaceId, + metadata: updatedMetadata, + }); + } + + return Ok({ newWorkspaceId: workspaceId }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return Err(`Failed to rename workspace: ${message}`); + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.WORKSPACE_FORK, + async (_event, sourceWorkspaceId: string, newName: string) => { + try { + // Validate new workspace name + const validation = validateWorkspaceName(newName); + if (!validation.valid) { + return { success: false, error: validation.error }; + } + + // If streaming, commit the partial response to history first + // This preserves the streamed content in both workspaces + if (this.aiService.isStreaming(sourceWorkspaceId)) { + await this.partialService.commitToHistory(sourceWorkspaceId); + } + + // Get source workspace metadata + const sourceMetadataResult = await this.aiService.getWorkspaceMetadata(sourceWorkspaceId); + if (!sourceMetadataResult.success) { + return { + success: false, + error: `Failed to get source workspace metadata: ${sourceMetadataResult.error}`, + }; + } + const sourceMetadata = sourceMetadataResult.data; + const foundProjectPath = sourceMetadata.projectPath; + const projectName = sourceMetadata.projectName; + + // Create runtime for source workspace + const sourceRuntimeConfig = sourceMetadata.runtimeConfig ?? { + type: "local", + srcBaseDir: this.config.srcDir, + }; + const runtime = createRuntime(sourceRuntimeConfig); + + // Generate stable workspace ID for the new workspace + const newWorkspaceId = this.config.generateStableId(); + + // Create session BEFORE forking so init events can be forwarded + const session = this.getOrCreateSession(newWorkspaceId); + + // Start init tracking + this.initStateManager.startInit(newWorkspaceId, foundProjectPath); + + const initLogger = this.createInitLogger(newWorkspaceId); + + // Delegate fork operation to runtime + const forkResult = await runtime.forkWorkspace({ + projectPath: foundProjectPath, + sourceWorkspaceName: sourceMetadata.name, + newWorkspaceName: newName, + initLogger, + }); + + if (!forkResult.success) { + return { success: false, error: forkResult.error }; + } + + // Copy session files (chat.jsonl, partial.json) - local backend operation + const sourceSessionDir = this.config.getSessionDir(sourceWorkspaceId); + const newSessionDir = this.config.getSessionDir(newWorkspaceId); + + try { + await fsPromises.mkdir(newSessionDir, { recursive: true }); + + // Copy chat.jsonl if it exists + const sourceChatPath = path.join(sourceSessionDir, "chat.jsonl"); + const newChatPath = path.join(newSessionDir, "chat.jsonl"); + try { + await fsPromises.copyFile(sourceChatPath, newChatPath); + } catch (error) { + if ( + !(error && typeof error === "object" && "code" in error && error.code === "ENOENT") + ) { + throw error; + } + } + + // Copy partial.json if it exists + const sourcePartialPath = path.join(sourceSessionDir, "partial.json"); + const newPartialPath = path.join(newSessionDir, "partial.json"); + try { + await fsPromises.copyFile(sourcePartialPath, newPartialPath); + } catch (error) { + if ( + !(error && typeof error === "object" && "code" in error && error.code === "ENOENT") + ) { + throw error; + } + } + } catch (copyError) { + // If copy fails, clean up everything we created + await runtime.deleteWorkspace(foundProjectPath, newName, true); + try { + await fsPromises.rm(newSessionDir, { recursive: true, force: true }); + } catch (cleanupError) { + log.error(`Failed to clean up session dir ${newSessionDir}:`, cleanupError); + } + const message = copyError instanceof Error ? copyError.message : String(copyError); + return { success: false, error: `Failed to copy chat history: ${message}` }; + } + + // Initialize workspace metadata + const metadata: WorkspaceMetadata = { + id: newWorkspaceId, + name: newName, + projectName, + projectPath: foundProjectPath, + createdAt: new Date().toISOString(), + runtimeConfig: DEFAULT_RUNTIME_CONFIG, + }; + + // Write metadata to config.json + await this.config.addWorkspace(foundProjectPath, metadata); + + // Emit metadata event + session.emitMetadata(metadata); + + return { + success: true, + metadata, + projectPath: foundProjectPath, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { success: false, error: `Failed to fork workspace: ${message}` }; + } + } + ); + + ipcMain.handle(IPC_CHANNELS.WORKSPACE_LIST, async () => { + try { + // getAllWorkspaceMetadata now returns complete metadata with paths + return await this.config.getAllWorkspaceMetadata(); + } catch (error) { + console.error("Failed to list workspaces:", error); + return []; + } + }); + + ipcMain.handle(IPC_CHANNELS.WORKSPACE_GET_INFO, async (_event, workspaceId: string) => { + // Get complete metadata from config (includes paths) + const allMetadata = await this.config.getAllWorkspaceMetadata(); + const metadata = allMetadata.find((m) => m.id === workspaceId); + + // Regenerate title/branch if missing (robust to errors/restarts) + if (metadata && !metadata.name) { + log.info(`Workspace ${workspaceId} missing title or branch name, regenerating...`); + try { + const historyResult = await this.historyService.getHistory(workspaceId); + if (!historyResult.success) { + log.error(`Failed to load history for workspace ${workspaceId}:`, historyResult.error); + return metadata; + } + + const firstUserMessage = historyResult.data.find((m: MuxMessage) => m.role === "user"); + + if (firstUserMessage) { + // Extract text content from message parts + const textParts = firstUserMessage.parts.filter((p) => p.type === "text"); + const messageText = textParts.map((p) => p.text).join(" "); + + if (messageText.trim()) { + const nameResult = await generateWorkspaceName( + messageText, + "anthropic:claude-sonnet-4-5", // Use reasonable default model + this.aiService + ); + if (nameResult.success) { + const branchName = nameResult.data; + // Update config with regenerated name + await this.config.updateWorkspaceMetadata(workspaceId, { + name: branchName, + }); + + // Return updated metadata + metadata.name = branchName; + log.info(`Regenerated workspace name: ${branchName}`); + } else { + log.info( + `Skipping title regeneration for ${workspaceId}: ${ + ( + nameResult.error as { + type?: string; + provider?: string; + message?: string; + raw?: string; + } + ).type ?? "unknown" + }` + ); + } + } + } + } catch (error) { + log.error(`Failed to regenerate workspace names for ${workspaceId}:`, error); + } + } + + return metadata ?? null; + }); + + ipcMain.handle( + IPC_CHANNELS.WORKSPACE_SEND_MESSAGE, + async ( + _event, + workspaceId: string | null, + message: string, + options?: SendMessageOptions & { + imageParts?: ImagePart[]; + runtimeConfig?: RuntimeConfig; + projectPath?: string; + trunkBranch?: string; + } + ) => { + // If workspaceId is null, create a new workspace first (lazy creation) + if (workspaceId === null) { + if (!options?.projectPath) { + return { success: false, error: "projectPath is required when workspaceId is null" }; + } + + log.debug("sendMessage handler: Creating workspace for first message", { + projectPath: options.projectPath, + messagePreview: message.substring(0, 50), + }); + + return await this.createWorkspaceForFirstMessage(message, options.projectPath, options); + } + + // Normal path: workspace already exists + log.debug("sendMessage handler: Received", { + workspaceId, + messagePreview: message.substring(0, 50), + mode: options?.mode, + options, + }); + try { + const session = this.getOrCreateSession(workspaceId); + + // Update recency on user message (fire and forget) + void this.updateRecencyTimestamp(workspaceId); + + // Queue new messages during streaming, but allow edits through + if (this.aiService.isStreaming(workspaceId) && !options?.editMessageId) { + session.queueMessage(message, options); + return Ok(undefined); + } + + const result = await session.sendMessage(message, options); + if (!result.success) { + log.error("sendMessage handler: session returned error", { + workspaceId, + error: result.error, + }); + } + return result; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : JSON.stringify(error, null, 2); + log.error("Unexpected error in sendMessage handler:", error); + const sendError: SendMessageError = { + type: "unknown", + raw: `Failed to send message: ${errorMessage}`, + }; + return { success: false, error: sendError }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.WORKSPACE_RESUME_STREAM, + async (_event, workspaceId: string, options: SendMessageOptions) => { + log.debug("resumeStream handler: Received", { + workspaceId, + options, + }); + try { + const session = this.getOrCreateSession(workspaceId); + const result = await session.resumeStream(options); + if (!result.success) { + log.error("resumeStream handler: session returned error", { + workspaceId, + error: result.error, + }); + } + return result; + } catch (error) { + // Convert to SendMessageError for typed error handling + const errorMessage = error instanceof Error ? error.message : String(error); + log.error("Unexpected error in resumeStream handler:", error); + const sendError: SendMessageError = { + type: "unknown", + raw: `Failed to resume stream: ${errorMessage}`, + }; + return { success: false, error: sendError }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.WORKSPACE_INTERRUPT_STREAM, + async (_event, workspaceId: string, options?: { abandonPartial?: boolean }) => { + log.debug("interruptStream handler: Received", { workspaceId, options }); + try { + const session = this.getOrCreateSession(workspaceId); + const stopResult = await session.interruptStream(options?.abandonPartial); + if (!stopResult.success) { + log.error("Failed to stop stream:", stopResult.error); + return { success: false, error: stopResult.error }; + } + + return { success: true, data: undefined }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log.error("Unexpected error in interruptStream handler:", error); + return { success: false, error: `Failed to interrupt stream: ${errorMessage}` }; + } + } + ); + + ipcMain.handle(IPC_CHANNELS.WORKSPACE_CLEAR_QUEUE, (_event, workspaceId: string) => { + try { + const session = this.getOrCreateSession(workspaceId); + session.clearQueue(); + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log.error("Unexpected error in clearQueue handler:", error); + return { success: false, error: `Failed to clear queue: ${errorMessage}` }; + } + }); + + ipcMain.handle( + IPC_CHANNELS.WORKSPACE_TRUNCATE_HISTORY, + async (_event, workspaceId: string, percentage?: number) => { + // Block truncate if there's an active stream + // User must press Esc first to stop stream and commit partial to history + if (this.aiService.isStreaming(workspaceId)) { + return { + success: false, + error: + "Cannot truncate history while stream is active. Press Esc to stop the stream first.", + }; + } + + // Truncate chat.jsonl (only operates on committed history) + // Note: partial.json is NOT touched here - it has its own lifecycle + // Interrupted messages are committed to history by stream-abort handler + const truncateResult = await this.historyService.truncateHistory( + workspaceId, + percentage ?? 1.0 + ); + if (!truncateResult.success) { + return { success: false, error: truncateResult.error }; + } + + // Send DeleteMessage event to frontend with deleted historySequence numbers + const deletedSequences = truncateResult.data; + if (deletedSequences.length > 0 && this.mainWindow) { + const deleteMessage: DeleteMessage = { + type: "delete", + historySequences: deletedSequences, + }; + this.mainWindow.webContents.send(getChatChannel(workspaceId), deleteMessage); + } + + return { success: true, data: undefined }; + } + ); + + ipcMain.handle( + IPC_CHANNELS.WORKSPACE_REPLACE_HISTORY, + async (_event, workspaceId: string, summaryMessage: MuxMessage) => { + // Block replace if there's an active stream, UNLESS this is a compacted message + // (which is called from stream-end handler before stream cleanup completes) + const isCompaction = summaryMessage.metadata?.compacted === true; + if (!isCompaction && this.aiService.isStreaming(workspaceId)) { + return Err( + "Cannot replace history while stream is active. Press Esc to stop the stream first." + ); + } + + try { + // Clear entire history + const clearResult = await this.historyService.clearHistory(workspaceId); + if (!clearResult.success) { + return Err(`Failed to clear history: ${clearResult.error}`); + } + const deletedSequences = clearResult.data; + + // Append the summary message to history (gets historySequence assigned by backend) + // Frontend provides the message with all metadata (compacted, timestamp, etc.) + const appendResult = await this.historyService.appendToHistory( + workspaceId, + summaryMessage + ); + if (!appendResult.success) { + return Err(`Failed to append summary: ${appendResult.error}`); + } + + // Send delete event to frontend for all old messages + if (deletedSequences.length > 0 && this.mainWindow) { + const deleteMessage: DeleteMessage = { + type: "delete", + historySequences: deletedSequences, + }; + this.mainWindow.webContents.send(getChatChannel(workspaceId), deleteMessage); + } + + // Send the new summary message to frontend + if (this.mainWindow) { + this.mainWindow.webContents.send(getChatChannel(workspaceId), summaryMessage); + } + + return Ok(undefined); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return Err(`Failed to replace history: ${message}`); + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.WORKSPACE_EXECUTE_BASH, + async ( + _event, + workspaceId: string, + script: string, + options?: { + timeout_secs?: number; + niceness?: number; + } + ) => { + try { + // Get workspace metadata + const metadataResult = await this.aiService.getWorkspaceMetadata(workspaceId); + if (!metadataResult.success) { + return Err(`Failed to get workspace metadata: ${metadataResult.error}`); + } + + const metadata = metadataResult.data; + + // Get actual workspace path from config (handles both legacy and new format) + // Legacy workspaces: path stored in config doesn't match computed path + // New workspaces: path can be computed, but config is still source of truth + const workspace = this.config.findWorkspace(workspaceId); + if (!workspace) { + return Err(`Workspace ${workspaceId} not found in config`); + } + + // Load project secrets + const projectSecrets = this.config.getProjectSecrets(metadata.projectPath); + + // Create scoped temp directory for this IPC call + using tempDir = new DisposableTempDir("mux-ipc-bash"); + + // Create runtime and compute workspace path + // Runtime owns the path computation logic + const runtimeConfig = metadata.runtimeConfig ?? { + type: "local" as const, + srcBaseDir: this.config.srcDir, + }; + const runtime = createRuntime(runtimeConfig); + const workspacePath = runtime.getWorkspacePath(metadata.projectPath, metadata.name); + + // Create bash tool with workspace's cwd and secrets + // All IPC bash calls are from UI (background operations) - use truncate to avoid temp file spam + // No init wait needed - IPC calls are user-initiated, not AI tool use + const bashTool = createBashTool({ + cwd: workspacePath, // Bash executes in the workspace directory + runtime, + secrets: secretsToRecord(projectSecrets), + niceness: options?.niceness, + runtimeTempDir: tempDir.path, + overflow_policy: "truncate", + }); + + // Execute the script with provided options + const result = (await bashTool.execute!( + { + script, + timeout_secs: options?.timeout_secs ?? 120, + }, + { + toolCallId: `bash-${Date.now()}`, + messages: [], + } + )) as BashToolResult; + + return Ok(result); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return Err(`Failed to execute bash command: ${message}`); + } + } + ); + + ipcMain.handle(IPC_CHANNELS.WORKSPACE_OPEN_TERMINAL, async (_event, workspaceId: string) => { + try { + // Look up workspace metadata to get runtime config + const allMetadata = await this.config.getAllWorkspaceMetadata(); + const workspace = allMetadata.find((w) => w.id === workspaceId); + + if (!workspace) { + log.error(`Workspace not found: ${workspaceId}`); + return; + } + + const runtimeConfig = workspace.runtimeConfig; + + if (isSSHRuntime(runtimeConfig)) { + // SSH workspace - spawn local terminal that SSHs into remote host + await this.openTerminal({ + type: "ssh", + sshConfig: runtimeConfig, + remotePath: workspace.namedWorkspacePath, + }); + } else { + // Local workspace - spawn terminal with cwd set + await this.openTerminal({ type: "local", workspacePath: workspace.namedWorkspacePath }); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log.error(`Failed to open terminal: ${message}`); + } + }); + + // Debug IPC - only for testing + ipcMain.handle( + IPC_CHANNELS.DEBUG_TRIGGER_STREAM_ERROR, + (_event, workspaceId: string, errorMessage: string) => { + try { + // eslint-disable-next-line @typescript-eslint/dot-notation -- accessing private member for testing + const triggered = this.aiService["streamManager"].debugTriggerStreamError( + workspaceId, + errorMessage + ); + return { success: triggered }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log.error(`Failed to trigger stream error: ${message}`); + return { success: false, error: message }; + } + } + ); + } + + /** + * Internal workspace removal logic shared by both force and non-force deletion + */ + private async removeWorkspaceInternal( + workspaceId: string, + options: { force: boolean } + ): Promise<{ success: boolean; error?: string }> { + try { + // Get workspace metadata + const metadataResult = await this.aiService.getWorkspaceMetadata(workspaceId); + if (!metadataResult.success) { + // If metadata doesn't exist, workspace is already gone - consider it success + log.info(`Workspace ${workspaceId} metadata not found, considering removal successful`); + return { success: true }; + } + const metadata = metadataResult.data; + + // Get workspace from config to get projectPath + const workspace = this.config.findWorkspace(workspaceId); + if (!workspace) { + log.info(`Workspace ${workspaceId} metadata exists but not found in config`); + return { success: true }; // Consider it already removed + } + const { projectPath, workspacePath } = workspace; + + // Create runtime instance for this workspace + // For local runtimes, workdir should be srcDir, not the individual workspace path + const runtime = createRuntime( + metadata.runtimeConfig ?? { type: "local", srcBaseDir: this.config.srcDir } + ); + + // Delegate deletion to runtime - it handles all path computation, existence checks, and pruning + const deleteResult = await runtime.deleteWorkspace(projectPath, metadata.name, options.force); + + if (!deleteResult.success) { + // Real error (e.g., dirty workspace without force) - return it + return { success: false, error: deleteResult.error }; + } + + // Remove the workspace from AI service + const aiResult = await this.aiService.deleteWorkspace(workspaceId); + if (!aiResult.success) { + return { success: false, error: aiResult.error }; + } + + // Delete workspace metadata (fire and forget) + void this.extensionMetadata.deleteWorkspace(workspaceId); + + // Update config to remove the workspace from all projects + const projectsConfig = this.config.loadConfigOrDefault(); + let configUpdated = false; + for (const [_projectPath, projectConfig] of projectsConfig.projects.entries()) { + const initialCount = projectConfig.workspaces.length; + projectConfig.workspaces = projectConfig.workspaces.filter((w) => w.path !== workspacePath); + if (projectConfig.workspaces.length < initialCount) { + configUpdated = true; + } + } + if (configUpdated) { + await this.config.saveConfig(projectsConfig); + } + + // Emit metadata event for workspace removal (with null metadata to indicate deletion) + const existingSession = this.sessions.get(workspaceId); + if (existingSession) { + existingSession.emitMetadata(null); + } else if (this.mainWindow) { + this.mainWindow.webContents.send(IPC_CHANNELS.WORKSPACE_METADATA, { + workspaceId, + metadata: null, + }); + } + + this.disposeSession(workspaceId); + + return { success: true }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { success: false, error: `Failed to remove workspace: ${message}` }; + } + } + + private registerProviderHandlers(ipcMain: ElectronIpcMain): void { + ipcMain.handle( + IPC_CHANNELS.PROVIDERS_SET_CONFIG, + (_event, provider: string, keyPath: string[], value: string) => { + try { + // Load current providers config or create empty + const providersConfig = this.config.loadProvidersConfig() ?? {}; + + // Ensure provider exists + if (!providersConfig[provider]) { + providersConfig[provider] = {}; + } + + // Set nested property value + let current = providersConfig[provider] as Record; + for (let i = 0; i < keyPath.length - 1; i++) { + const key = keyPath[i]; + if (!(key in current) || typeof current[key] !== "object" || current[key] === null) { + current[key] = {}; + } + current = current[key] as Record; + } + + if (keyPath.length > 0) { + const lastKey = keyPath[keyPath.length - 1]; + // Delete key if value is empty string, otherwise set it + if (value === "") { + delete current[lastKey]; + } else { + current[lastKey] = value; + } + } + + // Save updated config + this.config.saveProvidersConfig(providersConfig); + + return { success: true, data: undefined }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { success: false, error: `Failed to set provider config: ${message}` }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.PROVIDERS_SET_MODELS, + (_event, provider: string, models: string[]) => { + try { + const providersConfig = this.config.loadProvidersConfig() ?? {}; + + if (!providersConfig[provider]) { + providersConfig[provider] = {}; + } + + providersConfig[provider].models = models; + this.config.saveProvidersConfig(providersConfig); + + return { success: true, data: undefined }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { success: false, error: `Failed to set models: ${message}` }; + } + } + ); + + ipcMain.handle(IPC_CHANNELS.PROVIDERS_LIST, () => { + try { + // Return all supported providers from centralized registry + // This automatically stays in sync as new providers are added + return [...SUPPORTED_PROVIDERS]; + } catch (error) { + log.error("Failed to list providers:", error); + return []; + } + }); + + ipcMain.handle(IPC_CHANNELS.PROVIDERS_GET_CONFIG, () => { + try { + const config = this.config.loadProvidersConfig() ?? {}; + // Return a sanitized version (only whether secrets are set, not the values) + const sanitized: Record> = {}; + for (const [provider, providerConfig] of Object.entries(config)) { + const baseUrl = providerConfig.baseUrl ?? providerConfig.baseURL; + const models = providerConfig.models; + + // Base fields for all providers + const providerData: Record = { + apiKeySet: !!providerConfig.apiKey, + baseUrl: typeof baseUrl === "string" ? baseUrl : undefined, + models: Array.isArray(models) + ? models.filter((m): m is string => typeof m === "string") + : undefined, + }; + + // Bedrock-specific fields + if (provider === "bedrock") { + const region = providerConfig.region; + providerData.region = typeof region === "string" ? region : undefined; + providerData.bearerTokenSet = !!providerConfig.bearerToken; + providerData.accessKeyIdSet = !!providerConfig.accessKeyId; + providerData.secretAccessKeySet = !!providerConfig.secretAccessKey; + } + + sanitized[provider] = providerData; + } + return sanitized; + } catch (error) { + log.error("Failed to get providers config:", error); + return {}; + } + }); + } + + private registerProjectHandlers(ipcMain: ElectronIpcMain): void { + ipcMain.handle( + IPC_CHANNELS.PROJECT_PICK_DIRECTORY, + async (event: IpcMainInvokeEvent | null) => { + if (!event?.sender || !this.projectDirectoryPicker) { + // In server mode (HttpIpcMainAdapter), there is no BrowserWindow / sender. + // The browser uses the web-based directory picker instead. + return null; + } + + try { + return await this.projectDirectoryPicker(event); + } catch (error) { + log.error("Failed to pick directory:", error); + return null; + } + } + ); + + ipcMain.handle(IPC_CHANNELS.PROJECT_CREATE, async (_event, projectPath: string) => { + try { + // Validate and expand path (handles tilde, checks existence and directory status) + const validation = await validateProjectPath(projectPath); + if (!validation.valid) { + return Err(validation.error ?? "Invalid project path"); + } + + // Use the expanded/normalized path + const normalizedPath = validation.expandedPath!; + + const config = this.config.loadConfigOrDefault(); + + // Check if project already exists (using normalized path) + if (config.projects.has(normalizedPath)) { + return Err("Project already exists"); + } + + // Create new project config + const projectConfig: ProjectConfig = { + workspaces: [], + }; + + // Add to config with normalized path + config.projects.set(normalizedPath, projectConfig); + await this.config.saveConfig(config); + + // Return both the config and the normalized path so frontend can use it + return Ok({ projectConfig, normalizedPath }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return Err(`Failed to create project: ${message}`); + } + }); + + ipcMain.handle(IPC_CHANNELS.PROJECT_REMOVE, async (_event, projectPath: string) => { + try { + const config = this.config.loadConfigOrDefault(); + const projectConfig = config.projects.get(projectPath); + + if (!projectConfig) { + return Err("Project not found"); + } + + // Check if project has any workspaces + if (projectConfig.workspaces.length > 0) { + return Err( + `Cannot remove project with active workspaces. Please remove all ${projectConfig.workspaces.length} workspace(s) first.` + ); + } + + // Remove project from config + config.projects.delete(projectPath); + await this.config.saveConfig(config); + + // Also remove project secrets if any + try { + await this.config.updateProjectSecrets(projectPath, []); + } catch (error) { + log.error(`Failed to clean up secrets for project ${projectPath}:`, error); + // Continue - don't fail the whole operation if secrets cleanup fails + } + + return Ok(undefined); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return Err(`Failed to remove project: ${message}`); + } + }); + + ipcMain.handle(IPC_CHANNELS.PROJECT_LIST, () => { + try { + const config = this.config.loadConfigOrDefault(); + // Return array of [projectPath, projectConfig] tuples + return Array.from(config.projects.entries()); + } catch (error) { + log.error("Failed to list projects:", error); + return []; + } + }); + + ipcMain.handle(IPC_CHANNELS.PROJECT_LIST_BRANCHES, async (_event, projectPath: string) => { + if (typeof projectPath !== "string" || projectPath.trim().length === 0) { + throw new Error("Project path is required to list branches"); + } + + try { + // Validate and expand path (handles tilde) + const validation = await validateProjectPath(projectPath); + if (!validation.valid) { + throw new Error(validation.error ?? "Invalid project path"); + } + + const normalizedPath = validation.expandedPath!; + const branches = await listLocalBranches(normalizedPath); + const recommendedTrunk = await detectDefaultTrunkBranch(normalizedPath, branches); + return { branches, recommendedTrunk }; + } catch (error) { + log.error("Failed to list branches:", error); + throw error instanceof Error ? error : new Error(String(error)); + } + }); + + ipcMain.handle(IPC_CHANNELS.PROJECT_SECRETS_GET, (_event, projectPath: string) => { + try { + return this.config.getProjectSecrets(projectPath); + } catch (error) { + log.error("Failed to get project secrets:", error); + return []; + } + }); + + ipcMain.handle( + IPC_CHANNELS.PROJECT_SECRETS_UPDATE, + async (_event, projectPath: string, secrets: Array<{ key: string; value: string }>) => { + try { + await this.config.updateProjectSecrets(projectPath, secrets); + return Ok(undefined); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return Err(`Failed to update project secrets: ${message}`); + } + } + ); + } + + private registerTerminalHandlers(ipcMain: ElectronIpcMain, mainWindow: BrowserWindow): void { + ipcMain.handle(IPC_CHANNELS.TERMINAL_CREATE, async (event, params: TerminalCreateParams) => { + try { + let senderWindow: Electron.BrowserWindow | null = null; + // Get the window that requested this terminal + // In Electron, use the actual sender window. In browser mode, event is null, + // so we use the mainWindow (mockWindow) which broadcasts to all WebSocket clients + if (event?.sender) { + // We must dynamically import here because the browser distribution + // does not include the electron module. + // eslint-disable-next-line no-restricted-syntax + const { BrowserWindow } = await import("electron"); + senderWindow = BrowserWindow.fromWebContents(event.sender); + } else { + senderWindow = mainWindow; + } + if (!senderWindow) { + throw new Error("Could not find sender window for terminal creation"); + } + + // Get workspace metadata + const allMetadata = await this.config.getAllWorkspaceMetadata(); + const workspaceMetadata = allMetadata.find((ws) => ws.id === params.workspaceId); + + if (!workspaceMetadata) { + throw new Error(`Workspace ${params.workspaceId} not found`); + } + + // Create runtime for this workspace (default to local if not specified) + const runtime = createRuntime( + workspaceMetadata.runtimeConfig ?? { type: "local", srcBaseDir: this.config.srcDir } + ); + + // Compute workspace path + const workspacePath = runtime.getWorkspacePath( + workspaceMetadata.projectPath, + workspaceMetadata.name + ); + + // Create terminal session with callbacks that send IPC events + // Note: callbacks capture sessionId from returned session object + const capturedSessionId = { current: "" }; + const session = await this.ptyService.createSession( + params, + runtime, + workspacePath, + // onData callback - send output to the window that created the session + (data: string) => { + senderWindow.webContents.send(`terminal:output:${capturedSessionId.current}`, data); + }, + // onExit callback - send exit event and clean up + (exitCode: number) => { + senderWindow.webContents.send(`terminal:exit:${capturedSessionId.current}`, exitCode); + } + ); + capturedSessionId.current = session.sessionId; + + return session; + } catch (err) { + log.error("Error creating terminal session:", err); + throw err; + } + }); + + // Handle terminal input (keyboard, etc.) + // Use handle() for both Electron and browser mode + ipcMain.handle(IPC_CHANNELS.TERMINAL_INPUT, (_event, sessionId: string, data: string) => { + try { + this.ptyService.sendInput(sessionId, data); + } catch (err) { + log.error(`Error sending input to terminal ${sessionId}:`, err); + throw err; + } + }); + + ipcMain.handle(IPC_CHANNELS.TERMINAL_CLOSE, (_event, sessionId: string) => { + try { + this.ptyService.closeSession(sessionId); + } catch (err) { + log.error("Error closing terminal session:", err); + throw err; + } + }); + + ipcMain.handle(IPC_CHANNELS.TERMINAL_RESIZE, (_event, params: TerminalResizeParams) => { + try { + this.ptyService.resize(params); + } catch (err) { + log.error("Error resizing terminal:", err); + throw err; + } + }); + + ipcMain.handle(IPC_CHANNELS.TERMINAL_WINDOW_OPEN, async (_event, workspaceId: string) => { + console.log(`[BACKEND] TERMINAL_WINDOW_OPEN handler called with: ${workspaceId}`); + try { + // Look up workspace to determine runtime type + const allMetadata = await this.config.getAllWorkspaceMetadata(); + const workspace = allMetadata.find((w) => w.id === workspaceId); + + if (!workspace) { + log.error(`Workspace not found: ${workspaceId}`); + throw new Error(`Workspace not found: ${workspaceId}`); + } + + const runtimeConfig = workspace.runtimeConfig; + const isSSH = isSSHRuntime(runtimeConfig); + const isDesktop = !!this.terminalWindowManager; + + // Terminal routing logic: + // - Desktop + Local: Native terminal + // - Desktop + SSH: Web terminal (ghostty-web Electron window) + // - Browser + Local: Web terminal (browser tab) + // - Browser + SSH: Web terminal (browser tab) + if (isDesktop && !isSSH) { + // Desktop + Local: Native terminal + log.info(`Opening native terminal for local workspace: ${workspaceId}`); + await this.openTerminal({ type: "local", workspacePath: workspace.namedWorkspacePath }); + } else if (isDesktop && isSSH) { + // Desktop + SSH: Web terminal (ghostty-web Electron window) + log.info(`Opening ghostty-web terminal for SSH workspace: ${workspaceId}`); + await this.terminalWindowManager!.openTerminalWindow(workspaceId); + } else { + // Browser mode (local or SSH): Web terminal (browser window) + // Browser will handle opening the terminal window via window.open() + log.info( + `Browser mode: terminal UI handled by browser for ${isSSH ? "SSH" : "local"} workspace: ${workspaceId}` + ); + } + + log.info(`Terminal opened successfully for workspace: ${workspaceId}`); + } catch (err) { + log.error("Error opening terminal window:", err); + throw err; + } + }); + + ipcMain.handle(IPC_CHANNELS.TERMINAL_WINDOW_CLOSE, (_event, workspaceId: string) => { + try { + if (!this.terminalWindowManager) { + throw new Error("Terminal window manager not available (desktop mode only)"); + } + this.terminalWindowManager.closeTerminalWindow(workspaceId); + } catch (err) { + log.error("Error closing terminal window:", err); + throw err; + } + }); + } + + private registerSubscriptionHandlers(ipcMain: ElectronIpcMain): void { + // Handle subscription events for chat history + ipcMain.on(`workspace:chat:subscribe`, (_event, workspaceId: string) => { + void (async () => { + const session = this.getOrCreateSession(workspaceId); + const chatChannel = getChatChannel(workspaceId); + + await session.replayHistory((event) => { + if (!this.mainWindow) { + return; + } + this.mainWindow.webContents.send(chatChannel, event.message); + }); + })(); + }); + + // Handle subscription events for metadata + ipcMain.on(IPC_CHANNELS.WORKSPACE_METADATA_SUBSCRIBE, () => { + void (async () => { + try { + const workspaceMetadata = await this.config.getAllWorkspaceMetadata(); + + // Emit current metadata for each workspace + for (const metadata of workspaceMetadata) { + this.mainWindow?.webContents.send(IPC_CHANNELS.WORKSPACE_METADATA, { + workspaceId: metadata.id, + metadata, + }); + } + } catch (error) { + console.error("Failed to emit current metadata:", error); + } + })(); + }); + + ipcMain.on(IPC_CHANNELS.WORKSPACE_ACTIVITY_SUBSCRIBE, () => { + void (async () => { + try { + const snapshots = await this.extensionMetadata.getAllSnapshots(); + for (const [workspaceId, activity] of snapshots.entries()) { + this.mainWindow?.webContents.send(IPC_CHANNELS.WORKSPACE_ACTIVITY, { + workspaceId, + activity, + }); + } + } catch (error) { + log.error("Failed to emit current workspace activity", error); + } + })(); + }); + + ipcMain.on(IPC_CHANNELS.WORKSPACE_ACTIVITY_UNSUBSCRIBE, () => { + // No-op; included for API completeness + }); + } + + /** + * Check if a command is available in the system PATH or known locations + */ + private async isCommandAvailable(command: string): Promise { + // Special handling for ghostty on macOS - check common installation paths + if (command === "ghostty" && process.platform === "darwin") { + const ghosttyPaths = [ + "/opt/homebrew/bin/ghostty", + "/Applications/Ghostty.app/Contents/MacOS/ghostty", + "/usr/local/bin/ghostty", + ]; + + for (const ghosttyPath of ghosttyPaths) { + try { + const stats = await fsPromises.stat(ghosttyPath); + // Check if it's a file and any executable bit is set (owner, group, or other) + if (stats.isFile() && (stats.mode & 0o111) !== 0) { + return true; + } + } catch { + // Try next path + } + } + // If none of the known paths work, fall through to which check + } + + try { + const result = spawnSync("which", [command], { encoding: "utf8" }); + return result.status === 0; + } catch { + return false; + } + } + + /** + * Open a terminal (local or SSH) with platform-specific handling + */ + private async openTerminal( + config: + | { type: "local"; workspacePath: string } + | { + type: "ssh"; + sshConfig: Extract; + remotePath: string; + } + ): Promise { + const isSSH = config.type === "ssh"; + + // Build SSH args if needed + let sshArgs: string[] | null = null; + if (isSSH) { + sshArgs = []; + // Add port if specified + if (config.sshConfig.port) { + sshArgs.push("-p", String(config.sshConfig.port)); + } + // Add identity file if specified + if (config.sshConfig.identityFile) { + sshArgs.push("-i", config.sshConfig.identityFile); + } + // Force pseudo-terminal allocation + sshArgs.push("-t"); + // Add host + sshArgs.push(config.sshConfig.host); + // Add remote command to cd into directory and start shell + // Use single quotes to prevent local shell expansion + // exec $SHELL replaces the SSH process with the shell, avoiding nested processes + sshArgs.push(`cd '${config.remotePath.replace(/'/g, "'\\''")}' && exec $SHELL`); + } + + const logPrefix = isSSH ? "SSH terminal" : "terminal"; + + if (process.platform === "darwin") { + // macOS - try Ghostty first, fallback to Terminal.app + const terminal = await this.findAvailableCommand(["ghostty", "terminal"]); + if (terminal === "ghostty") { + const cmd = "open"; + let args: string[]; + if (isSSH && sshArgs) { + // Ghostty: Use --command flag to run SSH + // Build the full SSH command as a single string + const sshCommand = ["ssh", ...sshArgs].join(" "); + args = ["-n", "-a", "Ghostty", "--args", `--command=${sshCommand}`]; + } else { + // Ghostty: Pass workspacePath to 'open -a Ghostty' to avoid regressions + if (config.type !== "local") throw new Error("Expected local config"); + args = ["-a", "Ghostty", config.workspacePath]; + } + log.info(`Opening ${logPrefix}: ${cmd} ${args.join(" ")}`); + const child = spawn(cmd, args, { + detached: true, + stdio: "ignore", + }); + child.unref(); + } else { + // Terminal.app + const cmd = isSSH ? "osascript" : "open"; + let args: string[]; + if (isSSH && sshArgs) { + // Terminal.app: Use osascript with proper AppleScript structure + // Properly escape single quotes in args before wrapping in quotes + const sshCommand = `ssh ${sshArgs + .map((arg) => { + if (arg.includes(" ") || arg.includes("'")) { + // Escape single quotes by ending quote, adding escaped quote, starting quote again + return `'${arg.replace(/'/g, "'\\''")}'`; + } + return arg; + }) + .join(" ")}`; + // Escape double quotes for AppleScript string + const escapedCommand = sshCommand.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + const script = `tell application "Terminal"\nactivate\ndo script "${escapedCommand}"\nend tell`; + args = ["-e", script]; + } else { + // Terminal.app opens in the directory when passed as argument + if (config.type !== "local") throw new Error("Expected local config"); + args = ["-a", "Terminal", config.workspacePath]; + } + log.info(`Opening ${logPrefix}: ${cmd} ${args.join(" ")}`); + const child = spawn(cmd, args, { + detached: true, + stdio: "ignore", + }); + child.unref(); + } + } else if (process.platform === "win32") { + // Windows + const cmd = "cmd"; + let args: string[]; + if (isSSH && sshArgs) { + // Windows - use cmd to start ssh + args = ["/c", "start", "cmd", "/K", "ssh", ...sshArgs]; + } else { + if (config.type !== "local") throw new Error("Expected local config"); + args = ["/c", "start", "cmd", "/K", "cd", "/D", config.workspacePath]; + } + log.info(`Opening ${logPrefix}: ${cmd} ${args.join(" ")}`); + const child = spawn(cmd, args, { + detached: true, + shell: true, + stdio: "ignore", + }); + child.unref(); + } else { + // Linux - try terminal emulators in order of preference + let terminals: Array<{ cmd: string; args: string[]; cwd?: string }>; + + if (isSSH && sshArgs) { + // x-terminal-emulator is checked first as it respects user's system-wide preference + terminals = [ + { cmd: "x-terminal-emulator", args: ["-e", "ssh", ...sshArgs] }, + { cmd: "ghostty", args: ["ssh", ...sshArgs] }, + { cmd: "alacritty", args: ["-e", "ssh", ...sshArgs] }, + { cmd: "kitty", args: ["ssh", ...sshArgs] }, + { cmd: "wezterm", args: ["start", "--", "ssh", ...sshArgs] }, + { cmd: "gnome-terminal", args: ["--", "ssh", ...sshArgs] }, + { cmd: "konsole", args: ["-e", "ssh", ...sshArgs] }, + { cmd: "xfce4-terminal", args: ["-e", `ssh ${sshArgs.join(" ")}`] }, + { cmd: "xterm", args: ["-e", "ssh", ...sshArgs] }, + ]; + } else { + if (config.type !== "local") throw new Error("Expected local config"); + const workspacePath = config.workspacePath; + terminals = [ + { cmd: "x-terminal-emulator", args: [], cwd: workspacePath }, + { cmd: "ghostty", args: ["--working-directory=" + workspacePath] }, + { cmd: "alacritty", args: ["--working-directory", workspacePath] }, + { cmd: "kitty", args: ["--directory", workspacePath] }, + { cmd: "wezterm", args: ["start", "--cwd", workspacePath] }, + { cmd: "gnome-terminal", args: ["--working-directory", workspacePath] }, + { cmd: "konsole", args: ["--workdir", workspacePath] }, + { cmd: "xfce4-terminal", args: ["--working-directory", workspacePath] }, + { cmd: "xterm", args: [], cwd: workspacePath }, + ]; + } + + const availableTerminal = await this.findAvailableTerminal(terminals); + + if (availableTerminal) { + const cwdInfo = availableTerminal.cwd ? ` (cwd: ${availableTerminal.cwd})` : ""; + log.info( + `Opening ${logPrefix}: ${availableTerminal.cmd} ${availableTerminal.args.join(" ")}${cwdInfo}` + ); + const child = spawn(availableTerminal.cmd, availableTerminal.args, { + cwd: availableTerminal.cwd, + detached: true, + stdio: "ignore", + }); + child.unref(); + } else { + log.error("No terminal emulator found. Tried: " + terminals.map((t) => t.cmd).join(", ")); + } + } + } + + /** + * Find the first available command from a list of commands + */ + private async findAvailableCommand(commands: string[]): Promise { + for (const cmd of commands) { + if (await this.isCommandAvailable(cmd)) { + return cmd; + } + } + return null; + } + + /** + * Find the first available terminal emulator from a list + */ + private async findAvailableTerminal( + terminals: Array<{ cmd: string; args: string[]; cwd?: string }> + ): Promise<{ cmd: string; args: string[]; cwd?: string } | null> { + for (const terminal of terminals) { + if (await this.isCommandAvailable(terminal.cmd)) { + return terminal; + } + } + return null; + } + + private async getWorkspaceChatHistory(workspaceId: string): Promise { + const historyResult = await this.historyService.getHistory(workspaceId); + if (historyResult.success) { + return historyResult.data; + } + return []; + } + + private async getFullReplayEvents(workspaceId: string): Promise { + const session = this.getOrCreateSession(workspaceId); + const events: WorkspaceChatMessage[] = []; + await session.replayHistory(({ message }) => { + events.push(message); + }); + return events; + } +} diff --git a/src/node/services/log.ts b/src/node/services/log.ts index 41839f083..8640f57c2 100644 --- a/src/node/services/log.ts +++ b/src/node/services/log.ts @@ -30,25 +30,6 @@ function supportsColor(): boolean { return process.stdout.isTTY ?? false; } -// Chalk can be unexpectedly hoisted or partially mocked in certain test runners. -// Guard each style helper to avoid runtime TypeErrors (e.g., dim is not a function). -const chalkDim = - typeof (chalk as { dim?: (text: string) => string }).dim === "function" - ? (chalk as { dim: (text: string) => string }).dim - : (text: string) => text; -const chalkCyan = - typeof (chalk as { cyan?: (text: string) => string }).cyan === "function" - ? (chalk as { cyan: (text: string) => string }).cyan - : (text: string) => text; -const chalkGray = - typeof (chalk as { gray?: (text: string) => string }).gray === "function" - ? (chalk as { gray: (text: string) => string }).gray - : (text: string) => text; -const chalkRed = - typeof (chalk as { red?: (text: string) => string }).red === "function" - ? (chalk as { red: (text: string) => string }).red - : (text: string) => text; - /** * Get kitchen time timestamp for logs (12-hour format with milliseconds) * Format: 8:23.456PM (hours:minutes.milliseconds) @@ -115,13 +96,13 @@ function safePipeLog(level: "info" | "error" | "debug", ...args: unknown[]): voi // Apply colors based on level (if terminal supports it) let prefix: string; if (useColor) { - const coloredTimestamp = chalkDim(timestamp); - const coloredLocation = chalkCyan(location); + const coloredTimestamp = chalk.dim(timestamp); + const coloredLocation = chalk.cyan(location); if (level === "error") { prefix = `${coloredTimestamp} ${coloredLocation}`; } else if (level === "debug") { - prefix = `${coloredTimestamp} ${chalkGray(location)}`; + prefix = `${coloredTimestamp} ${chalk.gray(location)}`; } else { // info prefix = `${coloredTimestamp} ${coloredLocation}`; @@ -137,7 +118,7 @@ function safePipeLog(level: "info" | "error" | "debug", ...args: unknown[]): voi if (useColor) { console.error( prefix, - ...args.map((arg) => (typeof arg === "string" ? chalkRed(arg) : arg)) + ...args.map((arg) => (typeof arg === "string" ? chalk.red(arg) : arg)) ); } else { console.error(prefix, ...args); diff --git a/src/node/services/messageQueue.test.ts b/src/node/services/messageQueue.test.ts index 96774462a..47d172778 100644 --- a/src/node/services/messageQueue.test.ts +++ b/src/node/services/messageQueue.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach } from "bun:test"; import { MessageQueue } from "./messageQueue"; import type { MuxFrontendMetadata } from "@/common/types/message"; -import type { SendMessageOptions } from "@/common/orpc/types"; +import type { SendMessageOptions } from "@/common/types/ipc"; describe("MessageQueue", () => { let queue: MessageQueue; @@ -118,18 +118,9 @@ describe("MessageQueue", () => { describe("getImageParts", () => { it("should return accumulated images from multiple messages", () => { - const image1 = { - url: "data:image/png;base64,abc", - mediaType: "image/png", - }; - const image2 = { - url: "data:image/jpeg;base64,def", - mediaType: "image/jpeg", - }; - const image3 = { - url: "data:image/gif;base64,ghi", - mediaType: "image/gif", - }; + const image1 = { url: "data:image/png;base64,abc", mediaType: "image/png" }; + const image2 = { url: "data:image/jpeg;base64,def", mediaType: "image/jpeg" }; + const image3 = { url: "data:image/gif;base64,ghi", mediaType: "image/gif" }; queue.add("First message", { model: "gpt-4", imageParts: [image1] }); queue.add("Second message", { model: "gpt-4", imageParts: [image2, image3] }); @@ -144,11 +135,7 @@ describe("MessageQueue", () => { }); it("should return copy of images array", () => { - const image = { - type: "file" as const, - url: "data:image/png;base64,abc", - mediaType: "image/png", - }; + const image = { url: "data:image/png;base64,abc", mediaType: "image/png" }; queue.add("Message", { model: "gpt-4", imageParts: [image] }); const images1 = queue.getImageParts(); @@ -159,10 +146,7 @@ describe("MessageQueue", () => { }); it("should clear images when queue is cleared", () => { - const image = { - url: "data:image/png;base64,abc", - mediaType: "image/png", - }; + const image = { url: "data:image/png;base64,abc", mediaType: "image/png" }; queue.add("Message", { model: "gpt-4", imageParts: [image] }); expect(queue.getImageParts()).toHaveLength(1); diff --git a/src/node/services/messageQueue.ts b/src/node/services/messageQueue.ts index 69f2dd0ca..e589f8ee2 100644 --- a/src/node/services/messageQueue.ts +++ b/src/node/services/messageQueue.ts @@ -1,16 +1,4 @@ -import type { ImagePart, SendMessageOptions } from "@/common/orpc/types"; - -// Type guard for compaction request metadata (for display text) -interface CompactionMetadata { - type: "compaction-request"; - rawCommand: string; -} - -function isCompactionMetadata(meta: unknown): meta is CompactionMetadata { - if (typeof meta !== "object" || meta === null) return false; - const obj = meta as Record; - return obj.type === "compaction-request" && typeof obj.rawCommand === "string"; -} +import type { ImagePart, SendMessageOptions } from "@/common/types/ipc"; /** * Queue for messages sent during active streaming. @@ -67,9 +55,9 @@ export class MessageQueue { * Matches StreamingMessageAggregator behavior. */ getDisplayText(): string { - // Check if we have compaction metadata (cast from z.any() schema type) - const cmuxMetadata = this.latestOptions?.muxMetadata as unknown; - if (isCompactionMetadata(cmuxMetadata)) { + // Check if we have compaction metadata + const cmuxMetadata = this.latestOptions?.muxMetadata; + if (cmuxMetadata?.type === "compaction-request") { return cmuxMetadata.rawCommand; } diff --git a/src/node/services/mock/mockScenarioPlayer.ts b/src/node/services/mock/mockScenarioPlayer.ts index c42f7f9cd..9230d15f9 100644 --- a/src/node/services/mock/mockScenarioPlayer.ts +++ b/src/node/services/mock/mockScenarioPlayer.ts @@ -36,7 +36,7 @@ async function tokenizeWithMockModel(text: string, context: string): Promise | undefined; + let timeoutId: NodeJS.Timeout | undefined; const fallbackPromise = new Promise((resolve) => { timeoutId = setTimeout(() => { @@ -111,7 +111,7 @@ interface MockPlayerDeps { } interface ActiveStream { - timers: Array>; + timers: NodeJS.Timeout[]; messageId: string; eventQueue: Array<() => Promise>; isProcessing: boolean; @@ -212,7 +212,7 @@ export class MockScenarioPlayer { } private scheduleEvents(workspaceId: string, turn: ScenarioTurn, historySequence: number): void { - const timers: Array> = []; + const timers: NodeJS.Timeout[] = []; this.activeStreams.set(workspaceId, { timers, messageId: turn.assistant.messageId, diff --git a/src/node/services/projectService.test.ts b/src/node/services/projectService.test.ts deleted file mode 100644 index ee05f04f2..000000000 --- a/src/node/services/projectService.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from "bun:test"; -import * as fs from "fs/promises"; -import * as path from "path"; -import * as os from "os"; -import { Config } from "@/node/config"; -import { ProjectService } from "./projectService"; - -describe("ProjectService", () => { - let tempDir: string; - let config: Config; - let service: ProjectService; - - beforeEach(async () => { - tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "projectservice-test-")); - config = new Config(tempDir); - service = new ProjectService(config); - }); - - afterEach(async () => { - await fs.rm(tempDir, { recursive: true, force: true }); - }); - - describe("listDirectory", () => { - it("returns root node with the actual requested path, not empty string", async () => { - // Create test directory structure - const testDir = path.join(tempDir, "test-project"); - await fs.mkdir(testDir); - await fs.mkdir(path.join(testDir, "subdir1")); - await fs.mkdir(path.join(testDir, "subdir2")); - await fs.writeFile(path.join(testDir, "file.txt"), "test"); - - const result = await service.listDirectory(testDir); - - expect(result.success).toBe(true); - if (!result.success) throw new Error("Expected success"); - - // Critical regression test: root.path must be the actual path, not "" - // This was broken when buildFileTree() was used, which always returns path: "" - expect(result.data.path).toBe(testDir); - expect(result.data.name).toBe(testDir); - expect(result.data.isDirectory).toBe(true); - }); - - it("returns only immediate subdirectories as children", async () => { - const testDir = path.join(tempDir, "nested"); - await fs.mkdir(testDir); - await fs.mkdir(path.join(testDir, "child1")); - await fs.mkdir(path.join(testDir, "child1", "grandchild")); // nested - await fs.mkdir(path.join(testDir, "child2")); - await fs.writeFile(path.join(testDir, "file.txt"), "test"); // file, not dir - - const result = await service.listDirectory(testDir); - - expect(result.success).toBe(true); - if (!result.success) throw new Error("Expected success"); - - // Should only have child1 and child2, not grandchild or file.txt - expect(result.data.children.length).toBe(2); - const childNames = result.data.children.map((c) => c.name).sort(); - expect(childNames).toEqual(["child1", "child2"]); - }); - - it("children have correct full paths", async () => { - const testDir = path.join(tempDir, "paths-test"); - await fs.mkdir(testDir); - await fs.mkdir(path.join(testDir, "mysubdir")); - - const result = await service.listDirectory(testDir); - - expect(result.success).toBe(true); - if (!result.success) throw new Error("Expected success"); - - expect(result.data.children.length).toBe(1); - const child = result.data.children[0]; - expect(child.name).toBe("mysubdir"); - expect(child.path).toBe(path.join(testDir, "mysubdir")); - expect(child.isDirectory).toBe(true); - }); - - it("resolves relative paths to absolute", async () => { - // Create a subdir in tempDir - const subdir = path.join(tempDir, "relative-test"); - await fs.mkdir(subdir); - - const result = await service.listDirectory(subdir); - - expect(result.success).toBe(true); - if (!result.success) throw new Error("Expected success"); - - // Should be resolved to absolute path - expect(path.isAbsolute(result.data.path)).toBe(true); - expect(result.data.path).toBe(subdir); - }); - - it("handles empty directory", async () => { - const emptyDir = path.join(tempDir, "empty"); - await fs.mkdir(emptyDir); - - const result = await service.listDirectory(emptyDir); - - expect(result.success).toBe(true); - if (!result.success) throw new Error("Expected success"); - - expect(result.data.path).toBe(emptyDir); - expect(result.data.children).toEqual([]); - }); - - it("handles '.' path by resolving to current working directory", async () => { - // Save cwd and change to tempDir for this test - const originalCwd = process.cwd(); - // Use realpath to resolve symlinks (e.g., /var -> /private/var on macOS) - const realTempDir = await fs.realpath(tempDir); - process.chdir(realTempDir); - - try { - const result = await service.listDirectory("."); - - expect(result.success).toBe(true); - if (!result.success) throw new Error("Expected success"); - - expect(result.data.path).toBe(realTempDir); - expect(path.isAbsolute(result.data.path)).toBe(true); - } finally { - process.chdir(originalCwd); - } - }); - - it("returns error for non-existent directory", async () => { - const result = await service.listDirectory(path.join(tempDir, "does-not-exist")); - - expect(result.success).toBe(false); - if (result.success) throw new Error("Expected failure"); - expect(result.error).toContain("ENOENT"); - }); - }); -}); diff --git a/src/node/services/projectService.ts b/src/node/services/projectService.ts deleted file mode 100644 index 6195a4e22..000000000 --- a/src/node/services/projectService.ts +++ /dev/null @@ -1,173 +0,0 @@ -import type { Config, ProjectConfig } from "@/node/config"; -import { validateProjectPath } from "@/node/utils/pathUtils"; -import { listLocalBranches, detectDefaultTrunkBranch } from "@/node/git"; -import type { Result } from "@/common/types/result"; -import { Ok, Err } from "@/common/types/result"; -import type { Secret } from "@/common/types/secrets"; -import * as fsPromises from "fs/promises"; -import { log } from "@/node/services/log"; -import type { BranchListResult } from "@/common/orpc/types"; -import type { FileTreeNode } from "@/common/utils/git/numstatParser"; -import * as path from "path"; - -/** - * List directory contents for the DirectoryPickerModal. - * Returns a FileTreeNode where: - * - name and path are the resolved absolute path of the requested directory - * - children are the immediate subdirectories (not recursive) - */ -async function listDirectory(requestedPath: string): Promise { - const normalizedRoot = path.resolve(requestedPath || "."); - const entries = await fsPromises.readdir(normalizedRoot, { withFileTypes: true }); - - const children: FileTreeNode[] = entries - .filter((entry) => entry.isDirectory()) - .map((entry) => { - const entryPath = path.join(normalizedRoot, entry.name); - return { - name: entry.name, - path: entryPath, - isDirectory: true, - children: [], - }; - }); - - return { - name: normalizedRoot, - path: normalizedRoot, - isDirectory: true, - children, - }; -} - -export class ProjectService { - private directoryPicker?: () => Promise; - - constructor(private readonly config: Config) {} - - setDirectoryPicker(picker: () => Promise) { - this.directoryPicker = picker; - } - - async pickDirectory(): Promise { - if (!this.directoryPicker) return null; - return this.directoryPicker(); - } - - async create( - projectPath: string - ): Promise> { - try { - const validation = await validateProjectPath(projectPath); - if (!validation.valid) { - return Err(validation.error ?? "Invalid project path"); - } - - const normalizedPath = validation.expandedPath!; - const config = this.config.loadConfigOrDefault(); - - if (config.projects.has(normalizedPath)) { - return Err("Project already exists"); - } - - const projectConfig: ProjectConfig = { workspaces: [] }; - config.projects.set(normalizedPath, projectConfig); - await this.config.saveConfig(config); - - return Ok({ projectConfig, normalizedPath }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return Err(`Failed to create project: ${message}`); - } - } - - async remove(projectPath: string): Promise> { - try { - const config = this.config.loadConfigOrDefault(); - const projectConfig = config.projects.get(projectPath); - - if (!projectConfig) { - return Err("Project not found"); - } - - if (projectConfig.workspaces.length > 0) { - return Err( - `Cannot remove project with active workspaces. Please remove all ${projectConfig.workspaces.length} workspace(s) first.` - ); - } - - config.projects.delete(projectPath); - await this.config.saveConfig(config); - - try { - await this.config.updateProjectSecrets(projectPath, []); - } catch (error) { - log.error(`Failed to clean up secrets for project ${projectPath}:`, error); - } - - return Ok(undefined); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return Err(`Failed to remove project: ${message}`); - } - } - - list(): Array<[string, ProjectConfig]> { - try { - const config = this.config.loadConfigOrDefault(); - return Array.from(config.projects.entries()); - } catch (error) { - log.error("Failed to list projects:", error); - return []; - } - } - - async listBranches(projectPath: string): Promise { - if (typeof projectPath !== "string" || projectPath.trim().length === 0) { - throw new Error("Project path is required to list branches"); - } - try { - const validation = await validateProjectPath(projectPath); - if (!validation.valid) { - throw new Error(validation.error ?? "Invalid project path"); - } - const normalizedPath = validation.expandedPath!; - const branches = await listLocalBranches(normalizedPath); - const recommendedTrunk = await detectDefaultTrunkBranch(normalizedPath, branches); - return { branches, recommendedTrunk }; - } catch (error) { - log.error("Failed to list branches:", error); - throw error instanceof Error ? error : new Error(String(error)); - } - } - - getSecrets(projectPath: string): Secret[] { - try { - return this.config.getProjectSecrets(projectPath); - } catch (error) { - log.error("Failed to get project secrets:", error); - return []; - } - } - - async listDirectory(path: string) { - try { - const tree = await listDirectory(path); - return { success: true as const, data: tree }; - } catch (error) { - return { - success: false as const, - error: error instanceof Error ? error.message : String(error), - }; - } - } - async updateSecrets(projectPath: string, secrets: Secret[]): Promise> { - try { - await this.config.updateProjectSecrets(projectPath, secrets); - return Ok(undefined); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return Err(`Failed to update project secrets: ${message}`); - } - } -} diff --git a/src/node/services/providerService.ts b/src/node/services/providerService.ts deleted file mode 100644 index 124d3f78a..000000000 --- a/src/node/services/providerService.ts +++ /dev/null @@ -1,135 +0,0 @@ -import type { Config } from "@/node/config"; -import { SUPPORTED_PROVIDERS } from "@/common/constants/providers"; -import type { Result } from "@/common/types/result"; - -/** AWS credential status for Bedrock provider */ -export interface AWSCredentialStatus { - region?: string; - bearerTokenSet: boolean; - accessKeyIdSet: boolean; - secretAccessKeySet: boolean; -} - -export interface ProviderConfigInfo { - apiKeySet: boolean; - baseUrl?: string; - models?: string[]; - /** AWS-specific fields (only present for bedrock provider) */ - aws?: AWSCredentialStatus; -} - -export type ProvidersConfigMap = Record; - -export class ProviderService { - constructor(private readonly config: Config) {} - - public list(): string[] { - try { - return [...SUPPORTED_PROVIDERS]; - } catch (error) { - console.error("Failed to list providers:", error); - return []; - } - } - - /** - * Get the full providers config with safe info (no actual API keys) - */ - public getConfig(): ProvidersConfigMap { - const providersConfig = this.config.loadProvidersConfig() ?? {}; - const result: ProvidersConfigMap = {}; - - for (const provider of SUPPORTED_PROVIDERS) { - const config = (providersConfig[provider] ?? {}) as { - apiKey?: string; - baseUrl?: string; - models?: string[]; - region?: string; - bearerToken?: string; - accessKeyId?: string; - secretAccessKey?: string; - }; - - const providerInfo: ProviderConfigInfo = { - apiKeySet: !!config.apiKey, - baseUrl: config.baseUrl, - models: config.models, - }; - - // AWS/Bedrock-specific fields - if (provider === "bedrock") { - providerInfo.aws = { - region: config.region, - bearerTokenSet: !!config.bearerToken, - accessKeyIdSet: !!config.accessKeyId, - secretAccessKeySet: !!config.secretAccessKey, - }; - } - - result[provider] = providerInfo; - } - - return result; - } - - /** - * Set custom models for a provider - */ - public setModels(provider: string, models: string[]): Result { - try { - const providersConfig = this.config.loadProvidersConfig() ?? {}; - - if (!providersConfig[provider]) { - providersConfig[provider] = {}; - } - - providersConfig[provider].models = models; - this.config.saveProvidersConfig(providersConfig); - - return { success: true, data: undefined }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return { success: false, error: `Failed to set models: ${message}` }; - } - } - - public setConfig(provider: string, keyPath: string[], value: string): Result { - try { - // Load current providers config or create empty - const providersConfig = this.config.loadProvidersConfig() ?? {}; - - // Ensure provider exists - if (!providersConfig[provider]) { - providersConfig[provider] = {}; - } - - // Set nested property value - let current = providersConfig[provider] as Record; - for (let i = 0; i < keyPath.length - 1; i++) { - const key = keyPath[i]; - if (!(key in current) || typeof current[key] !== "object" || current[key] === null) { - current[key] = {}; - } - current = current[key] as Record; - } - - if (keyPath.length > 0) { - const lastKey = keyPath[keyPath.length - 1]; - // Delete key if value is empty string (used for clearing API keys), otherwise set it - if (value === "") { - delete current[lastKey]; - } else { - current[lastKey] = value; - } - } - - // Save updated config - this.config.saveProvidersConfig(providersConfig); - - return { success: true, data: undefined }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return { success: false, error: `Failed to set provider config: ${message}` }; - } - } -} diff --git a/src/node/services/serverService.test.ts b/src/node/services/serverService.test.ts deleted file mode 100644 index 3e64f0c6a..000000000 --- a/src/node/services/serverService.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { ServerService } from "./serverService"; - -describe("ServerService", () => { - test("initializes with null path", async () => { - const service = new ServerService(); - expect(await service.getLaunchProject()).toBeNull(); - }); - - test("sets and gets project path", async () => { - const service = new ServerService(); - service.setLaunchProject("/test/path"); - expect(await service.getLaunchProject()).toBe("/test/path"); - }); - - test("updates project path", async () => { - const service = new ServerService(); - service.setLaunchProject("/path/1"); - expect(await service.getLaunchProject()).toBe("/path/1"); - service.setLaunchProject("/path/2"); - expect(await service.getLaunchProject()).toBe("/path/2"); - }); - - test("clears project path", async () => { - const service = new ServerService(); - service.setLaunchProject("/test/path"); - expect(await service.getLaunchProject()).toBe("/test/path"); - service.setLaunchProject(null); - expect(await service.getLaunchProject()).toBeNull(); - }); -}); diff --git a/src/node/services/serverService.ts b/src/node/services/serverService.ts deleted file mode 100644 index f7106315f..000000000 --- a/src/node/services/serverService.ts +++ /dev/null @@ -1,17 +0,0 @@ -export class ServerService { - private launchProjectPath: string | null = null; - - /** - * Set the launch project path - */ - setLaunchProject(path: string | null): void { - this.launchProjectPath = path; - } - - /** - * Get the launch project path - */ - getLaunchProject(): Promise { - return Promise.resolve(this.launchProjectPath); - } -} diff --git a/src/node/services/serviceContainer.ts b/src/node/services/serviceContainer.ts deleted file mode 100644 index 6c65089fb..000000000 --- a/src/node/services/serviceContainer.ts +++ /dev/null @@ -1,86 +0,0 @@ -import * as path from "path"; -import type { Config } from "@/node/config"; -import { AIService } from "@/node/services/aiService"; -import { HistoryService } from "@/node/services/historyService"; -import { PartialService } from "@/node/services/partialService"; -import { InitStateManager } from "@/node/services/initStateManager"; -import { PTYService } from "@/node/services/ptyService"; -import type { TerminalWindowManager } from "@/desktop/terminalWindowManager"; -import { ProjectService } from "@/node/services/projectService"; -import { WorkspaceService } from "@/node/services/workspaceService"; -import { ProviderService } from "@/node/services/providerService"; -import { ExtensionMetadataService } from "@/node/services/ExtensionMetadataService"; -import { TerminalService } from "@/node/services/terminalService"; -import { WindowService } from "@/node/services/windowService"; -import { UpdateService } from "@/node/services/updateService"; -import { TokenizerService } from "@/node/services/tokenizerService"; -import { ServerService } from "@/node/services/serverService"; - -/** - * ServiceContainer - Central dependency container for all backend services. - * - * This class instantiates and wires together all services needed by the ORPC router. - * Services are accessed via the ORPC context object. - */ -export class ServiceContainer { - private readonly config: Config; - private readonly historyService: HistoryService; - private readonly partialService: PartialService; - private readonly aiService: AIService; - public readonly projectService: ProjectService; - public readonly workspaceService: WorkspaceService; - public readonly providerService: ProviderService; - public readonly terminalService: TerminalService; - public readonly windowService: WindowService; - public readonly updateService: UpdateService; - public readonly tokenizerService: TokenizerService; - public readonly serverService: ServerService; - private readonly initStateManager: InitStateManager; - private readonly extensionMetadata: ExtensionMetadataService; - private readonly ptyService: PTYService; - - constructor(config: Config) { - this.config = config; - this.historyService = new HistoryService(config); - this.partialService = new PartialService(config, this.historyService); - this.projectService = new ProjectService(config); - this.initStateManager = new InitStateManager(config); - this.extensionMetadata = new ExtensionMetadataService( - path.join(config.rootDir, "extensionMetadata.json") - ); - this.aiService = new AIService( - config, - this.historyService, - this.partialService, - this.initStateManager - ); - this.workspaceService = new WorkspaceService( - config, - this.historyService, - this.partialService, - this.aiService, - this.initStateManager, - this.extensionMetadata - ); - this.providerService = new ProviderService(config); - // Terminal services - PTYService is cross-platform - this.ptyService = new PTYService(); - this.terminalService = new TerminalService(config, this.ptyService); - this.windowService = new WindowService(); - this.updateService = new UpdateService(); - this.tokenizerService = new TokenizerService(); - this.serverService = new ServerService(); - } - - async initialize(): Promise { - await this.extensionMetadata.initialize(); - } - - setProjectDirectoryPicker(picker: () => Promise): void { - this.projectService.setDirectoryPicker(picker); - } - - setTerminalWindowManager(manager: TerminalWindowManager): void { - this.terminalService.setTerminalWindowManager(manager); - } -} diff --git a/src/node/services/streamManager.ts b/src/node/services/streamManager.ts index 7d0294e0a..3a10893cc 100644 --- a/src/node/services/streamManager.ts +++ b/src/node/services/streamManager.ts @@ -107,13 +107,11 @@ interface WorkspaceStreamInfo { // Track last partial write time for throttling lastPartialWriteTime: number; // Throttle timer for partial writes - partialWriteTimer?: ReturnType; + partialWriteTimer?: NodeJS.Timeout; // Track in-flight write to serialize writes partialWritePromise?: Promise; // Track background processing promise for guaranteed cleanup processingPromise: Promise; - // Soft-interrupt state: when pending, stream will end at next block boundary - softInterrupt: { pending: false } | { pending: true; abandonPartial: boolean }; // Temporary directory for tool outputs (auto-cleaned when stream ends) runtimeTempDir: string; // Runtime for temp directory cleanup @@ -420,40 +418,31 @@ export class StreamManager extends EventEmitter { ): Promise { try { streamInfo.state = StreamState.STOPPING; + // Flush any pending partial write immediately (preserves work on interruption) await this.flushPartialWrite(workspaceId, streamInfo); streamInfo.abortController.abort(); - await this.cleanupStream(workspaceId, streamInfo, abandonPartial); - } catch (error) { - console.error("Error during stream cancellation:", error); - // Force cleanup even if cancellation fails - this.workspaceStreams.delete(workspaceId); - } - } + // CRITICAL: Wait for processing to fully complete before cleanup + // This prevents race conditions where the old stream is still running + // while a new stream starts (e.g., old stream writing to partial.json) + await streamInfo.processingPromise; - // Checks if a soft interrupt is necessary, and performs one if so - // Similar to cancelStreamSafely but performs cleanup without blocking - private async checkSoftCancelStream( - workspaceId: WorkspaceId, - streamInfo: WorkspaceStreamInfo - ): Promise { - if (!streamInfo.softInterrupt.pending) return; - try { - streamInfo.state = StreamState.STOPPING; + // Get usage and duration metadata (usage may be undefined if aborted early) + const { usage, duration } = await this.getStreamMetadata(streamInfo); - // Flush any pending partial write immediately (preserves work on interruption) - await this.flushPartialWrite(workspaceId, streamInfo); - - streamInfo.abortController.abort(); + // Emit abort event with usage if available + this.emit("stream-abort", { + type: "stream-abort", + workspaceId: workspaceId as string, + messageId: streamInfo.messageId, + metadata: { usage, duration }, + abandonPartial, + }); - // Return back to the stream loop so we can wait for it to finish before - // sending the stream abort event. - const abandonPartial = streamInfo.softInterrupt.pending - ? streamInfo.softInterrupt.abandonPartial - : false; - void this.cleanupStream(workspaceId, streamInfo, abandonPartial); + // Clean up immediately + this.workspaceStreams.delete(workspaceId); } catch (error) { console.error("Error during stream cancellation:", error); // Force cleanup even if cancellation fails @@ -461,32 +450,6 @@ export class StreamManager extends EventEmitter { } } - private async cleanupStream( - workspaceId: WorkspaceId, - streamInfo: WorkspaceStreamInfo, - abandonPartial?: boolean - ): Promise { - // CRITICAL: Wait for processing to fully complete before cleanup - // This prevents race conditions where the old stream is still running - // while a new stream starts (e.g., old stream writing to partial.json) - await streamInfo.processingPromise; - - // Get usage and duration metadata (usage may be undefined if aborted early) - const { usage, duration } = await this.getStreamMetadata(streamInfo); - - // Emit abort event with usage if available - this.emit("stream-abort", { - type: "stream-abort", - workspaceId: workspaceId as string, - messageId: streamInfo.messageId, - metadata: { usage, duration }, - abandonPartial, - }); - - // Clean up immediately - this.workspaceStreams.delete(workspaceId); - } - /** * Atomically creates a new stream with all necessary setup */ @@ -592,7 +555,6 @@ export class StreamManager extends EventEmitter { lastPartialWriteTime: 0, // Initialize to 0 to allow immediate first write partialWritePromise: undefined, // No write in flight initially processingPromise: Promise.resolve(), // Placeholder, overwritten in startStream - softInterrupt: { pending: false }, runtimeTempDir, // Stream-scoped temp directory for tool outputs runtime, // Runtime for temp directory cleanup }; @@ -756,7 +718,6 @@ export class StreamManager extends EventEmitter { workspaceId: workspaceId as string, messageId: streamInfo.messageId, }); - await this.checkSoftCancelStream(workspaceId, streamInfo); break; } @@ -811,7 +772,6 @@ export class StreamManager extends EventEmitter { strippedOutput ); } - await this.checkSoftCancelStream(workspaceId, streamInfo); break; } @@ -848,7 +808,6 @@ export class StreamManager extends EventEmitter { toolErrorPart.toolName, errorOutput ); - await this.checkSoftCancelStream(workspaceId, streamInfo); break; } @@ -893,7 +852,6 @@ export class StreamManager extends EventEmitter { case "start": case "start-step": case "text-start": - case "finish": // These events can be logged or handled if needed break; @@ -911,14 +869,13 @@ export class StreamManager extends EventEmitter { usage: finishStepPart.usage, }; this.emit("usage-delta", usageEvent); - await this.checkSoftCancelStream(workspaceId, streamInfo); break; } - case "text-end": { - await this.checkSoftCancelStream(workspaceId, streamInfo); + case "finish": + // No usage-delta here - totalUsage sums all steps, not current context. + // Last finish-step already has correct context window usage. break; - } } } @@ -1406,32 +1363,14 @@ export class StreamManager extends EventEmitter { /** * Stops an active stream for a workspace - * First call: Sets soft interrupt and emits delta event → frontend shows "Interrupting..." - * Second call: Hard aborts the stream immediately */ - async stopStream( - workspaceId: string, - options?: { soft?: boolean; abandonPartial?: boolean } - ): Promise> { + async stopStream(workspaceId: string, abandonPartial?: boolean): Promise> { const typedWorkspaceId = workspaceId as WorkspaceId; try { const streamInfo = this.workspaceStreams.get(typedWorkspaceId); - if (!streamInfo) { - return Ok(undefined); // No active stream - } - - const soft = options?.soft ?? false; - - if (soft) { - // Soft interrupt: set flag, will cancel at next block boundary - streamInfo.softInterrupt = { - pending: true, - abandonPartial: options?.abandonPartial ?? false, - }; - } else { - // Hard interrupt: cancel immediately - await this.cancelStreamSafely(typedWorkspaceId, streamInfo, options?.abandonPartial); + if (streamInfo) { + await this.cancelStreamSafely(typedWorkspaceId, streamInfo, abandonPartial); } return Ok(undefined); } catch (error) { diff --git a/src/node/services/terminalService.test.ts b/src/node/services/terminalService.test.ts deleted file mode 100644 index 49712b2b0..000000000 --- a/src/node/services/terminalService.test.ts +++ /dev/null @@ -1,448 +0,0 @@ -import { describe, it, expect, mock, beforeEach, afterEach, spyOn, type Mock } from "bun:test"; -import { TerminalService } from "./terminalService"; -import type { PTYService } from "./ptyService"; -import type { Config } from "@/node/config"; -import type { TerminalWindowManager } from "@/desktop/terminalWindowManager"; -import type { TerminalCreateParams } from "@/common/types/terminal"; -import * as childProcess from "child_process"; -import * as fs from "fs/promises"; - -// Mock dependencies -const mockConfig = { - getAllWorkspaceMetadata: mock(() => - Promise.resolve([ - { - id: "ws-1", - projectPath: "/tmp/project", - name: "main", - runtimeConfig: { type: "local", srcBaseDir: "/tmp" }, - }, - ]) - ), - srcDir: "/tmp", -} as unknown as Config; - -const createSessionMock = mock( - ( - params: TerminalCreateParams, - _runtime: unknown, - _path: string, - onData: (d: string) => void, - _onExit: (code: number) => void - ) => { - // Simulate immediate data emission to test buffering - onData("initial data"); - return Promise.resolve({ - sessionId: "session-1", - workspaceId: params.workspaceId, - cols: 80, - rows: 24, - }); - } -); - -const resizeMock = mock(() => { - /* no-op */ -}); -const sendInputMock = mock(() => { - /* no-op */ -}); -const closeSessionMock = mock(() => { - /* no-op */ -}); - -const mockPTYService = { - createSession: createSessionMock, - closeSession: closeSessionMock, - resize: resizeMock, - sendInput: sendInputMock, -} as unknown as PTYService; - -const openTerminalWindowMock = mock(() => Promise.resolve()); -const closeTerminalWindowMock = mock(() => { - /* no-op */ -}); - -const mockWindowManager = { - openTerminalWindow: openTerminalWindowMock, - closeTerminalWindow: closeTerminalWindowMock, -} as unknown as TerminalWindowManager; - -describe("TerminalService", () => { - let service: TerminalService; - - beforeEach(() => { - service = new TerminalService(mockConfig, mockPTYService); - service.setTerminalWindowManager(mockWindowManager); - createSessionMock.mockClear(); - resizeMock.mockClear(); - sendInputMock.mockClear(); - openTerminalWindowMock.mockClear(); - }); - - it("should create a session and buffer initial output", async () => { - const session = await service.create({ - workspaceId: "ws-1", - cols: 80, - rows: 24, - }); - - expect(session.sessionId).toBe("session-1"); - expect(createSessionMock).toHaveBeenCalled(); - - // Verify buffering: subscribe AFTER creation - let output = ""; - const unsubscribe = service.onOutput("session-1", (data) => { - output += data; - }); - - expect(output).toBe("initial data"); - unsubscribe(); - }); - - it("should handle resizing", () => { - service.resize({ sessionId: "session-1", cols: 100, rows: 30 }); - expect(resizeMock).toHaveBeenCalledWith({ - sessionId: "session-1", - cols: 100, - rows: 30, - }); - }); - - it("should handle input", () => { - service.sendInput("session-1", "ls\n"); - expect(sendInputMock).toHaveBeenCalledWith("session-1", "ls\n"); - }); - - it("should open terminal window via manager", async () => { - await service.openWindow("ws-1"); - expect(openTerminalWindowMock).toHaveBeenCalledWith("ws-1"); - }); - - it("should handle session exit", async () => { - // We need to capture the onExit callback passed to createSession - let capturedOnExit: ((code: number) => void) | undefined; - - // Override mock temporarily for this test - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (mockPTYService.createSession as any) = mock( - ( - params: TerminalCreateParams, - _runtime: unknown, - _path: string, - _onData: unknown, - onExit: (code: number) => void - ) => { - capturedOnExit = onExit; - return Promise.resolve({ - sessionId: "session-2", - workspaceId: params.workspaceId, - cols: 80, - rows: 24, - }); - } - ); - - await service.create({ workspaceId: "ws-1", cols: 80, rows: 24 }); - - let exitCode: number | null = null; - service.onExit("session-2", (code) => { - exitCode = code; - }); - - // Simulate exit - if (capturedOnExit) capturedOnExit(0); - - expect(exitCode as unknown as number).toBe(0); - - // Restore mock (optional if beforeEach resets, but we are replacing the reference on the object) - // Actually best to restore it. - // However, since we defined mockPTYService as a const object, we can't easily replace properties safely if they are readonly. - // But they are not readonly in the mock definition. - // Let's just restore it to createSessionMock. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (mockPTYService.createSession as any) = createSessionMock; - }); -}); - -describe("TerminalService.openNative", () => { - let service: TerminalService; - // Using simplified mock types since spawnSync has complex overloads - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let spawnSpy: Mock; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let spawnSyncSpy: Mock; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let fsStatSpy: Mock; - let originalPlatform: NodeJS.Platform; - - // Helper to create a mock child process - const createMockChildProcess = () => - ({ - unref: mock(() => undefined), - on: mock(() => undefined), - pid: 12345, - }) as unknown as ReturnType; - - // Config with local workspace - const configWithLocalWorkspace = { - getAllWorkspaceMetadata: mock(() => - Promise.resolve([ - { - id: "ws-local", - projectPath: "/tmp/project", - name: "main", - namedWorkspacePath: "/tmp/project/main", - runtimeConfig: { type: "local", srcBaseDir: "/tmp" }, - }, - ]) - ), - srcDir: "/tmp", - } as unknown as Config; - - // Config with SSH workspace - const configWithSSHWorkspace = { - getAllWorkspaceMetadata: mock(() => - Promise.resolve([ - { - id: "ws-ssh", - projectPath: "/home/user/project", - name: "feature", - namedWorkspacePath: "/home/user/project/feature", - runtimeConfig: { - type: "ssh", - host: "remote.example.com", - port: 2222, - identityFile: "~/.ssh/id_rsa", - }, - }, - ]) - ), - srcDir: "/tmp", - } as unknown as Config; - - beforeEach(() => { - // Store original platform - originalPlatform = process.platform; - - // Spy on spawn to capture calls without actually spawning processes - // Using `as unknown as` to bypass complex overload matching - spawnSpy = spyOn(childProcess, "spawn").mockImplementation((() => - createMockChildProcess()) as unknown as typeof childProcess.spawn); - - // Spy on spawnSync for command availability checks - spawnSyncSpy = spyOn(childProcess, "spawnSync").mockImplementation((() => ({ - status: 0, - output: [null, "/usr/bin/cmd"], - })) as unknown as typeof childProcess.spawnSync); - - // Spy on fs.stat to reject (no ghostty installed by default) - fsStatSpy = spyOn(fs, "stat").mockImplementation((() => - Promise.reject(new Error("ENOENT"))) as unknown as typeof fs.stat); - }); - - afterEach(() => { - // Restore original platform - Object.defineProperty(process, "platform", { value: originalPlatform }); - // Restore spies - spawnSpy.mockRestore(); - spawnSyncSpy.mockRestore(); - fsStatSpy.mockRestore(); - }); - - /** - * Helper to set the platform for testing - */ - function setPlatform(platform: NodeJS.Platform) { - Object.defineProperty(process, "platform", { value: platform }); - } - - describe("macOS (darwin)", () => { - beforeEach(() => { - setPlatform("darwin"); - }); - - it("should open Terminal.app for local workspace when ghostty is not available", async () => { - // spawnSync returns non-zero for ghostty check (not available) - spawnSyncSpy.mockImplementation((cmd: string, args: string[]) => { - if (cmd === "which" && args?.[0] === "ghostty") { - return { status: 1 }; // ghostty not found - } - return { status: 0 }; // other commands available - }); - - service = new TerminalService(configWithLocalWorkspace, mockPTYService); - - await service.openNative("ws-local"); - - expect(spawnSpy).toHaveBeenCalledTimes(1); - // Type assertion for spawn call args: [command, args, options] - const call = spawnSpy.mock.calls[0] as [string, string[], childProcess.SpawnOptions]; - expect(call[0]).toBe("open"); - expect(call[1]).toEqual(["-a", "Terminal", "/tmp/project/main"]); - expect(call[2]?.detached).toBe(true); - expect(call[2]?.stdio).toBe("ignore"); - }); - - it("should open Ghostty for local workspace when available", async () => { - // Make ghostty available via fs.stat (common install path) - fsStatSpy.mockImplementation((path: string) => { - if (path === "/Applications/Ghostty.app/Contents/MacOS/ghostty") { - return Promise.resolve({ isFile: () => true, mode: 0o755 }); - } - return Promise.reject(new Error("ENOENT")); - }); - - service = new TerminalService(configWithLocalWorkspace, mockPTYService); - - await service.openNative("ws-local"); - - expect(spawnSpy).toHaveBeenCalledTimes(1); - const call = spawnSpy.mock.calls[0] as [string, string[], childProcess.SpawnOptions]; - expect(call[0]).toBe("open"); - expect(call[1]).toContain("-a"); - expect(call[1]).toContain("Ghostty"); - expect(call[1]).toContain("/tmp/project/main"); - }); - - it("should use osascript for SSH workspace with Terminal.app", async () => { - // No ghostty available - spawnSyncSpy.mockImplementation((cmd: string, args: string[]) => { - if (cmd === "which" && args?.[0] === "ghostty") { - return { status: 1 }; - } - return { status: 0 }; - }); - - service = new TerminalService(configWithSSHWorkspace, mockPTYService); - - await service.openNative("ws-ssh"); - - expect(spawnSpy).toHaveBeenCalledTimes(1); - const call = spawnSpy.mock.calls[0] as [string, string[], childProcess.SpawnOptions]; - expect(call[0]).toBe("osascript"); - expect(call[1]?.[0]).toBe("-e"); - // Verify the AppleScript contains SSH command with proper args - const script = call[1]?.[1]; - expect(script).toContain('tell application "Terminal"'); - expect(script).toContain("ssh"); - expect(script).toContain("-p 2222"); // port - expect(script).toContain("-i ~/.ssh/id_rsa"); // identity file - expect(script).toContain("remote.example.com"); // host - }); - }); - - describe("Windows (win32)", () => { - beforeEach(() => { - setPlatform("win32"); - }); - - it("should open cmd for local workspace", async () => { - service = new TerminalService(configWithLocalWorkspace, mockPTYService); - - await service.openNative("ws-local"); - - expect(spawnSpy).toHaveBeenCalledTimes(1); - const call = spawnSpy.mock.calls[0] as [string, string[], childProcess.SpawnOptions]; - expect(call[0]).toBe("cmd"); - expect(call[1]).toEqual(["/c", "start", "cmd", "/K", "cd", "/D", "/tmp/project/main"]); - expect(call[2]?.shell).toBe(true); - }); - - it("should open cmd with SSH for SSH workspace", async () => { - service = new TerminalService(configWithSSHWorkspace, mockPTYService); - - await service.openNative("ws-ssh"); - - expect(spawnSpy).toHaveBeenCalledTimes(1); - const call = spawnSpy.mock.calls[0] as [string, string[], childProcess.SpawnOptions]; - expect(call[0]).toBe("cmd"); - expect(call[1]?.[0]).toBe("/c"); - expect(call[1]?.[1]).toBe("start"); - expect(call[1]).toContain("ssh"); - expect(call[1]).toContain("-p"); - expect(call[1]).toContain("2222"); - expect(call[1]).toContain("remote.example.com"); - }); - }); - - describe("Linux", () => { - beforeEach(() => { - setPlatform("linux"); - }); - - it("should try terminal emulators in order of preference", async () => { - // Make gnome-terminal the first available - spawnSyncSpy.mockImplementation((cmd: string, args: string[]) => { - if (cmd === "which") { - const terminal = args?.[0]; - // x-terminal-emulator, ghostty, alacritty, kitty, wezterm not found - // gnome-terminal found - if (terminal === "gnome-terminal") { - return { status: 0 }; - } - return { status: 1 }; - } - return { status: 0 }; - }); - - service = new TerminalService(configWithLocalWorkspace, mockPTYService); - - await service.openNative("ws-local"); - - expect(spawnSpy).toHaveBeenCalledTimes(1); - const call = spawnSpy.mock.calls[0] as [string, string[], childProcess.SpawnOptions]; - expect(call[0]).toBe("gnome-terminal"); - expect(call[1]).toContain("--working-directory"); - expect(call[1]).toContain("/tmp/project/main"); - }); - - it("should throw error when no terminal emulator is found", async () => { - // All terminals not found - spawnSyncSpy.mockImplementation(() => ({ status: 1 })); - - service = new TerminalService(configWithLocalWorkspace, mockPTYService); - - // eslint-disable-next-line @typescript-eslint/await-thenable - await expect(service.openNative("ws-local")).rejects.toThrow("No terminal emulator found"); - }); - - it("should pass SSH args to terminal for SSH workspace", async () => { - // Make alacritty available - spawnSyncSpy.mockImplementation((cmd: string, args: string[]) => { - if (cmd === "which" && args?.[0] === "alacritty") { - return { status: 0 }; - } - return { status: 1 }; - }); - - service = new TerminalService(configWithSSHWorkspace, mockPTYService); - - await service.openNative("ws-ssh"); - - expect(spawnSpy).toHaveBeenCalledTimes(1); - const call = spawnSpy.mock.calls[0] as [string, string[], childProcess.SpawnOptions]; - expect(call[0]).toBe("alacritty"); - expect(call[1]).toContain("-e"); - expect(call[1]).toContain("ssh"); - expect(call[1]).toContain("-p"); - expect(call[1]).toContain("2222"); - }); - }); - - describe("error handling", () => { - beforeEach(() => { - setPlatform("darwin"); - spawnSyncSpy.mockImplementation(() => ({ status: 0 })); - }); - - it("should throw error for non-existent workspace", async () => { - service = new TerminalService(configWithLocalWorkspace, mockPTYService); - - // eslint-disable-next-line @typescript-eslint/await-thenable - await expect(service.openNative("non-existent")).rejects.toThrow( - "Workspace not found: non-existent" - ); - }); - }); -}); diff --git a/src/node/services/terminalService.ts b/src/node/services/terminalService.ts deleted file mode 100644 index ebda1d03d..000000000 --- a/src/node/services/terminalService.ts +++ /dev/null @@ -1,545 +0,0 @@ -import { EventEmitter } from "events"; -import { spawn, spawnSync } from "child_process"; -import * as fs from "fs/promises"; -import type { Config } from "@/node/config"; -import type { PTYService } from "@/node/services/ptyService"; -import type { TerminalWindowManager } from "@/desktop/terminalWindowManager"; -import type { - TerminalSession, - TerminalCreateParams, - TerminalResizeParams, -} from "@/common/types/terminal"; -import { createRuntime } from "@/node/runtime/runtimeFactory"; -import type { RuntimeConfig } from "@/common/types/runtime"; -import { isSSHRuntime } from "@/common/types/runtime"; -import { log } from "@/node/services/log"; - -/** - * Configuration for opening a native terminal - */ -type NativeTerminalConfig = - | { type: "local"; workspacePath: string } - | { - type: "ssh"; - sshConfig: Extract; - remotePath: string; - }; - -export class TerminalService { - private readonly config: Config; - private readonly ptyService: PTYService; - private terminalWindowManager?: TerminalWindowManager; - - // Event emitters for each session - private readonly outputEmitters = new Map(); - private readonly exitEmitters = new Map(); - - // Buffer for initial output to handle race condition between create and subscribe - // Map - private readonly outputBuffers = new Map(); - private readonly MAX_BUFFER_SIZE = 50; // Keep last 50 chunks - - constructor(config: Config, ptyService: PTYService) { - this.config = config; - this.ptyService = ptyService; - } - - setTerminalWindowManager(manager: TerminalWindowManager) { - this.terminalWindowManager = manager; - } - - async create(params: TerminalCreateParams): Promise { - try { - // 1. Resolve workspace - const allMetadata = await this.config.getAllWorkspaceMetadata(); - const workspaceMetadata = allMetadata.find((w) => w.id === params.workspaceId); - - if (!workspaceMetadata) { - throw new Error(`Workspace not found: ${params.workspaceId}`); - } - - // 2. Create runtime - const runtime = createRuntime( - workspaceMetadata.runtimeConfig ?? { type: "local", srcBaseDir: this.config.srcDir } - ); - - // 3. Compute workspace path - const workspacePath = runtime.getWorkspacePath( - workspaceMetadata.projectPath, - workspaceMetadata.name - ); - - // 4. Setup emitters and buffer - // We don't know the sessionId yet (PTYService generates it), but PTYService uses a callback. - // We need to capture the sessionId. - // Actually PTYService returns the session object with ID. - // But the callbacks are passed IN to createSession. - // So we need a way to map the callback to the future sessionId. - - // Hack: We'll create a temporary object to hold the emitter/buffer and assign it to the map once we have the ID. - // But the callback runs *after* creation usually (when data comes). - // However, it's safer to create the emitter *before* passing callbacks if we can. - // We can't key it by sessionId yet. - - let tempSessionId: string | null = null; - const localBuffer: string[] = []; - - const onData = (data: string) => { - if (tempSessionId) { - this.emitOutput(tempSessionId, data); - } else { - // Buffer data if session ID is not yet available (race condition during creation) - localBuffer.push(data); - } - }; - - const onExit = (code: number) => { - if (tempSessionId) { - const emitter = this.exitEmitters.get(tempSessionId); - emitter?.emit("exit", code); - this.cleanup(tempSessionId); - } - }; - - // 5. Create session - const session = await this.ptyService.createSession( - params, - runtime, - workspacePath, - onData, - onExit - ); - - tempSessionId = session.sessionId; - - // Initialize emitters - this.outputEmitters.set(session.sessionId, new EventEmitter()); - this.exitEmitters.set(session.sessionId, new EventEmitter()); - this.outputBuffers.set(session.sessionId, []); - - // Replay local buffer that arrived during creation - for (const data of localBuffer) { - this.emitOutput(session.sessionId, data); - } - - return session; - } catch (err) { - log.error("Error creating terminal session:", err); - throw err; - } - } - - close(sessionId: string): void { - try { - this.ptyService.closeSession(sessionId); - this.cleanup(sessionId); - } catch (err) { - log.error("Error closing terminal session:", err); - throw err; - } - } - - resize(params: TerminalResizeParams): void { - try { - this.ptyService.resize(params); - } catch (err) { - log.error("Error resizing terminal:", err); - throw err; - } - } - - sendInput(sessionId: string, data: string): void { - try { - this.ptyService.sendInput(sessionId, data); - } catch (err) { - log.error(`Error sending input to terminal ${sessionId}:`, err); - throw err; - } - } - - async openWindow(workspaceId: string): Promise { - try { - const allMetadata = await this.config.getAllWorkspaceMetadata(); - const workspace = allMetadata.find((w) => w.id === workspaceId); - - if (!workspace) { - throw new Error(`Workspace not found: ${workspaceId}`); - } - - const runtimeConfig = workspace.runtimeConfig; - const isSSH = isSSHRuntime(runtimeConfig); - const isDesktop = !!this.terminalWindowManager; - - if (isDesktop) { - log.info(`Opening terminal window for workspace: ${workspaceId}`); - await this.terminalWindowManager!.openTerminalWindow(workspaceId); - } else { - log.info( - `Browser mode: terminal UI handled by browser for ${isSSH ? "SSH" : "local"} workspace: ${workspaceId}` - ); - } - } catch (err) { - log.error("Error opening terminal window:", err); - throw err; - } - } - - closeWindow(workspaceId: string): void { - try { - if (!this.terminalWindowManager) { - // Not an error in server mode, just no-op - return; - } - this.terminalWindowManager.closeTerminalWindow(workspaceId); - } catch (err) { - log.error("Error closing terminal window:", err); - throw err; - } - } - - /** - * Open the native system terminal for a workspace. - * Opens the user's preferred terminal emulator (Ghostty, Terminal.app, etc.) - * with the working directory set to the workspace path. - * - * For SSH workspaces, opens a terminal that SSHs into the remote host. - */ - async openNative(workspaceId: string): Promise { - try { - const allMetadata = await this.config.getAllWorkspaceMetadata(); - const workspace = allMetadata.find((w) => w.id === workspaceId); - - if (!workspace) { - throw new Error(`Workspace not found: ${workspaceId}`); - } - - const runtimeConfig = workspace.runtimeConfig; - - if (isSSHRuntime(runtimeConfig)) { - // SSH workspace - spawn local terminal that SSHs into remote host - await this.openNativeTerminal({ - type: "ssh", - sshConfig: runtimeConfig, - remotePath: workspace.namedWorkspacePath, - }); - } else { - // Local workspace - spawn terminal with cwd set - await this.openNativeTerminal({ - type: "local", - workspacePath: workspace.namedWorkspacePath, - }); - } - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - log.error(`Failed to open native terminal: ${message}`); - throw err; - } - } - - /** - * Open a native terminal (local or SSH) with platform-specific handling. - * This spawns the user's native terminal emulator, not a web-based terminal. - */ - private async openNativeTerminal(config: NativeTerminalConfig): Promise { - const isSSH = config.type === "ssh"; - - // Build SSH args if needed - let sshArgs: string[] | null = null; - if (isSSH) { - sshArgs = []; - // Add port if specified - if (config.sshConfig.port) { - sshArgs.push("-p", String(config.sshConfig.port)); - } - // Add identity file if specified - if (config.sshConfig.identityFile) { - sshArgs.push("-i", config.sshConfig.identityFile); - } - // Force pseudo-terminal allocation - sshArgs.push("-t"); - // Add host - sshArgs.push(config.sshConfig.host); - // Add remote command to cd into directory and start shell - // Use single quotes to prevent local shell expansion - // exec $SHELL replaces the SSH process with the shell, avoiding nested processes - sshArgs.push(`cd '${config.remotePath.replace(/'/g, "'\\''")}' && exec $SHELL`); - } - - const logPrefix = isSSH ? "SSH terminal" : "terminal"; - - if (process.platform === "darwin") { - await this.openNativeTerminalMacOS(config, sshArgs, logPrefix); - } else if (process.platform === "win32") { - this.openNativeTerminalWindows(config, sshArgs, logPrefix); - } else { - await this.openNativeTerminalLinux(config, sshArgs, logPrefix); - } - } - - private async openNativeTerminalMacOS( - config: NativeTerminalConfig, - sshArgs: string[] | null, - logPrefix: string - ): Promise { - const isSSH = config.type === "ssh"; - - // macOS - try Ghostty first, fallback to Terminal.app - const terminal = await this.findAvailableCommand(["ghostty", "terminal"]); - if (terminal === "ghostty") { - const cmd = "open"; - let args: string[]; - if (isSSH && sshArgs) { - // Ghostty: Use --command flag to run SSH - // Build the full SSH command as a single string - const sshCommand = ["ssh", ...sshArgs].join(" "); - args = ["-n", "-a", "Ghostty", "--args", `--command=${sshCommand}`]; - } else { - // Ghostty: Pass workspacePath to 'open -a Ghostty' to avoid regressions - if (config.type !== "local") throw new Error("Expected local config"); - args = ["-a", "Ghostty", config.workspacePath]; - } - log.info(`Opening ${logPrefix}: ${cmd} ${args.join(" ")}`); - const child = spawn(cmd, args, { - detached: true, - stdio: "ignore", - }); - child.unref(); - } else { - // Terminal.app - const cmd = isSSH ? "osascript" : "open"; - let args: string[]; - if (isSSH && sshArgs) { - // Terminal.app: Use osascript with proper AppleScript structure - // Properly escape single quotes in args before wrapping in quotes - const sshCommand = `ssh ${sshArgs - .map((arg) => { - if (arg.includes(" ") || arg.includes("'")) { - // Escape single quotes by ending quote, adding escaped quote, starting quote again - return `'${arg.replace(/'/g, "'\\''")}'`; - } - return arg; - }) - .join(" ")}`; - // Escape double quotes for AppleScript string - const escapedCommand = sshCommand.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); - const script = `tell application "Terminal"\nactivate\ndo script "${escapedCommand}"\nend tell`; - args = ["-e", script]; - } else { - // Terminal.app opens in the directory when passed as argument - if (config.type !== "local") throw new Error("Expected local config"); - args = ["-a", "Terminal", config.workspacePath]; - } - log.info(`Opening ${logPrefix}: ${cmd} ${args.join(" ")}`); - const child = spawn(cmd, args, { - detached: true, - stdio: "ignore", - }); - child.unref(); - } - } - - private openNativeTerminalWindows( - config: NativeTerminalConfig, - sshArgs: string[] | null, - logPrefix: string - ): void { - const isSSH = config.type === "ssh"; - - // Windows - const cmd = "cmd"; - let args: string[]; - if (isSSH && sshArgs) { - // Windows - use cmd to start ssh - args = ["/c", "start", "cmd", "/K", "ssh", ...sshArgs]; - } else { - if (config.type !== "local") throw new Error("Expected local config"); - args = ["/c", "start", "cmd", "/K", "cd", "/D", config.workspacePath]; - } - log.info(`Opening ${logPrefix}: ${cmd} ${args.join(" ")}`); - const child = spawn(cmd, args, { - detached: true, - shell: true, - stdio: "ignore", - }); - child.unref(); - } - - private async openNativeTerminalLinux( - config: NativeTerminalConfig, - sshArgs: string[] | null, - logPrefix: string - ): Promise { - const isSSH = config.type === "ssh"; - - // Linux - try terminal emulators in order of preference - let terminals: Array<{ cmd: string; args: string[]; cwd?: string }>; - - if (isSSH && sshArgs) { - // x-terminal-emulator is checked first as it respects user's system-wide preference - terminals = [ - { cmd: "x-terminal-emulator", args: ["-e", "ssh", ...sshArgs] }, - { cmd: "ghostty", args: ["ssh", ...sshArgs] }, - { cmd: "alacritty", args: ["-e", "ssh", ...sshArgs] }, - { cmd: "kitty", args: ["ssh", ...sshArgs] }, - { cmd: "wezterm", args: ["start", "--", "ssh", ...sshArgs] }, - { cmd: "gnome-terminal", args: ["--", "ssh", ...sshArgs] }, - { cmd: "konsole", args: ["-e", "ssh", ...sshArgs] }, - { cmd: "xfce4-terminal", args: ["-e", `ssh ${sshArgs.join(" ")}`] }, - { cmd: "xterm", args: ["-e", "ssh", ...sshArgs] }, - ]; - } else { - if (config.type !== "local") throw new Error("Expected local config"); - const workspacePath = config.workspacePath; - terminals = [ - { cmd: "x-terminal-emulator", args: [], cwd: workspacePath }, - { cmd: "ghostty", args: ["--working-directory=" + workspacePath] }, - { cmd: "alacritty", args: ["--working-directory", workspacePath] }, - { cmd: "kitty", args: ["--directory", workspacePath] }, - { cmd: "wezterm", args: ["start", "--cwd", workspacePath] }, - { cmd: "gnome-terminal", args: ["--working-directory", workspacePath] }, - { cmd: "konsole", args: ["--workdir", workspacePath] }, - { cmd: "xfce4-terminal", args: ["--working-directory", workspacePath] }, - { cmd: "xterm", args: [], cwd: workspacePath }, - ]; - } - - const availableTerminal = await this.findAvailableTerminal(terminals); - - if (availableTerminal) { - const cwdInfo = availableTerminal.cwd ? ` (cwd: ${availableTerminal.cwd})` : ""; - log.info( - `Opening ${logPrefix}: ${availableTerminal.cmd} ${availableTerminal.args.join(" ")}${cwdInfo}` - ); - const child = spawn(availableTerminal.cmd, availableTerminal.args, { - cwd: availableTerminal.cwd, - detached: true, - stdio: "ignore", - }); - child.unref(); - } else { - log.error("No terminal emulator found. Tried: " + terminals.map((t) => t.cmd).join(", ")); - throw new Error("No terminal emulator found"); - } - } - - /** - * Check if a command is available in the system PATH or known locations - */ - private async isCommandAvailable(command: string): Promise { - // Special handling for ghostty on macOS - check common installation paths - if (command === "ghostty" && process.platform === "darwin") { - const ghosttyPaths = [ - "/opt/homebrew/bin/ghostty", - "/Applications/Ghostty.app/Contents/MacOS/ghostty", - "/usr/local/bin/ghostty", - ]; - - for (const ghosttyPath of ghosttyPaths) { - try { - const stats = await fs.stat(ghosttyPath); - // Check if it's a file and any executable bit is set (owner, group, or other) - if (stats.isFile() && (stats.mode & 0o111) !== 0) { - return true; - } - } catch { - // Try next path - } - } - // If none of the known paths work, fall through to which check - } - - try { - const result = spawnSync("which", [command], { encoding: "utf8" }); - return result.status === 0; - } catch { - return false; - } - } - - /** - * Find the first available command from a list of commands - */ - private async findAvailableCommand(commands: string[]): Promise { - for (const cmd of commands) { - if (await this.isCommandAvailable(cmd)) { - return cmd; - } - } - return null; - } - - /** - * Find the first available terminal emulator from a list - */ - private async findAvailableTerminal( - terminals: Array<{ cmd: string; args: string[]; cwd?: string }> - ): Promise<{ cmd: string; args: string[]; cwd?: string } | null> { - for (const terminal of terminals) { - if (await this.isCommandAvailable(terminal.cmd)) { - return terminal; - } - } - return null; - } - - onOutput(sessionId: string, callback: (data: string) => void): () => void { - const emitter = this.outputEmitters.get(sessionId); - if (!emitter) { - // Session might not exist yet or closed. - // If it doesn't exist, we can't subscribe. - return () => { - /* no-op */ - }; - } - - // Replay buffer - const buffer = this.outputBuffers.get(sessionId); - if (buffer) { - buffer.forEach((data) => callback(data)); - } - - const handler = (data: string) => callback(data); - emitter.on("data", handler); - - return () => { - emitter.off("data", handler); - }; - } - - onExit(sessionId: string, callback: (code: number) => void): () => void { - const emitter = this.exitEmitters.get(sessionId); - if (!emitter) - return () => { - /* no-op */ - }; - - const handler = (code: number) => callback(code); - emitter.on("exit", handler); - - return () => { - emitter.off("exit", handler); - }; - } - - private emitOutput(sessionId: string, data: string) { - const emitter = this.outputEmitters.get(sessionId); - if (emitter) { - emitter.emit("data", data); - } - - // Update buffer - const buffer = this.outputBuffers.get(sessionId); - if (buffer) { - buffer.push(data); - if (buffer.length > this.MAX_BUFFER_SIZE) { - buffer.shift(); - } - } - } - - private cleanup(sessionId: string) { - this.outputEmitters.delete(sessionId); - this.exitEmitters.delete(sessionId); - this.outputBuffers.delete(sessionId); - } -} diff --git a/src/node/services/tokenizerService.test.ts b/src/node/services/tokenizerService.test.ts deleted file mode 100644 index 95cc89f75..000000000 --- a/src/node/services/tokenizerService.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, expect, test, spyOn } from "bun:test"; -import { TokenizerService } from "./tokenizerService"; -import * as tokenizerUtils from "@/node/utils/main/tokenizer"; -import * as statsUtils from "@/common/utils/tokens/tokenStatsCalculator"; - -describe("TokenizerService", () => { - const service = new TokenizerService(); - - describe("countTokens", () => { - test("delegates to underlying function", async () => { - const spy = spyOn(tokenizerUtils, "countTokens").mockResolvedValue(42); - - const result = await service.countTokens("gpt-4", "hello world"); - expect(result).toBe(42); - expect(spy).toHaveBeenCalledWith("gpt-4", "hello world"); - spy.mockRestore(); - }); - - test("throws on empty model", () => { - expect(service.countTokens("", "text")).rejects.toThrow("requires model name"); - }); - - test("throws on invalid text", () => { - // @ts-expect-error testing runtime validation - expect(service.countTokens("gpt-4", null)).rejects.toThrow("requires text"); - }); - }); - - describe("countTokensBatch", () => { - test("delegates to underlying function", async () => { - const spy = spyOn(tokenizerUtils, "countTokensBatch").mockResolvedValue([10, 20]); - - const result = await service.countTokensBatch("gpt-4", ["a", "b"]); - expect(result).toEqual([10, 20]); - expect(spy).toHaveBeenCalledWith("gpt-4", ["a", "b"]); - spy.mockRestore(); - }); - - test("throws on non-array input", () => { - // @ts-expect-error testing runtime validation - expect(service.countTokensBatch("gpt-4", "not-array")).rejects.toThrow("requires an array"); - }); - }); - - describe("calculateStats", () => { - test("delegates to underlying function", async () => { - const mockResult = { - consumers: [], - totalTokens: 100, - model: "gpt-4", - tokenizerName: "cl100k", - usageHistory: [], - }; - const spy = spyOn(statsUtils, "calculateTokenStats").mockResolvedValue(mockResult); - - const result = await service.calculateStats([], "gpt-4"); - expect(result).toBe(mockResult); - expect(spy).toHaveBeenCalledWith([], "gpt-4"); - spy.mockRestore(); - }); - - test("throws on invalid messages", () => { - // @ts-expect-error testing runtime validation - expect(service.calculateStats(null, "gpt-4")).rejects.toThrow("requires an array"); - }); - }); -}); diff --git a/src/node/services/tokenizerService.ts b/src/node/services/tokenizerService.ts deleted file mode 100644 index 2630e2a51..000000000 --- a/src/node/services/tokenizerService.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { countTokens, countTokensBatch } from "@/node/utils/main/tokenizer"; -import { calculateTokenStats } from "@/common/utils/tokens/tokenStatsCalculator"; -import type { MuxMessage } from "@/common/types/message"; -import type { ChatStats } from "@/common/types/chatStats"; -import assert from "@/common/utils/assert"; - -export class TokenizerService { - /** - * Count tokens for a single string - */ - async countTokens(model: string, text: string): Promise { - assert( - typeof model === "string" && model.length > 0, - "Tokenizer countTokens requires model name" - ); - assert(typeof text === "string", "Tokenizer countTokens requires text"); - return countTokens(model, text); - } - - /** - * Count tokens for a batch of strings - */ - async countTokensBatch(model: string, texts: string[]): Promise { - assert( - typeof model === "string" && model.length > 0, - "Tokenizer countTokensBatch requires model name" - ); - assert(Array.isArray(texts), "Tokenizer countTokensBatch requires an array of strings"); - return countTokensBatch(model, texts); - } - - /** - * Calculate detailed token statistics for a chat history - */ - async calculateStats(messages: MuxMessage[], model: string): Promise { - assert(Array.isArray(messages), "Tokenizer calculateStats requires an array of messages"); - assert( - typeof model === "string" && model.length > 0, - "Tokenizer calculateStats requires model name" - ); - - return calculateTokenStats(messages, model); - } -} diff --git a/src/node/services/tools/bash.test.ts b/src/node/services/tools/bash.test.ts index 820c2bd53..b2c95103f 100644 --- a/src/node/services/tools/bash.test.ts +++ b/src/node/services/tools/bash.test.ts @@ -1103,19 +1103,19 @@ fi const abortController = new AbortController(); // Use unique token to identify our test processes - const token = (100 + Math.random() * 100).toFixed(4); // Unique duration for grep + const token = `test-abort-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; // Spawn a command that creates child processes (simulating cargo build) const args: BashToolArgs = { script: ` # Simulate cargo spawning rustc processes for i in {1..5}; do - (echo "child-\${i}"; exec sleep ${token}) & + (echo "child-\${i}"; exec -a "sleep-${token}" sleep 100) & echo "SPAWNED:$!" done echo "ALL_SPAWNED" # Wait so we can abort while children are running - exec sleep ${token} + exec -a "sleep-${token}" sleep 100 `, timeout_secs: 10, }; @@ -1151,7 +1151,7 @@ fi using checkEnv = createTestBashTool(); const checkResult = (await checkEnv.tool.execute!( { - script: `ps aux | grep "sleep ${token}" | grep -v grep | wc -l`, + script: `ps aux | grep "${token}" | grep -v grep | wc -l`, timeout_secs: 1, }, mockToolCallOptions diff --git a/src/node/services/updateService.ts b/src/node/services/updateService.ts deleted file mode 100644 index 28afacbe8..000000000 --- a/src/node/services/updateService.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { log } from "@/node/services/log"; -import type { UpdateStatus } from "@/common/orpc/types"; -import { parseDebugUpdater } from "@/common/utils/env"; - -// Interface matching the implementation class in desktop/updater.ts -// We redefine it here to avoid importing the class directly which brings in electron-updater -interface DesktopUpdaterService { - checkForUpdates(): void; - downloadUpdate(): Promise; - installUpdate(): void; - subscribe(callback: (status: UpdateStatus) => void): () => void; - getStatus(): UpdateStatus; -} - -export class UpdateService { - private impl: DesktopUpdaterService | null = null; - private currentStatus: UpdateStatus = { type: "idle" }; - private subscribers = new Set<(status: UpdateStatus) => void>(); - - constructor() { - this.initialize().catch((err) => { - log.error("Failed to initialize UpdateService:", err); - }); - } - - private async initialize() { - // Check if running in Electron Main process - if (process.versions.electron) { - try { - // Dynamic import to avoid loading electron-updater in CLI - // eslint-disable-next-line no-restricted-syntax - const { UpdaterService: DesktopUpdater } = await import("@/desktop/updater"); - this.impl = new DesktopUpdater(); - - // Forward updates - this.impl.subscribe((status: UpdateStatus) => { - this.currentStatus = status; - this.notifySubscribers(); - }); - - // Sync initial status - this.currentStatus = this.impl.getStatus(); - } catch (err) { - log.debug( - "UpdateService: Failed to load desktop updater (likely CLI mode or missing dep):", - err - ); - } - } - } - - async check(): Promise { - if (this.impl) { - if (process.versions.electron) { - try { - // eslint-disable-next-line no-restricted-syntax - const { app } = await import("electron"); - - const debugConfig = parseDebugUpdater(process.env.DEBUG_UPDATER); - if (!app.isPackaged && !debugConfig.enabled) { - log.debug("UpdateService: Updates disabled in dev mode"); - return; - } - } catch (err) { - // Ignore errors (e.g. if modules not found), proceed to check - log.debug("UpdateService: Error checking env:", err); - } - } - this.impl.checkForUpdates(); - } else { - log.debug("UpdateService: check() called but no implementation (CLI mode)"); - } - } - - async download(): Promise { - if (this.impl) { - await this.impl.downloadUpdate(); - } - } - - install(): void { - if (this.impl) { - this.impl.installUpdate(); - } - } - - onStatus(callback: (status: UpdateStatus) => void): () => void { - // Send current status immediately - callback(this.currentStatus); - - this.subscribers.add(callback); - return () => { - this.subscribers.delete(callback); - }; - } - - private notifySubscribers() { - for (const sub of this.subscribers) { - try { - sub(this.currentStatus); - } catch (err) { - log.error("Error in UpdateService subscriber:", err); - } - } - } -} diff --git a/src/node/services/windowService.ts b/src/node/services/windowService.ts deleted file mode 100644 index 8ac279751..000000000 --- a/src/node/services/windowService.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { BrowserWindow } from "electron"; -import { log } from "@/node/services/log"; - -export class WindowService { - private mainWindow: BrowserWindow | null = null; - - setMainWindow(window: BrowserWindow) { - this.mainWindow = window; - } - - send(channel: string, ...args: unknown[]): void { - const isDestroyed = - this.mainWindow && - typeof (this.mainWindow as { isDestroyed?: () => boolean }).isDestroyed === "function" - ? (this.mainWindow as { isDestroyed: () => boolean }).isDestroyed() - : false; - - if (this.mainWindow && !isDestroyed) { - this.mainWindow.webContents.send(channel, ...args); - return; - } - - log.debug( - "WindowService: send called but mainWindow is not set or destroyed", - channel, - ...args - ); - } - - setTitle(title: string): void { - if (this.mainWindow && !this.mainWindow.isDestroyed()) { - this.mainWindow.setTitle(title); - } else { - log.debug("WindowService: setTitle called but mainWindow is not set or destroyed"); - } - } -} diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts deleted file mode 100644 index 24c8c7bf3..000000000 --- a/src/node/services/workspaceService.ts +++ /dev/null @@ -1,1094 +0,0 @@ -import { EventEmitter } from "events"; -import * as path from "path"; -import * as fsPromises from "fs/promises"; -import assert from "@/common/utils/assert"; -import type { Config } from "@/node/config"; -import type { Result } from "@/common/types/result"; -import { Ok, Err } from "@/common/types/result"; -import { log } from "@/node/services/log"; -import { AgentSession } from "@/node/services/agentSession"; -import type { HistoryService } from "@/node/services/historyService"; -import type { PartialService } from "@/node/services/partialService"; -import type { AIService } from "@/node/services/aiService"; -import type { InitStateManager } from "@/node/services/initStateManager"; -import type { ExtensionMetadataService } from "@/node/services/ExtensionMetadataService"; -import { listLocalBranches, detectDefaultTrunkBranch } from "@/node/git"; -import { createRuntime } from "@/node/runtime/runtimeFactory"; -import { generateWorkspaceName } from "./workspaceTitleGenerator"; -import { validateWorkspaceName } from "@/common/utils/validation/workspaceValidation"; - -import type { - SendMessageOptions, - DeleteMessage, - ImagePart, - WorkspaceChatMessage, -} from "@/common/orpc/types"; -import type { SendMessageError } from "@/common/types/errors"; -import type { - FrontendWorkspaceMetadata, - WorkspaceActivitySnapshot, -} from "@/common/types/workspace"; -import type { MuxMessage } from "@/common/types/message"; -import type { RuntimeConfig } from "@/common/types/runtime"; -import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace"; -import type { StreamEndEvent, StreamAbortEvent } from "@/common/types/stream"; - -import { DisposableTempDir } from "@/node/services/tempDir"; -import { createBashTool } from "@/node/services/tools/bash"; -import type { BashToolResult } from "@/common/types/tools"; -import { secretsToRecord } from "@/common/types/secrets"; - -export interface WorkspaceServiceEvents { - chat: (event: { workspaceId: string; message: WorkspaceChatMessage }) => void; - metadata: (event: { workspaceId: string; metadata: FrontendWorkspaceMetadata | null }) => void; - activity: (event: { workspaceId: string; activity: WorkspaceActivitySnapshot | null }) => void; -} - -// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging -export declare interface WorkspaceService { - on(event: U, listener: WorkspaceServiceEvents[U]): this; - emit( - event: U, - ...args: Parameters - ): boolean; -} - -// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging -export class WorkspaceService extends EventEmitter { - private readonly sessions = new Map(); - private readonly sessionSubscriptions = new Map< - string, - { chat: () => void; metadata: () => void } - >(); - - constructor( - private readonly config: Config, - private readonly historyService: HistoryService, - private readonly partialService: PartialService, - private readonly aiService: AIService, - private readonly initStateManager: InitStateManager, - private readonly extensionMetadata: ExtensionMetadataService - ) { - super(); - this.setupMetadataListeners(); - } - - /** - * Setup listeners to update metadata store based on AIService events. - * This tracks workspace recency and streaming status for VS Code extension integration. - */ - private setupMetadataListeners(): void { - const isObj = (v: unknown): v is Record => typeof v === "object" && v !== null; - const isWorkspaceEvent = (v: unknown): v is { workspaceId: string } => - isObj(v) && "workspaceId" in v && typeof v.workspaceId === "string"; - const isStreamStartEvent = (v: unknown): v is { workspaceId: string; model: string } => - isWorkspaceEvent(v) && "model" in v && typeof v.model === "string"; - const isStreamEndEvent = (v: unknown): v is StreamEndEvent => - isWorkspaceEvent(v) && - (!("metadata" in (v as Record)) || isObj((v as StreamEndEvent).metadata)); - const isStreamAbortEvent = (v: unknown): v is StreamAbortEvent => isWorkspaceEvent(v); - const extractTimestamp = (event: StreamEndEvent | { metadata?: { timestamp?: number } }) => { - const raw = event.metadata?.timestamp; - return typeof raw === "number" && Number.isFinite(raw) ? raw : Date.now(); - }; - - // Update streaming status and recency on stream start - this.aiService.on("stream-start", (data: unknown) => { - if (isStreamStartEvent(data)) { - void this.updateStreamingStatus(data.workspaceId, true, data.model); - } - }); - - this.aiService.on("stream-end", (data: unknown) => { - if (isStreamEndEvent(data)) { - void this.handleStreamCompletion(data.workspaceId, extractTimestamp(data)); - } - }); - - this.aiService.on("stream-abort", (data: unknown) => { - if (isStreamAbortEvent(data)) { - void this.updateStreamingStatus(data.workspaceId, false); - } - }); - } - - private emitWorkspaceActivity( - workspaceId: string, - snapshot: WorkspaceActivitySnapshot | null - ): void { - this.emit("activity", { workspaceId, activity: snapshot }); - } - - private async updateRecencyTimestamp(workspaceId: string, timestamp?: number): Promise { - try { - const snapshot = await this.extensionMetadata.updateRecency( - workspaceId, - timestamp ?? Date.now() - ); - this.emitWorkspaceActivity(workspaceId, snapshot); - } catch (error) { - log.error("Failed to update workspace recency", { workspaceId, error }); - } - } - - private async updateStreamingStatus( - workspaceId: string, - streaming: boolean, - model?: string - ): Promise { - try { - const snapshot = await this.extensionMetadata.setStreaming(workspaceId, streaming, model); - this.emitWorkspaceActivity(workspaceId, snapshot); - } catch (error) { - log.error("Failed to update workspace streaming status", { workspaceId, error }); - } - } - - private async handleStreamCompletion(workspaceId: string, timestamp: number): Promise { - await this.updateRecencyTimestamp(workspaceId, timestamp); - await this.updateStreamingStatus(workspaceId, false); - } - - private createInitLogger(workspaceId: string) { - return { - logStep: (message: string) => { - this.initStateManager.appendOutput(workspaceId, message, false); - }, - logStdout: (line: string) => { - this.initStateManager.appendOutput(workspaceId, line, false); - }, - logStderr: (line: string) => { - this.initStateManager.appendOutput(workspaceId, line, true); - }, - logComplete: (exitCode: number) => { - void this.initStateManager.endInit(workspaceId, exitCode); - }, - }; - } - - public getOrCreateSession(workspaceId: string): AgentSession { - assert(typeof workspaceId === "string", "workspaceId must be a string"); - const trimmed = workspaceId.trim(); - assert(trimmed.length > 0, "workspaceId must not be empty"); - - let session = this.sessions.get(trimmed); - if (session) { - return session; - } - - session = new AgentSession({ - workspaceId: trimmed, - config: this.config, - historyService: this.historyService, - partialService: this.partialService, - aiService: this.aiService, - initStateManager: this.initStateManager, - }); - - const chatUnsubscribe = session.onChatEvent((event) => { - this.emit("chat", { workspaceId: event.workspaceId, message: event.message }); - }); - - const metadataUnsubscribe = session.onMetadataEvent((event) => { - this.emit("metadata", { - workspaceId: event.workspaceId, - metadata: event.metadata as FrontendWorkspaceMetadata, - }); - }); - - this.sessions.set(trimmed, session); - this.sessionSubscriptions.set(trimmed, { - chat: chatUnsubscribe, - metadata: metadataUnsubscribe, - }); - - return session; - } - - public disposeSession(workspaceId: string): void { - const session = this.sessions.get(workspaceId); - if (!session) { - return; - } - - const subscriptions = this.sessionSubscriptions.get(workspaceId); - if (subscriptions) { - subscriptions.chat(); - subscriptions.metadata(); - this.sessionSubscriptions.delete(workspaceId); - } - - session.dispose(); - this.sessions.delete(workspaceId); - } - - async create( - projectPath: string, - branchName: string, - trunkBranch: string, - runtimeConfig?: RuntimeConfig - ): Promise> { - // Validate workspace name - const validation = validateWorkspaceName(branchName); - if (!validation.valid) { - return Err(validation.error ?? "Invalid workspace name"); - } - - if (typeof trunkBranch !== "string" || trunkBranch.trim().length === 0) { - return Err("Trunk branch is required"); - } - - const normalizedTrunkBranch = trunkBranch.trim(); - - // Generate stable workspace ID - const workspaceId = this.config.generateStableId(); - - // Create runtime for workspace creation - const finalRuntimeConfig: RuntimeConfig = runtimeConfig ?? { - type: "local", - srcBaseDir: this.config.srcDir, - }; - - let runtime; - let resolvedSrcBaseDir: string; - try { - runtime = createRuntime(finalRuntimeConfig); - resolvedSrcBaseDir = await runtime.resolvePath(finalRuntimeConfig.srcBaseDir); - - if (resolvedSrcBaseDir !== finalRuntimeConfig.srcBaseDir) { - const resolvedRuntimeConfig: RuntimeConfig = { - ...finalRuntimeConfig, - srcBaseDir: resolvedSrcBaseDir, - }; - runtime = createRuntime(resolvedRuntimeConfig); - finalRuntimeConfig.srcBaseDir = resolvedSrcBaseDir; - } - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - return Err(errorMsg); - } - - const session = this.getOrCreateSession(workspaceId); - this.initStateManager.startInit(workspaceId, projectPath); - const initLogger = this.createInitLogger(workspaceId); - - try { - const createResult = await runtime.createWorkspace({ - projectPath, - branchName, - trunkBranch: normalizedTrunkBranch, - directoryName: branchName, - initLogger, - }); - - if (!createResult.success || !createResult.workspacePath) { - return Err(createResult.error ?? "Failed to create workspace"); - } - - const projectName = - projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "unknown"; - - const metadata = { - id: workspaceId, - name: branchName, - projectName, - projectPath, - createdAt: new Date().toISOString(), - }; - - await this.config.editConfig((config) => { - let projectConfig = config.projects.get(projectPath); - if (!projectConfig) { - projectConfig = { workspaces: [] }; - config.projects.set(projectPath, projectConfig); - } - projectConfig.workspaces.push({ - path: createResult.workspacePath!, - id: workspaceId, - name: branchName, - createdAt: metadata.createdAt, - runtimeConfig: finalRuntimeConfig, - }); - return config; - }); - - const allMetadata = await this.config.getAllWorkspaceMetadata(); - const completeMetadata = allMetadata.find((m) => m.id === workspaceId); - if (!completeMetadata) { - return Err("Failed to retrieve workspace metadata"); - } - - session.emitMetadata(completeMetadata); - - void runtime - .initWorkspace({ - projectPath, - branchName, - trunkBranch: normalizedTrunkBranch, - workspacePath: createResult.workspacePath, - initLogger, - }) - .catch((error: unknown) => { - const errorMsg = error instanceof Error ? error.message : String(error); - log.error(`initWorkspace failed for ${workspaceId}:`, error); - initLogger.logStderr(`Initialization failed: ${errorMsg}`); - initLogger.logComplete(-1); - }); - - return Ok({ metadata: completeMetadata }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return Err(`Failed to create workspace: ${message}`); - } - } - - async createForFirstMessage( - message: string, - projectPath: string, - options: SendMessageOptions & { - imageParts?: Array<{ url: string; mediaType: string }>; - runtimeConfig?: RuntimeConfig; - trunkBranch?: string; - } = { model: "claude-3-5-sonnet-20241022" } - ): Promise< - | { success: true; workspaceId: string; metadata: FrontendWorkspaceMetadata } - | { success: false; error: string } - > { - try { - const branchNameResult = await generateWorkspaceName(message, options.model, this.aiService); - if (!branchNameResult.success) { - const err = branchNameResult.error; - const errorMessage = - "message" in err - ? err.message - : err.type === "api_key_not_found" - ? `API key not found for ${err.provider}` - : err.type === "provider_not_supported" - ? `Provider not supported: ${err.provider}` - : "raw" in err - ? err.raw - : "Unknown error"; - return { success: false, error: errorMessage }; - } - const branchName = branchNameResult.data; - log.debug("Generated workspace name", { branchName }); - - const branches = await listLocalBranches(projectPath); - const recommendedTrunk = - options.trunkBranch ?? (await detectDefaultTrunkBranch(projectPath, branches)) ?? "main"; - - const finalRuntimeConfig: RuntimeConfig = options.runtimeConfig ?? { - type: "local", - srcBaseDir: this.config.srcDir, - }; - - const workspaceId = this.config.generateStableId(); - - let runtime; - let resolvedSrcBaseDir: string; - try { - runtime = createRuntime(finalRuntimeConfig); - resolvedSrcBaseDir = await runtime.resolvePath(finalRuntimeConfig.srcBaseDir); - - if (resolvedSrcBaseDir !== finalRuntimeConfig.srcBaseDir) { - const resolvedRuntimeConfig: RuntimeConfig = { - ...finalRuntimeConfig, - srcBaseDir: resolvedSrcBaseDir, - }; - runtime = createRuntime(resolvedRuntimeConfig); - finalRuntimeConfig.srcBaseDir = resolvedSrcBaseDir; - } - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - return { success: false, error: errorMsg }; - } - - const session = this.getOrCreateSession(workspaceId); - this.initStateManager.startInit(workspaceId, projectPath); - const initLogger = this.createInitLogger(workspaceId); - - const createResult = await runtime.createWorkspace({ - projectPath, - branchName, - trunkBranch: recommendedTrunk, - directoryName: branchName, - initLogger, - }); - - if (!createResult.success || !createResult.workspacePath) { - return { success: false, error: createResult.error ?? "Failed to create workspace" }; - } - - const projectName = - projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "unknown"; - - // Compute namedWorkspacePath - const namedWorkspacePath = runtime.getWorkspacePath(projectPath, branchName); - - const metadata: FrontendWorkspaceMetadata = { - id: workspaceId, - name: branchName, - projectName, - projectPath, - createdAt: new Date().toISOString(), - namedWorkspacePath, - runtimeConfig: finalRuntimeConfig, - }; - - await this.config.editConfig((config) => { - let projectConfig = config.projects.get(projectPath); - if (!projectConfig) { - projectConfig = { workspaces: [] }; - config.projects.set(projectPath, projectConfig); - } - projectConfig.workspaces.push({ - path: createResult.workspacePath!, - id: workspaceId, - name: branchName, - createdAt: metadata.createdAt, - runtimeConfig: finalRuntimeConfig, - }); - return config; - }); - - const allMetadata = await this.config.getAllWorkspaceMetadata(); - const completeMetadata = allMetadata.find((m) => m.id === workspaceId); - - if (!completeMetadata) { - return { success: false, error: "Failed to retrieve workspace metadata" }; - } - - session.emitMetadata(completeMetadata); - - void runtime - .initWorkspace({ - projectPath, - branchName, - trunkBranch: recommendedTrunk, - workspacePath: createResult.workspacePath, - initLogger, - }) - .catch((error: unknown) => { - const errorMsg = error instanceof Error ? error.message : String(error); - log.error(`initWorkspace failed for ${workspaceId}:`, error); - initLogger.logStderr(`Initialization failed: ${errorMsg}`); - initLogger.logComplete(-1); - }); - - void session.sendMessage(message, options); - - return { - success: true, - workspaceId, - metadata: completeMetadata, - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log.error("Unexpected error in createWorkspaceForFirstMessage:", error); - return { success: false, error: `Failed to create workspace: ${errorMessage}` }; - } - } - - async remove(workspaceId: string, force = false): Promise> { - // Try to remove from runtime (filesystem) - try { - const metadataResult = await this.aiService.getWorkspaceMetadata(workspaceId); - if (metadataResult.success) { - const metadata = metadataResult.data; - const projectPath = metadata.projectPath; - - const runtime = createRuntime( - metadata.runtimeConfig ?? { type: "local", srcBaseDir: this.config.srcDir } - ); - - // Delete workspace from runtime - const deleteResult = await runtime.deleteWorkspace( - projectPath, - metadata.name, // use branch name - force - ); - - if (!deleteResult.success) { - // If force is true, we continue to remove from config even if fs removal failed - if (!force) { - return Err(deleteResult.error ?? "Failed to delete workspace from disk"); - } - log.error( - `Failed to delete workspace from disk, but force=true. Removing from config. Error: ${deleteResult.error}` - ); - } - } else { - log.error(`Could not find metadata for workspace ${workspaceId}, creating phantom cleanup`); - } - - // Remove session data - try { - const sessionDir = this.config.getSessionDir(workspaceId); - await fsPromises.rm(sessionDir, { recursive: true, force: true }); - } catch (error) { - log.error(`Failed to remove session directory for ${workspaceId}:`, error); - } - - // Dispose session - this.disposeSession(workspaceId); - - // Remove from config - await this.config.removeWorkspace(workspaceId); - - this.emit("metadata", { workspaceId, metadata: null }); - - return Ok(undefined); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return Err(`Failed to remove workspace: ${message}`); - } - } - - async list(): Promise { - try { - return await this.config.getAllWorkspaceMetadata(); - } catch (error) { - console.error("Failed to list workspaces:", error); - return []; - } - } - - async getInfo(workspaceId: string): Promise { - const allMetadata = await this.config.getAllWorkspaceMetadata(); - const metadata = allMetadata.find((m) => m.id === workspaceId); - - if (metadata && !metadata.name) { - log.info(`Workspace ${workspaceId} missing title or branch name, regenerating...`); - try { - const historyResult = await this.historyService.getHistory(workspaceId); - if (!historyResult.success) { - log.error(`Failed to load history for workspace ${workspaceId}:`, historyResult.error); - return metadata; - } - - const firstUserMessage = historyResult.data.find((m: MuxMessage) => m.role === "user"); - - if (firstUserMessage) { - const textParts = firstUserMessage.parts.filter((p) => p.type === "text"); - const messageText = textParts.map((p) => p.text).join(" "); - - if (messageText.trim()) { - const branchNameResult = await generateWorkspaceName( - messageText, - "anthropic:claude-sonnet-4-5", - this.aiService - ); - - if (branchNameResult.success) { - const branchName = branchNameResult.data; - await this.config.updateWorkspaceMetadata(workspaceId, { - name: branchName, - }); - - metadata.name = branchName; - log.info(`Regenerated workspace name: ${branchName}`); - } - } - } - } catch (error) { - log.error(`Failed to regenerate workspace names for ${workspaceId}:`, error); - } - } - - return metadata! ?? null; - } - - async rename(workspaceId: string, newName: string): Promise> { - try { - if (this.aiService.isStreaming(workspaceId)) { - return Err( - "Cannot rename workspace while AI stream is active. Please wait for the stream to complete." - ); - } - - const validation = validateWorkspaceName(newName); - if (!validation.valid) { - return Err(validation.error ?? "Invalid workspace name"); - } - - const metadataResult = await this.aiService.getWorkspaceMetadata(workspaceId); - if (!metadataResult.success) { - return Err(`Failed to get workspace metadata: ${metadataResult.error}`); - } - const oldMetadata = metadataResult.data; - const oldName = oldMetadata.name; - - if (newName === oldName) { - return Ok({ newWorkspaceId: workspaceId }); - } - - const allWorkspaces = await this.config.getAllWorkspaceMetadata(); - const collision = allWorkspaces.find( - (ws) => (ws.name === newName || ws.id === newName) && ws.id !== workspaceId - ); - if (collision) { - return Err(`Workspace with name "${newName}" already exists`); - } - - const workspace = this.config.findWorkspace(workspaceId); - if (!workspace) { - return Err("Failed to find workspace in config"); - } - const { projectPath } = workspace; - - const runtime = createRuntime( - oldMetadata.runtimeConfig ?? { type: "local", srcBaseDir: this.config.srcDir } - ); - - const renameResult = await runtime.renameWorkspace(projectPath, oldName, newName); - - if (!renameResult.success) { - return Err(renameResult.error); - } - - const { oldPath, newPath } = renameResult; - - await this.config.editConfig((config) => { - const projectConfig = config.projects.get(projectPath); - if (projectConfig) { - const workspaceEntry = projectConfig.workspaces.find((w) => w.path === oldPath); - if (workspaceEntry) { - workspaceEntry.name = newName; - workspaceEntry.path = newPath; - } - } - return config; - }); - - const allMetadataUpdated = await this.config.getAllWorkspaceMetadata(); - const updatedMetadata = allMetadataUpdated.find((m) => m.id === workspaceId); - if (!updatedMetadata) { - return Err("Failed to retrieve updated workspace metadata"); - } - - const session = this.sessions.get(workspaceId); - if (session) { - session.emitMetadata(updatedMetadata); - } else { - this.emit("metadata", { workspaceId, metadata: updatedMetadata }); - } - - return Ok({ newWorkspaceId: workspaceId }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return Err(`Failed to rename workspace: ${message}`); - } - } - - async fork( - sourceWorkspaceId: string, - newName: string - ): Promise> { - try { - const validation = validateWorkspaceName(newName); - if (!validation.valid) { - return Err(validation.error ?? "Invalid workspace name"); - } - - if (this.aiService.isStreaming(sourceWorkspaceId)) { - await this.partialService.commitToHistory(sourceWorkspaceId); - } - - const sourceMetadataResult = await this.aiService.getWorkspaceMetadata(sourceWorkspaceId); - if (!sourceMetadataResult.success) { - return Err(`Failed to get source workspace metadata: ${sourceMetadataResult.error}`); - } - const sourceMetadata = sourceMetadataResult.data; - const foundProjectPath = sourceMetadata.projectPath; - const projectName = sourceMetadata.projectName; - - const sourceRuntimeConfig = sourceMetadata.runtimeConfig ?? { - type: "local", - srcBaseDir: this.config.srcDir, - }; - const runtime = createRuntime(sourceRuntimeConfig); - - const newWorkspaceId = this.config.generateStableId(); - - const session = this.getOrCreateSession(newWorkspaceId); - this.initStateManager.startInit(newWorkspaceId, foundProjectPath); - const initLogger = this.createInitLogger(newWorkspaceId); - - const forkResult = await runtime.forkWorkspace({ - projectPath: foundProjectPath, - sourceWorkspaceName: sourceMetadata.name, - newWorkspaceName: newName, - initLogger, - }); - - if (!forkResult.success) { - return Err(forkResult.error ?? "Failed to fork workspace"); - } - - const sourceSessionDir = this.config.getSessionDir(sourceWorkspaceId); - const newSessionDir = this.config.getSessionDir(newWorkspaceId); - - try { - await fsPromises.mkdir(newSessionDir, { recursive: true }); - - const sourceChatPath = path.join(sourceSessionDir, "chat.jsonl"); - const newChatPath = path.join(newSessionDir, "chat.jsonl"); - try { - await fsPromises.copyFile(sourceChatPath, newChatPath); - } catch (error) { - if (!(error && typeof error === "object" && "code" in error && error.code === "ENOENT")) { - throw error; - } - } - - const sourcePartialPath = path.join(sourceSessionDir, "partial.json"); - const newPartialPath = path.join(newSessionDir, "partial.json"); - try { - await fsPromises.copyFile(sourcePartialPath, newPartialPath); - } catch (error) { - if (!(error && typeof error === "object" && "code" in error && error.code === "ENOENT")) { - throw error; - } - } - } catch (copyError) { - await runtime.deleteWorkspace(foundProjectPath, newName, true); - try { - await fsPromises.rm(newSessionDir, { recursive: true, force: true }); - } catch (cleanupError) { - log.error(`Failed to clean up session dir ${newSessionDir}:`, cleanupError); - } - const message = copyError instanceof Error ? copyError.message : String(copyError); - return Err(`Failed to copy chat history: ${message}`); - } - - // Compute namedWorkspacePath for frontend metadata - const namedWorkspacePath = runtime.getWorkspacePath(foundProjectPath, newName); - - const metadata: FrontendWorkspaceMetadata = { - id: newWorkspaceId, - name: newName, - projectName, - projectPath: foundProjectPath, - createdAt: new Date().toISOString(), - runtimeConfig: DEFAULT_RUNTIME_CONFIG, - namedWorkspacePath, - }; - - await this.config.addWorkspace(foundProjectPath, metadata); - session.emitMetadata(metadata); - - return Ok({ metadata, projectPath: foundProjectPath }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return Err(`Failed to fork workspace: ${message}`); - } - } - - async sendMessage( - workspaceId: string | null, - message: string, - options: - | (SendMessageOptions & { - imageParts?: ImagePart[]; - runtimeConfig?: RuntimeConfig; - projectPath?: string; - trunkBranch?: string; - }) - | undefined = { model: "claude-sonnet-4-5-latest" } - ): Promise< - | Result - | { success: true; workspaceId: string; metadata: FrontendWorkspaceMetadata } - | { success: false; error: string } - > { - if (workspaceId === null) { - if (!options?.projectPath) { - return Err("projectPath is required when workspaceId is null"); - } - - log.debug("sendMessage handler: Creating workspace for first message", { - projectPath: options.projectPath, - messagePreview: message.substring(0, 50), - }); - - return await this.createForFirstMessage(message, options.projectPath, options); - } - - log.debug("sendMessage handler: Received", { - workspaceId, - messagePreview: message.substring(0, 50), - mode: options?.mode, - options, - }); - - try { - const session = this.getOrCreateSession(workspaceId); - void this.updateRecencyTimestamp(workspaceId); - - if (this.aiService.isStreaming(workspaceId) && !options?.editMessageId) { - session.queueMessage(message, options); - return Ok(undefined); - } - - const result = await session.sendMessage(message, options); - if (!result.success) { - log.error("sendMessage handler: session returned error", { - workspaceId, - error: result.error, - }); - } - return result; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : JSON.stringify(error, null, 2); - log.error("Unexpected error in sendMessage handler:", error); - const sendError: SendMessageError = { - type: "unknown", - raw: `Failed to send message: ${errorMessage}`, - }; - return Err(sendError); - } - } - - async resumeStream( - workspaceId: string, - options: SendMessageOptions | undefined = { model: "claude-3-5-sonnet-latest" } - ): Promise> { - try { - const session = this.getOrCreateSession(workspaceId); - const result = await session.resumeStream(options); - if (!result.success) { - log.error("resumeStream handler: session returned error", { - workspaceId, - error: result.error, - }); - } - return result; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log.error("Unexpected error in resumeStream handler:", error); - const sendError: SendMessageError = { - type: "unknown", - raw: `Failed to resume stream: ${errorMessage}`, - }; - return Err(sendError); - } - } - - async interruptStream( - workspaceId: string, - options?: { soft?: boolean; abandonPartial?: boolean } - ): Promise> { - try { - const session = this.getOrCreateSession(workspaceId); - const stopResult = await session.interruptStream(options); - if (!stopResult.success) { - log.error("Failed to stop stream:", stopResult.error); - return Err(stopResult.error); - } - - // For hard interrupts, delete partial immediately. For soft interrupts, - // defer to stream-abort handler (stream is still running and may recreate partial). - if (options?.abandonPartial && !options?.soft) { - log.debug("Abandoning partial for workspace:", workspaceId); - await this.partialService.deletePartial(workspaceId); - } - - return Ok(undefined); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log.error("Unexpected error in interruptStream handler:", error); - return Err(`Failed to interrupt stream: ${errorMessage}`); - } - } - - clearQueue(workspaceId: string): Result { - try { - const session = this.getOrCreateSession(workspaceId); - session.clearQueue(); - return Ok(undefined); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log.error("Unexpected error in clearQueue handler:", error); - return Err(`Failed to clear queue: ${errorMessage}`); - } - } - - async truncateHistory(workspaceId: string, percentage?: number): Promise> { - if (this.aiService.isStreaming(workspaceId)) { - return Err( - "Cannot truncate history while stream is active. Press Esc to stop the stream first." - ); - } - - const truncateResult = await this.historyService.truncateHistory( - workspaceId, - percentage ?? 1.0 - ); - if (!truncateResult.success) { - return Err(truncateResult.error); - } - - const deletedSequences = truncateResult.data; - if (deletedSequences.length > 0) { - const deleteMessage: DeleteMessage = { - type: "delete", - historySequences: deletedSequences, - }; - // Emit through the session so ORPC subscriptions receive the event - const session = this.sessions.get(workspaceId); - if (session) { - session.emitChatEvent(deleteMessage); - } else { - // Fallback to direct emit (legacy path) - this.emit("chat", { workspaceId, message: deleteMessage }); - } - } - - return Ok(undefined); - } - - async replaceHistory(workspaceId: string, summaryMessage: MuxMessage): Promise> { - const isCompaction = summaryMessage.metadata?.compacted === true; - if (!isCompaction && this.aiService.isStreaming(workspaceId)) { - return Err( - "Cannot replace history while stream is active. Press Esc to stop the stream first." - ); - } - - try { - const clearResult = await this.historyService.clearHistory(workspaceId); - if (!clearResult.success) { - return Err(`Failed to clear history: ${clearResult.error}`); - } - const deletedSequences = clearResult.data; - - const appendResult = await this.historyService.appendToHistory(workspaceId, summaryMessage); - if (!appendResult.success) { - return Err(`Failed to append summary message: ${appendResult.error}`); - } - - // Emit through the session so ORPC subscriptions receive the events - const session = this.sessions.get(workspaceId); - if (deletedSequences.length > 0) { - const deleteMessage: DeleteMessage = { - type: "delete", - historySequences: deletedSequences, - }; - if (session) { - session.emitChatEvent(deleteMessage); - } else { - this.emit("chat", { workspaceId, message: deleteMessage }); - } - } - - if (session) { - session.emitChatEvent(summaryMessage); - } else { - this.emit("chat", { workspaceId, message: summaryMessage }); - } - - return Ok(undefined); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return Err(`Failed to replace history: ${message}`); - } - } - - async getActivityList(): Promise> { - try { - const snapshots = await this.extensionMetadata.getAllSnapshots(); - return Object.fromEntries(snapshots.entries()); - } catch (error) { - log.error("Failed to list activity:", error); - return {}; - } - } - async getChatHistory(workspaceId: string): Promise { - try { - const history = await this.historyService.getHistory(workspaceId); - return history.success ? history.data : []; - } catch (error) { - log.error("Failed to get chat history:", error); - return []; - } - } - - async getFullReplay(workspaceId: string): Promise { - try { - const session = this.getOrCreateSession(workspaceId); - const events: WorkspaceChatMessage[] = []; - await session.replayHistory(({ message }) => { - events.push(message); - }); - return events; - } catch (error) { - log.error("Failed to get full replay:", error); - return []; - } - } - - async executeBash( - workspaceId: string, - script: string, - options?: { - timeout_secs?: number; - niceness?: number; - } - ): Promise> { - try { - // Get workspace metadata - const metadataResult = await this.aiService.getWorkspaceMetadata(workspaceId); - if (!metadataResult.success) { - return Err(`Failed to get workspace metadata: ${metadataResult.error}`); - } - - const metadata = metadataResult.data; - - // Get actual workspace path from config - const workspace = this.config.findWorkspace(workspaceId); - if (!workspace) { - return Err(`Workspace ${workspaceId} not found in config`); - } - - // Load project secrets - const projectSecrets = this.config.getProjectSecrets(metadata.projectPath); - - // Create scoped temp directory for this IPC call - using tempDir = new DisposableTempDir("mux-ipc-bash"); - - // Create runtime and compute workspace path - const runtimeConfig = metadata.runtimeConfig ?? { - type: "local" as const, - srcBaseDir: this.config.srcDir, - }; - const runtime = createRuntime(runtimeConfig); - const workspacePath = runtime.getWorkspacePath(metadata.projectPath, metadata.name); - - // Create bash tool - const bashTool = createBashTool({ - cwd: workspacePath, - runtime, - secrets: secretsToRecord(projectSecrets), - niceness: options?.niceness, - runtimeTempDir: tempDir.path, - overflow_policy: "truncate", - }); - - // Execute the script - const result = (await bashTool.execute!( - { - script, - timeout_secs: options?.timeout_secs ?? 120, - }, - { - toolCallId: `bash-${Date.now()}`, - messages: [], - } - )) as BashToolResult; - - return Ok(result); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return Err(`Failed to execute bash command: ${message}`); - } - } -} diff --git a/src/server/auth.ts b/src/server/auth.ts new file mode 100644 index 000000000..58e7df077 --- /dev/null +++ b/src/server/auth.ts @@ -0,0 +1,90 @@ +/** + * Simple bearer token auth helpers for cmux-server + * + * Optional by design: if no token is configured, middleware is a no-op. + * Token can be supplied via CLI flag (--auth-token) or env (MUX_SERVER_AUTH_TOKEN). + * + * WebSocket notes: + * - React Native / Expo cannot always set custom Authorization headers. + * - We therefore accept the token via any of the following (first match wins): + * 1) Query param: /ws?token=... (recommended for Expo) + * 2) Authorization: Bearer + * 3) Sec-WebSocket-Protocol: a single value equal to the token + */ + +import type { Request, Response, NextFunction } from "express"; +import type { IncomingMessage } from "http"; +import { URL } from "url"; + +export interface AuthConfig { + token?: string | null; +} + +export function createAuthMiddleware(config: AuthConfig) { + const token = (config.token ?? "").trim(); + const enabled = token.length > 0; + + return function authMiddleware(req: Request, res: Response, next: NextFunction) { + if (!enabled) return next(); + + // Skip health check and static assets by convention + if (req.path === "/health" || req.path === "/version") return next(); + + const header = req.headers.authorization; // e.g. "Bearer " + const candidate = + typeof header === "string" && header.toLowerCase().startsWith("bearer ") + ? header.slice("bearer ".length) + : undefined; + + if (candidate && safeEq(candidate.trim(), token)) return next(); + + res.status(401).json({ success: false, error: "Unauthorized" }); + }; +} + +export function extractWsToken(req: IncomingMessage): string | null { + // 1) Query param token + try { + const url = new URL(req.url ?? "", "http://localhost"); + const qp = url.searchParams.get("token"); + if (qp && qp.trim().length > 0) return qp.trim(); + } catch { + // ignore + } + + // 2) Authorization header + const header = req.headers.authorization; + if (typeof header === "string" && header.toLowerCase().startsWith("bearer ")) { + const v = header.slice("bearer ".length).trim(); + if (v.length > 0) return v; + } + + // 3) Sec-WebSocket-Protocol: use first comma-separated value as token + const proto = req.headers["sec-websocket-protocol"]; + if (typeof proto === "string") { + const first = proto + .split(",") + .map((s) => s.trim()) + .find((s) => s.length > 0); + if (first) return first; + } + + return null; +} + +export function isWsAuthorized(req: IncomingMessage, config: AuthConfig): boolean { + const token = (config.token ?? "").trim(); + if (token.length === 0) return true; // disabled + const presented = extractWsToken(req); + return presented != null && safeEq(presented, token); +} + +// Time-constant-ish equality for short tokens +function safeEq(a: string, b: string): boolean { + if (a.length !== b.length) return false; + let out = 0; + for (let i = 0; i < a.length; i++) { + out |= a.charCodeAt(i) ^ b.charCodeAt(i); + } + return out === 0; +} diff --git a/tests/__mocks__/jsdom.js b/tests/__mocks__/jsdom.js index 16ca413b2..0a28ff713 100644 --- a/tests/__mocks__/jsdom.js +++ b/tests/__mocks__/jsdom.js @@ -7,10 +7,10 @@ module.exports = { constructor(html, options) { this.window = { document: { - title: "Mock Document", - body: { innerHTML: html || "" }, - }, + title: 'Mock Document', + body: { innerHTML: html || '' } + } }; } - }, + } }; diff --git a/tests/e2e/scenarios/review.spec.ts b/tests/e2e/scenarios/review.spec.ts index 48b828955..2d2ce4be0 100644 --- a/tests/e2e/scenarios/review.spec.ts +++ b/tests/e2e/scenarios/review.spec.ts @@ -23,7 +23,8 @@ test("review scenario", async ({ ui }) => { await ui.chat.sendMessage(REVIEW_PROMPTS.SHOW_ONBOARDING_DOC); await ui.chat.expectTranscriptContains("Found it. Here’s the quick-start summary:"); - await ui.chat.sendCommandAndExpectStatus("/truncate 50", "Chat history truncated"); + await ui.chat.sendMessage("/truncate 50"); + await ui.chat.expectStatusMessageContains("Chat history truncated"); await ui.metaSidebar.expectVisible(); await ui.metaSidebar.selectTab("Review"); diff --git a/tests/e2e/scenarios/slashCommands.spec.ts b/tests/e2e/scenarios/slashCommands.spec.ts index 0bb8a71f0..32b3ae106 100644 --- a/tests/e2e/scenarios/slashCommands.spec.ts +++ b/tests/e2e/scenarios/slashCommands.spec.ts @@ -58,7 +58,8 @@ test.describe("slash command flows", () => { await expect(transcript).toContainText("Mock README content"); await expect(transcript).toContainText("hello"); - await ui.chat.sendCommandAndExpectStatus("/truncate 50", "Chat history truncated by 50%"); + await ui.chat.sendMessage("/truncate 50"); + await ui.chat.expectStatusMessageContains("Chat history truncated by 50%"); await expect(transcript).not.toContainText("Mock README content"); await expect(transcript).toContainText("hello"); @@ -94,7 +95,7 @@ test.describe("slash command flows", () => { const transcript = page.getByRole("log", { name: "Conversation transcript" }); await ui.chat.expectTranscriptContains(COMPACT_SUMMARY_TEXT); await expect(transcript).toContainText(COMPACT_SUMMARY_TEXT); - // Note: The old "📦 compacted" label was removed - compaction now shows only summary text + await expect(transcript.getByText("📦 compacted")).toBeVisible(); await expect(transcript).not.toContainText("Mock README content"); await expect(transcript).not.toContainText("Directory listing:"); }); diff --git a/tests/e2e/utils/ui.ts b/tests/e2e/utils/ui.ts index d275551b9..eae4451c8 100644 --- a/tests/e2e/utils/ui.ts +++ b/tests/e2e/utils/ui.ts @@ -32,7 +32,6 @@ export interface WorkspaceUI { expectActionButtonVisible(label: string): Promise; clickActionButton(label: string): Promise; expectStatusMessageContains(text: string): Promise; - sendCommandAndExpectStatus(command: string, expectedStatus: string): Promise; captureStreamTimeline( action: () => Promise, options?: { timeoutMs?: number } @@ -170,40 +169,6 @@ export function createWorkspaceUI(page: Page, context: DemoProjectConfig): Works await expect(status).toBeVisible(); }, - /** - * Send a slash command and wait for a status toast concurrently. - * This avoids the race condition where the toast can auto-dismiss (after 3s) - * before a sequential assertion has a chance to observe it. - * - * Uses waitForSelector which polls more aggressively than expect().toBeVisible() - * to catch transient elements like auto-dismissing toasts. - */ - async sendCommandAndExpectStatus(command: string, expectedStatus: string): Promise { - if (!command.startsWith("/")) { - throw new Error("sendCommandAndExpectStatus expects a slash command"); - } - const input = page.getByRole("textbox", { - name: /Message Claude|Edit your last message/, - }); - await expect(input).toBeVisible(); - - // Use page.waitForSelector which polls aggressively for transient elements. - // Start the wait BEFORE triggering the action to catch the toast immediately. - // Use longer timeout since slash commands involve async ORPC calls under the hood. - const toastSelector = `[role="status"]:has-text("${expectedStatus}")`; - const toastPromise = page.waitForSelector(toastSelector, { - state: "attached", - timeout: 30_000, - }); - - // Send the command - await input.fill(command); - await page.keyboard.press("Enter"); - - // Wait for the toast we started watching for - await toastPromise; - }, - async captureStreamTimeline( action: () => Promise, options?: { timeoutMs?: number } @@ -228,6 +193,7 @@ export function createWorkspaceUI(page: Page, context: DemoProjectConfig): Works }; const win = window as unknown as { + api: typeof window.api; __muxStreamCapture?: Record; }; @@ -241,94 +207,60 @@ export function createWorkspaceUI(page: Page, context: DemoProjectConfig): Works } const events: StreamCaptureEvent[] = []; - const controller = new AbortController(); - const signal = controller.signal; - - // Start processing in background - void (async () => { - try { - if (!window.__ORPC_CLIENT__) { - throw new Error("ORPC client not initialized"); - } - const iterator = await window.__ORPC_CLIENT__.workspace.onChat( - { workspaceId: id }, - { signal } - ); - - for await (const message of iterator) { - if (signal.aborted) break; - - if (!message || typeof message !== "object") { - continue; - } - if ( - !("type" in message) || - typeof (message as { type?: unknown }).type !== "string" - ) { - continue; - } - const eventType = (message as { type: string }).type; - const isStreamEvent = eventType.startsWith("stream-"); - const isToolEvent = eventType.startsWith("tool-call-"); - const isReasoningEvent = eventType.startsWith("reasoning-"); - if (!isStreamEvent && !isToolEvent && !isReasoningEvent) { - continue; - } - const entry: StreamCaptureEvent = { - type: eventType, - timestamp: Date.now(), - }; - if ( - "delta" in message && - typeof (message as { delta?: unknown }).delta === "string" - ) { - entry.delta = (message as { delta: string }).delta; - } - if ( - "messageId" in message && - typeof (message as { messageId?: unknown }).messageId === "string" - ) { - entry.messageId = (message as { messageId: string }).messageId; - } - if ( - "model" in message && - typeof (message as { model?: unknown }).model === "string" - ) { - entry.model = (message as { model: string }).model; - } - if ( - isToolEvent && - "toolName" in message && - typeof (message as { toolName?: unknown }).toolName === "string" - ) { - entry.toolName = (message as { toolName: string }).toolName; - } - if ( - isToolEvent && - "toolCallId" in message && - typeof (message as { toolCallId?: unknown }).toolCallId === "string" - ) { - entry.toolCallId = (message as { toolCallId: string }).toolCallId; - } - if (isToolEvent && "args" in message) { - entry.args = (message as { args?: unknown }).args; - } - if (isToolEvent && "result" in message) { - entry.result = (message as { result?: unknown }).result; - } - events.push(entry); - } - } catch (err) { - if (!signal.aborted) { - console.error("[E2E] Stream capture error:", err); - } + const unsubscribe = win.api.workspace.onChat(id, (message) => { + if (!message || typeof message !== "object") { + return; + } + if (!("type" in message) || typeof (message as { type?: unknown }).type !== "string") { + return; + } + const eventType = (message as { type: string }).type; + const isStreamEvent = eventType.startsWith("stream-"); + const isToolEvent = eventType.startsWith("tool-call-"); + const isReasoningEvent = eventType.startsWith("reasoning-"); + if (!isStreamEvent && !isToolEvent && !isReasoningEvent) { + return; + } + const entry: StreamCaptureEvent = { + type: eventType, + timestamp: Date.now(), + }; + if ("delta" in message && typeof (message as { delta?: unknown }).delta === "string") { + entry.delta = (message as { delta: string }).delta; + } + if ( + "messageId" in message && + typeof (message as { messageId?: unknown }).messageId === "string" + ) { + entry.messageId = (message as { messageId: string }).messageId; } - })(); + if ("model" in message && typeof (message as { model?: unknown }).model === "string") { + entry.model = (message as { model: string }).model; + } + if ( + isToolEvent && + "toolName" in message && + typeof (message as { toolName?: unknown }).toolName === "string" + ) { + entry.toolName = (message as { toolName: string }).toolName; + } + if ( + isToolEvent && + "toolCallId" in message && + typeof (message as { toolCallId?: unknown }).toolCallId === "string" + ) { + entry.toolCallId = (message as { toolCallId: string }).toolCallId; + } + if (isToolEvent && "args" in message) { + entry.args = (message as { args?: unknown }).args; + } + if (isToolEvent && "result" in message) { + entry.result = (message as { result?: unknown }).result; + } + events.push(entry); + }); - store[id] = { - events, - unsubscribe: () => controller.abort(), - }; + store[id] = { events, unsubscribe }; }, workspaceId); let actionError: unknown; diff --git a/tests/integration/helpers.ts b/tests/integration/helpers.ts deleted file mode 100644 index 59ab50dd0..000000000 --- a/tests/integration/helpers.ts +++ /dev/null @@ -1,626 +0,0 @@ -import type { IpcRenderer } from "electron"; -import type { - ImagePart, - SendMessageOptions, - WorkspaceChatMessage, - WorkspaceInitEvent, -} from "@/common/orpc/types"; -import { isInitStart, isInitOutput, isInitEnd } from "@/common/orpc/types"; - -// Re-export StreamCollector utilities for backwards compatibility -export { - StreamCollector, - createStreamCollector, - assertStreamSuccess, - withStreamCollection, - waitForStreamSuccess, - extractTextFromEvents, -} from "./streamCollector"; -import { createStreamCollector } from "./streamCollector"; -import type { Result } from "../../src/common/types/result"; -import type { SendMessageError } from "../../src/common/types/errors"; -import type { FrontendWorkspaceMetadata } from "../../src/common/types/workspace"; -import * as path from "path"; -import * as os from "os"; -import * as fs from "fs/promises"; -import { exec } from "child_process"; -import { promisify } from "util"; -import { detectDefaultTrunkBranch } from "../../src/node/git"; -import type { TestEnvironment } from "./setup"; -import type { RuntimeConfig } from "../../src/common/types/runtime"; -import type { OrpcTestClient } from "./orpcTestClient"; -import { KNOWN_MODELS } from "../../src/common/constants/knownModels"; -import type { ToolPolicy } from "../../src/common/utils/tools/toolPolicy"; -import type { WorkspaceSendMessageOutput } from "@/common/orpc/schemas"; -import { HistoryService } from "../../src/node/services/historyService"; -import { createMuxMessage } from "../../src/common/types/message"; - -const execAsync = promisify(exec); -import { ORPCError } from "@orpc/client"; -import { ValidationError } from "@orpc/server"; - -// Test constants - centralized for consistency across all tests -export const INIT_HOOK_WAIT_MS = 1500; // Wait for async init hook completion (local runtime) -export const SSH_INIT_WAIT_MS = 7000; // SSH init includes sync + checkout + hook, takes longer -export const HAIKU_MODEL = "anthropic:claude-haiku-4-5"; // Fast model for tests -export const GPT_5_MINI_MODEL = "openai:gpt-5-mini"; // Fastest model for performance-critical tests -export const TEST_TIMEOUT_LOCAL_MS = 25000; // Recommended timeout for local runtime tests -export const TEST_TIMEOUT_SSH_MS = 60000; // Recommended timeout for SSH runtime tests -export const STREAM_TIMEOUT_LOCAL_MS = 15000; // Stream timeout for local runtime - -export type OrpcSource = - | TestEnvironment - | OrpcTestClient - | (IpcRenderer & { __orpc?: OrpcTestClient }); - -export function resolveOrpcClient(source: OrpcSource): OrpcTestClient { - if ("orpc" in source) { - return source.orpc; - } - - if ("workspace" in source) { - return source; - } - - if ("__orpc" in source && source.__orpc) { - return source.__orpc; - } - - throw new Error( - "ORPC client unavailable. Pass TestEnvironment or OrpcTestClient to test helpers instead of mockIpcRenderer." - ); -} -export const STREAM_TIMEOUT_SSH_MS = 25000; // Stream timeout for SSH runtime - -/** - * Generate a unique branch name - * Uses high-resolution time (nanosecond precision) to prevent collisions - */ -export function generateBranchName(prefix = "test"): string { - const hrTime = process.hrtime.bigint(); - const random = Math.random().toString(36).substring(2, 10); - return `${prefix}-${hrTime}-${random}`; -} - -/** - * Create a full model string from provider and model name - */ -export function modelString(provider: string, model: string): string { - return `${provider}:${model}`; -} - -/** - * Send a message via IPC - */ -type SendMessageWithModelOptions = Omit & { - imageParts?: Array<{ url: string; mediaType: string }>; -}; - -const DEFAULT_MODEL_ID = KNOWN_MODELS.SONNET.id; -const DEFAULT_PROVIDER = KNOWN_MODELS.SONNET.provider; - -export async function sendMessage( - source: OrpcSource, - workspaceId: string, - message: string, - options?: SendMessageOptions & { imageParts?: ImagePart[] } -): Promise> { - const client = resolveOrpcClient(source); - - let result: WorkspaceSendMessageOutput; - try { - result = await client.workspace.sendMessage({ workspaceId, message, options }); - } catch (error) { - // Normalize ORPC input validation or transport errors into Result shape expected by tests. - let raw: string = ""; - - if ( - error instanceof ORPCError && - error.code === "BAD_REQUEST" && - error.cause instanceof ValidationError - ) { - raw = error.cause.issues.map((iss) => iss.message).join(); - } else { - raw = - error instanceof Error - ? error.message || error.toString() - : typeof error === "string" - ? error - : JSON.stringify(error); - } - - return { success: false, error: { type: "unknown", raw } }; - } - - // Normalize to Result for callers - they just care about success/failure - if (result.success) { - return { success: true, data: undefined }; - } - - return { success: false, error: result.error }; -} - -/** - * Send a message with an explicit model id (defaults to SONNET). - */ -export async function sendMessageWithModel( - source: OrpcSource, - workspaceId: string, - message: string, - modelId: string = DEFAULT_MODEL_ID, - options?: SendMessageWithModelOptions -): Promise> { - const resolvedModel = modelId.includes(":") ? modelId : modelString(DEFAULT_PROVIDER, modelId); - - return sendMessage(source, workspaceId, message, { - ...options, - model: resolvedModel, - }); -} - -/** - * Create a workspace via IPC - */ -export async function createWorkspace( - source: OrpcSource, - projectPath: string, - branchName: string, - trunkBranch?: string, - runtimeConfig?: RuntimeConfig -): Promise< - { success: true; metadata: FrontendWorkspaceMetadata } | { success: false; error: string } -> { - const resolvedTrunk = - typeof trunkBranch === "string" && trunkBranch.trim().length > 0 - ? trunkBranch.trim() - : await detectDefaultTrunkBranch(projectPath); - - const client = resolveOrpcClient(source); - return client.workspace.create({ - projectPath, - branchName, - trunkBranch: resolvedTrunk, - runtimeConfig, - }); -} - -/** - * Clear workspace history via IPC - */ -export async function clearHistory( - source: OrpcSource, - workspaceId: string, - percentage?: number -): Promise> { - const client = resolveOrpcClient(source); - return (await client.workspace.truncateHistory({ workspaceId, percentage })) as Result< - void, - string - >; -} - -/** - * Create workspace with optional init hook wait - * Enhanced version that can wait for init hook completion (needed for runtime tests) - */ -export async function createWorkspaceWithInit( - env: TestEnvironment, - projectPath: string, - branchName: string, - runtimeConfig?: RuntimeConfig, - waitForInit: boolean = false, - isSSH: boolean = false -): Promise<{ workspaceId: string; workspacePath: string; cleanup: () => Promise }> { - const trunkBranch = await detectDefaultTrunkBranch(projectPath); - - const result = await env.orpc.workspace.create({ - projectPath, - branchName, - trunkBranch, - runtimeConfig, - }); - - if (!result.success) { - throw new Error(`Failed to create workspace: ${result.error}`); - } - - const workspaceId = result.metadata.id; - const workspacePath = result.metadata.namedWorkspacePath; - - // Wait for init hook to complete if requested - if (waitForInit) { - const initTimeout = isSSH ? SSH_INIT_WAIT_MS : INIT_HOOK_WAIT_MS; - - const collector = createStreamCollector(env.orpc, workspaceId); - collector.start(); - try { - await collector.waitForEvent("init-end", initTimeout); - } catch (err) { - // Init hook might not exist or might have already completed before we started waiting - // This is not necessarily an error - just log it - console.log( - `Note: init-end event not detected within ${initTimeout}ms (may have completed early)` - ); - } finally { - collector.stop(); - } - } - - const cleanup = async () => { - await env.orpc.workspace.remove({ workspaceId }); - }; - - return { workspaceId, workspacePath, cleanup }; -} - -/** - * Send message and wait for stream completion - * Convenience helper that combines message sending with event collection - */ -export async function sendMessageAndWait( - env: TestEnvironment, - workspaceId: string, - message: string, - model: string, - toolPolicy?: ToolPolicy, - timeoutMs: number = STREAM_TIMEOUT_LOCAL_MS -): Promise { - const collector = createStreamCollector(env.orpc, workspaceId); - collector.start(); - - try { - // Wait for subscription to be established before sending message - // This prevents race conditions where events are emitted before collector is ready - // The subscription is ready once we receive the first event (history replay) - await collector.waitForSubscription(); - - // Additional small delay to ensure the generator loop is stable - // This helps with concurrent test execution where system load causes timing issues - await new Promise((resolve) => setTimeout(resolve, 50)); - - // Send message - const result = await env.orpc.workspace.sendMessage({ - workspaceId, - message, - options: { - model, - toolPolicy, - thinkingLevel: "off", // Disable reasoning for fast test execution - mode: "exec", // Execute commands directly, don't propose plans - }, - }); - - if (!result.success && !("workspaceId" in result)) { - throw new Error(`Failed to send message: ${JSON.stringify(result, null, 2)}`); - } - - // Wait for stream completion - await collector.waitForEvent("stream-end", timeoutMs); - return collector.getEvents(); - } finally { - collector.stop(); - } -} - -// Re-export StreamCollector for use as EventCollector (API compatible) -export { StreamCollector as EventCollector } from "./streamCollector"; - -/** - * Create an event collector for a workspace. - * - * MIGRATION NOTE: Tests should migrate to using StreamCollector directly: - * const collector = createStreamCollector(env.orpc, workspaceId); - * collector.start(); - * ... test code ... - * collector.stop(); - * - * This function exists for backwards compatibility during migration. - * It detects whether the first argument is an ORPC client or sentEvents array. - */ -export function createEventCollector( - firstArg: OrpcTestClient | Array<{ channel: string; data: unknown }>, - workspaceId: string -) { - const { createStreamCollector } = require("./streamCollector"); - - // Check if firstArg is an OrpcTestClient (has workspace.onChat method) - if (firstArg && typeof firstArg === "object" && "workspace" in firstArg) { - return createStreamCollector(firstArg as OrpcTestClient, workspaceId); - } - - // Legacy signature - throw helpful error directing to new pattern - throw new Error( - `createEventCollector(sentEvents, workspaceId) is deprecated.\n` + - `Use the new pattern:\n` + - ` const collector = createStreamCollector(env.orpc, workspaceId);\n` + - ` collector.start();\n` + - ` ... test code ...\n` + - ` collector.stop();` - ); -} - -/** - * Assert that a result has a specific error type - */ -export function assertError( - result: Result, - expectedErrorType: string -): void { - expect(result.success).toBe(false); - if (!result.success) { - expect(result.error.type).toBe(expectedErrorType); - } -} - -/** - * Poll for a condition with exponential backoff - * More robust than fixed sleeps for async operations - */ -export async function waitFor( - condition: () => boolean | Promise, - timeoutMs = 5000, - pollIntervalMs = 50 -): Promise { - const startTime = Date.now(); - let currentInterval = pollIntervalMs; - - while (Date.now() - startTime < timeoutMs) { - if (await condition()) { - return true; - } - await new Promise((resolve) => setTimeout(resolve, currentInterval)); - // Exponential backoff with max 500ms - currentInterval = Math.min(currentInterval * 1.5, 500); - } - - return false; -} - -/** - * Wait for a file to exist with retry logic - * Useful for checking file operations that may take time - */ -export async function waitForFileExists(filePath: string, timeoutMs = 5000): Promise { - return waitFor(async () => { - try { - await fs.access(filePath); - return true; - } catch { - return false; - } - }, timeoutMs); -} - -/** - * Wait for init hook to complete by watching for init-end event. - * Uses ORPC subscription via StreamCollector. - */ -/** - * Wait for init to complete successfully (exitCode === 0). - * Throws if init fails or times out. - * Returns collected init events for inspection. - */ -export async function waitForInitComplete( - env: import("./setup").TestEnvironment, - workspaceId: string, - timeoutMs = 5000 -): Promise { - const collector = createStreamCollector(env.orpc, workspaceId); - collector.start(); - - try { - const initEndEvent = await collector.waitForEvent("init-end", timeoutMs); - if (!initEndEvent) { - throw new Error(`Init did not complete within ${timeoutMs}ms - workspace may not be ready`); - } - - const initEvents = collector - .getEvents() - .filter( - (msg) => isInitStart(msg) || isInitOutput(msg) || isInitEnd(msg) - ) as WorkspaceInitEvent[]; - - // Check if init succeeded (exitCode === 0) - const exitCode = (initEndEvent as { exitCode?: number }).exitCode; - if (exitCode !== undefined && exitCode !== 0) { - // Collect all init output for debugging - const initOutputEvents = initEvents.filter((e) => isInitOutput(e)); - const output = initOutputEvents - .map((e) => (e as { line?: string }).line) - .filter(Boolean) - .join("\n"); - throw new Error(`Init hook failed with exit code ${exitCode}:\n${output}`); - } - - return initEvents; - } finally { - collector.stop(); - } -} - -/** - * Collect all init events for a workspace (alias for waitForInitComplete). - * Uses ORPC subscription via StreamCollector. - * Note: This starts a collector, waits for init-end, then returns init events. - */ -export async function collectInitEvents( - env: import("./setup").TestEnvironment, - workspaceId: string, - timeoutMs = 5000 -): Promise { - return waitForInitComplete(env, workspaceId, timeoutMs); -} - -/** - * Wait for init-end event without checking exit code. - * Use this when you want to test failure cases or inspect the exit code yourself. - * Returns collected init events for inspection. - */ -export async function waitForInitEnd( - env: import("./setup").TestEnvironment, - workspaceId: string, - timeoutMs = 5000 -): Promise { - const collector = createStreamCollector(env.orpc, workspaceId); - collector.start(); - - try { - const event = await collector.waitForEvent("init-end", timeoutMs); - if (!event) { - throw new Error(`Init did not complete within ${timeoutMs}ms`); - } - return collector - .getEvents() - .filter( - (msg) => isInitStart(msg) || isInitOutput(msg) || isInitEnd(msg) - ) as WorkspaceInitEvent[]; - } finally { - collector.stop(); - } -} - -/** - * Read and parse chat history from disk - */ -export async function readChatHistory( - tempDir: string, - workspaceId: string -): Promise }>> { - const historyPath = path.join(tempDir, "sessions", workspaceId, "chat.jsonl"); - const historyContent = await fs.readFile(historyPath, "utf-8"); - return historyContent - .trim() - .split("\n") - .map((line: string) => JSON.parse(line)); -} - -/** - * Test image fixtures (1x1 pixel PNGs) - */ -export const TEST_IMAGES: Record = { - RED_PIXEL: { - url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==", - mediaType: "image/png", - }, - BLUE_PIXEL: { - url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M/wHwAEBgIApD5fRAAAAABJRU5ErkJggg==", - mediaType: "image/png", - }, -}; - -/** - * Wait for a file to NOT exist with retry logic - */ -export async function waitForFileNotExists(filePath: string, timeoutMs = 5000): Promise { - return waitFor(async () => { - try { - await fs.access(filePath); - return false; - } catch { - return true; - } - }, timeoutMs); -} - -/** - * Create a temporary git repository for testing - */ -export async function createTempGitRepo(): Promise { - // eslint-disable-next-line local/no-unsafe-child-process - - // Use mkdtemp to avoid race conditions and ensure unique directory - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-test-repo-")); - - // Use promisify(exec) for test setup - DisposableExec has issues in CI - // TODO: Investigate why DisposableExec causes empty git output in CI - await execAsync(`git init`, { cwd: tempDir }); - await execAsync(`git config user.email "test@example.com" && git config user.name "Test User"`, { - cwd: tempDir, - }); - await execAsync( - `echo "test" > README.md && git add . && git commit -m "Initial commit" && git branch test-branch`, - { cwd: tempDir } - ); - - return tempDir; -} - -/** - * Add a git submodule to a repository - * @param repoPath - Path to the repository to add the submodule to - * @param submoduleUrl - URL of the submodule repository (defaults to leftpad) - * @param submoduleName - Name/path for the submodule - */ -export async function addSubmodule( - repoPath: string, - submoduleUrl: string = "https://github.com/left-pad/left-pad.git", - submoduleName: string = "vendor/left-pad" -): Promise { - await execAsync(`git submodule add "${submoduleUrl}" "${submoduleName}"`, { cwd: repoPath }); - await execAsync(`git commit -m "Add submodule ${submoduleName}"`, { cwd: repoPath }); -} - -/** - * Cleanup temporary git repository with retry logic - */ -export async function cleanupTempGitRepo(repoPath: string): Promise { - const maxRetries = 3; - let lastError: unknown; - - for (let i = 0; i < maxRetries; i++) { - try { - await fs.rm(repoPath, { recursive: true, force: true }); - return; - } catch (error) { - lastError = error; - // Wait before retry (files might be locked temporarily) - if (i < maxRetries - 1) { - await new Promise((resolve) => setTimeout(resolve, 100 * (i + 1))); - } - } - } - console.warn(`Failed to cleanup temp git repo after ${maxRetries} attempts:`, lastError); -} - -/** - * Build large conversation history to test context limits - * - * This is a test-only utility that uses HistoryService directly to quickly - * populate history without making API calls. Real application code should - * NEVER bypass IPC like this. - * - * @param workspaceId - Workspace to populate - * @param config - Config instance for HistoryService - * @param options - Configuration for history size - * @returns Promise that resolves when history is built - */ -export async function buildLargeHistory( - workspaceId: string, - config: { getSessionDir: (id: string) => string }, - options: { - messageSize?: number; - messageCount?: number; - textPrefix?: string; - } = {} -): Promise { - // HistoryService only needs getSessionDir, so we can cast the partial config - const historyService = new HistoryService(config as any); - - const messageSize = options.messageSize ?? 50_000; - const messageCount = options.messageCount ?? 80; - const textPrefix = options.textPrefix ?? ""; - - const largeText = textPrefix + "A".repeat(messageSize); - - // Build conversation history with alternating user/assistant messages - for (let i = 0; i < messageCount; i++) { - const isUser = i % 2 === 0; - const role = isUser ? "user" : "assistant"; - const message = createMuxMessage(`history-msg-${i}`, role, largeText, {}); - - const result = await historyService.appendToHistory(workspaceId, message); - if (!result.success) { - throw new Error(`Failed to append message ${i} to history: ${result.error}`); - } - } -} diff --git a/tests/integration/initWorkspace.test.ts b/tests/integration/initWorkspace.test.ts deleted file mode 100644 index e8d11e1ed..000000000 --- a/tests/integration/initWorkspace.test.ts +++ /dev/null @@ -1,454 +0,0 @@ -import { - shouldRunIntegrationTests, - createTestEnvironment, - cleanupTestEnvironment, - validateApiKeys, - getApiKey, - setupProviders, - type TestEnvironment, -} from "./setup"; -import { - generateBranchName, - createWorkspace, - waitForInitComplete, - waitForInitEnd, - collectInitEvents, - waitFor, - resolveOrpcClient, -} from "./helpers"; -import type { WorkspaceChatMessage, WorkspaceInitEvent } from "@/common/orpc/types"; -import { isInitStart, isInitOutput, isInitEnd } from "@/common/orpc/types"; -import * as path from "path"; -import * as os from "os"; -import * as fs from "fs/promises"; -import { exec } from "child_process"; -import { promisify } from "util"; -import { - isDockerAvailable, - startSSHServer, - stopSSHServer, - type SSHServerConfig, -} from "../runtime/ssh-fixture"; -import type { RuntimeConfig } from "../../src/common/types/runtime"; - -// Skip all tests if TEST_INTEGRATION is not set -const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip; - -// Validate API keys for AI tests -if (shouldRunIntegrationTests()) { - validateApiKeys(["ANTHROPIC_API_KEY"]); -} - -/** - * Create a temp git repo with a .mux/init hook that writes to stdout/stderr and exits with a given code - */ -async function createTempGitRepoWithInitHook(options: { - exitCode: number; - stdoutLines?: string[]; - stderrLines?: string[]; - sleepBetweenLines?: number; // milliseconds - customScript?: string; // Optional custom script content (overrides stdout/stderr) -}): Promise { - const execAsync = promisify(exec); - - // Use mkdtemp to avoid race conditions - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-test-init-hook-")); - - // Initialize git repo - await execAsync(`git init`, { cwd: tempDir }); - await execAsync(`git config user.email "test@example.com" && git config user.name "Test User"`, { - cwd: tempDir, - }); - await execAsync(`echo "test" > README.md && git add . && git commit -m "Initial commit"`, { - cwd: tempDir, - }); - - // Create .mux directory - const muxDir = path.join(tempDir, ".mux"); - await fs.mkdir(muxDir, { recursive: true }); - - // Create init hook script - const hookPath = path.join(muxDir, "init"); - - let scriptContent: string; - if (options.customScript) { - scriptContent = `#!/bin/bash\n${options.customScript}\nexit ${options.exitCode}\n`; - } else { - const sleepCmd = options.sleepBetweenLines ? `sleep ${options.sleepBetweenLines / 1000}` : ""; - - const stdoutCmds = (options.stdoutLines ?? []) - .map((line, idx) => { - const needsSleep = sleepCmd && idx < (options.stdoutLines?.length ?? 0) - 1; - return `echo "${line}"${needsSleep ? `\n${sleepCmd}` : ""}`; - }) - .join("\n"); - - const stderrCmds = (options.stderrLines ?? []).map((line) => `echo "${line}" >&2`).join("\n"); - - scriptContent = `#!/bin/bash\n${stdoutCmds}\n${stderrCmds}\nexit ${options.exitCode}\n`; - } - - await fs.writeFile(hookPath, scriptContent, { mode: 0o755 }); - - // Commit the init hook (required for SSH runtime - git worktree syncs committed files) - await execAsync(`git add -A && git commit -m "Add init hook"`, { cwd: tempDir }); - - return tempDir; -} - -/** - * Cleanup temporary git repository - */ -async function cleanupTempGitRepo(repoPath: string): Promise { - const maxRetries = 3; - let lastError: unknown; - - for (let i = 0; i < maxRetries; i++) { - try { - await fs.rm(repoPath, { recursive: true, force: true }); - return; - } catch (error) { - lastError = error; - if (i < maxRetries - 1) { - await new Promise((resolve) => setTimeout(resolve, 100 * (i + 1))); - } - } - } - console.warn(`Failed to cleanup temp git repo after ${maxRetries} attempts:`, lastError); -} - -describeIntegration("Workspace init hook", () => { - test.concurrent( - "should stream init hook output and allow workspace usage on hook success", - async () => { - const env = await createTestEnvironment(); - const tempGitRepo = await createTempGitRepoWithInitHook({ - exitCode: 0, - stdoutLines: ["Installing dependencies...", "Build complete!"], - stderrLines: ["Warning: deprecated package"], - }); - - try { - const branchName = generateBranchName("init-hook-success"); - - // Create workspace (which will trigger the hook) - const createResult = await createWorkspace(env, tempGitRepo, branchName); - expect(createResult.success).toBe(true); - if (!createResult.success) return; - - const workspaceId = createResult.metadata.id; - - // Wait for hook to complete and collect init events for verification - const initEvents = await collectInitEvents(env, workspaceId, 10000); - - // Verify event sequence - expect(initEvents.length).toBeGreaterThan(0); - - // First event should be start - const startEvent = initEvents.find((e) => isInitStart(e)); - expect(startEvent).toBeDefined(); - if (startEvent && isInitStart(startEvent)) { - // Hook path should be the project path (where .mux/init exists) - expect(startEvent.hookPath).toBeTruthy(); - } - - // Should have output and error lines - const outputEvents = initEvents.filter( - (e): e is Extract => - isInitOutput(e) && !e.isError - ); - const errorEvents = initEvents.filter( - (e): e is Extract => - isInitOutput(e) && e.isError === true - ); - - // Should have workspace creation logs + hook output - expect(outputEvents.length).toBeGreaterThanOrEqual(2); - - // Verify hook output is present (may have workspace creation logs before it) - const outputLines = outputEvents.map((e) => e.line); - expect(outputLines).toContain("Installing dependencies..."); - expect(outputLines).toContain("Build complete!"); - - expect(errorEvents.length).toBe(1); - expect(errorEvents[0].line).toBe("Warning: deprecated package"); - - // Last event should be end with exitCode 0 - const finalEvent = initEvents[initEvents.length - 1]; - expect(isInitEnd(finalEvent)).toBe(true); - if (isInitEnd(finalEvent)) { - expect(finalEvent.exitCode).toBe(0); - } - - // Workspace should be usable - verify getInfo succeeds - const client = resolveOrpcClient(env); - const info = await client.workspace.getInfo({ workspaceId }); - expect(info).not.toBeNull(); - if (info) expect(info.id).toBe(workspaceId); - } finally { - await cleanupTestEnvironment(env); - await cleanupTempGitRepo(tempGitRepo); - } - }, - 15000 - ); - - test.concurrent( - "should stream init hook output and allow workspace usage on hook failure", - async () => { - const env = await createTestEnvironment(); - const tempGitRepo = await createTempGitRepoWithInitHook({ - exitCode: 1, - stdoutLines: ["Starting setup..."], - stderrLines: ["ERROR: Failed to install dependencies"], - }); - - try { - const branchName = generateBranchName("init-hook-failure"); - - // Create workspace - const createResult = await createWorkspace(env, tempGitRepo, branchName); - expect(createResult.success).toBe(true); - if (!createResult.success) return; - - const workspaceId = createResult.metadata.id; - - // Wait for hook to complete (without throwing on failure) and collect events - const initEvents = await waitForInitEnd(env, workspaceId, 10000); - - // Verify we got events - expect(initEvents.length).toBeGreaterThan(0); - - // Should have start event - const failureStartEvent = initEvents.find((e) => isInitStart(e)); - expect(failureStartEvent).toBeDefined(); - - // Should have output and error - const failureOutputEvents = initEvents.filter( - (e): e is Extract => - isInitOutput(e) && !e.isError - ); - const failureErrorEvents = initEvents.filter( - (e): e is Extract => - isInitOutput(e) && e.isError === true - ); - expect(failureOutputEvents.length).toBeGreaterThanOrEqual(1); - expect(failureErrorEvents.length).toBeGreaterThanOrEqual(1); - - // Last event should be end with exitCode 1 - const failureFinalEvent = initEvents[initEvents.length - 1]; - expect(isInitEnd(failureFinalEvent)).toBe(true); - if (isInitEnd(failureFinalEvent)) { - expect(failureFinalEvent.exitCode).toBe(1); - } - - // CRITICAL: Workspace should remain usable even after hook failure - const client = resolveOrpcClient(env); - const info = await client.workspace.getInfo({ workspaceId }); - expect(info).not.toBeNull(); - if (info) expect(info.id).toBe(workspaceId); - } finally { - await cleanupTestEnvironment(env); - await cleanupTempGitRepo(tempGitRepo); - } - }, - 15000 - ); - - test.concurrent( - "should not emit meta events when no init hook exists", - async () => { - const env = await createTestEnvironment(); - // Create repo without .mux/init hook - const execAsync = promisify(exec); - - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-test-no-hook-")); - - try { - // Initialize git repo without hook - await execAsync(`git init`, { cwd: tempDir }); - await execAsync( - `git config user.email "test@example.com" && git config user.name "Test User"`, - { cwd: tempDir } - ); - await execAsync(`echo "test" > README.md && git add . && git commit -m "Initial commit"`, { - cwd: tempDir, - }); - - const branchName = generateBranchName("no-hook"); - - // Create workspace - const createResult = await createWorkspace(env, tempDir, branchName); - expect(createResult.success).toBe(true); - if (!createResult.success) return; - - const workspaceId = createResult.metadata.id; - - // Wait for init to complete and collect events - const initEvents = await collectInitEvents(env, workspaceId, 5000); - - // Should have init-start event (always emitted, even without hook) - const startEvent = initEvents.find((e) => isInitStart(e)); - expect(startEvent).toBeDefined(); - - // Should have workspace creation logs (e.g., "Creating git worktree...") - const outputEvents = initEvents.filter((e) => isInitOutput(e)); - expect(outputEvents.length).toBeGreaterThan(0); - - // Should have completion event with exit code 0 (success, no hook) - const endEvent = initEvents.find((e) => isInitEnd(e)); - expect(endEvent).toBeDefined(); - if (endEvent && isInitEnd(endEvent)) { - expect(endEvent.exitCode).toBe(0); - } - - // Workspace should still be usable - const client = resolveOrpcClient(env); - const info = await client.workspace.getInfo({ workspaceId: createResult.metadata.id }); - expect(info).not.toBeNull(); - } finally { - await cleanupTestEnvironment(env); - await cleanupTempGitRepo(tempDir); - } - }, - 15000 - ); - - test.concurrent( - "should persist init state to disk for replay across page reloads", - async () => { - const env = await createTestEnvironment(); - - const repoPath = await createTempGitRepoWithInitHook({ - exitCode: 0, - stdoutLines: ["Installing dependencies", "Done!"], - stderrLines: [], - }); - - try { - const branchName = generateBranchName("replay-test"); - const createResult = await createWorkspace(env, repoPath, branchName); - expect(createResult.success).toBe(true); - if (!createResult.success) return; - - const workspaceId = createResult.metadata.id; - - // Wait for init hook to complete - await waitForInitComplete(env, workspaceId, 5000); - - // Verify init-status.json exists on disk - const initStatusPath = path.join(env.config.getSessionDir(workspaceId), "init-status.json"); - const statusExists = await fs - .access(initStatusPath) - .then(() => true) - .catch(() => false); - expect(statusExists).toBe(true); - - // Read and verify persisted state - const statusContent = await fs.readFile(initStatusPath, "utf-8"); - const status = JSON.parse(statusContent); - expect(status.status).toBe("success"); - expect(status.exitCode).toBe(0); - - // Should include workspace creation logs + hook output - expect(status.lines).toEqual( - expect.arrayContaining([ - { line: "Creating git worktree...", isError: false, timestamp: expect.any(Number) }, - { - line: "Worktree created successfully", - isError: false, - timestamp: expect.any(Number), - }, - expect.objectContaining({ - line: expect.stringMatching(/Running init hook:/), - isError: false, - }), - { line: "Installing dependencies", isError: false, timestamp: expect.any(Number) }, - { line: "Done!", isError: false, timestamp: expect.any(Number) }, - ]) - ); - expect(status.hookPath).toBeTruthy(); // Project path where hook exists - expect(status.startTime).toBeGreaterThan(0); - expect(status.endTime).toBeGreaterThan(status.startTime); - } finally { - await cleanupTestEnvironment(env); - await cleanupTempGitRepo(repoPath); - } - }, - 15000 - ); -}); - -// TODO: This test relies on timestamp-based event capture (sentEvents with timestamps) -// which isn't available in the ORPC subscription model. The test verified real-time -// streaming timing behavior. Consider reimplementing with StreamCollector timestamp tracking. -test.skip("should receive init events with natural timing (not batched)", () => { - // Test body removed - relies on legacy sentEvents with timestamp tracking -}); - -// SSH server config for runtime matrix tests -let sshConfig: SSHServerConfig | undefined; - -// ============================================================================ -// Runtime Matrix Tests - Init Queue Behavior -// ============================================================================ - -describeIntegration("Init Queue - Runtime Matrix", () => { - beforeAll(async () => { - // Only start SSH server if Docker is available - if (await isDockerAvailable()) { - console.log("Starting SSH server container for init queue tests..."); - sshConfig = await startSSHServer(); - console.log(`SSH server ready on port ${sshConfig.port}`); - } else { - console.log("Docker not available - SSH tests will be skipped"); - } - }, 60000); - - afterAll(async () => { - if (sshConfig) { - console.log("Stopping SSH server container..."); - await stopSSHServer(sshConfig); - } - }, 30000); - - // Test matrix: Run tests for both local and SSH runtimes - describe.each<{ type: "local" | "ssh" }>([{ type: "local" }, { type: "ssh" }])( - "Runtime: $type", - ({ type }) => { - // Helper to build runtime config - const getRuntimeConfig = (branchName: string): RuntimeConfig | undefined => { - if (type === "ssh" && sshConfig) { - return { - type: "ssh", - host: `testuser@localhost`, - srcBaseDir: `${sshConfig.workdir}/${branchName}`, - identityFile: sshConfig.privateKeyPath, - port: sshConfig.port, - }; - } - return undefined; // undefined = defaults to local - }; - - // Timeouts vary by runtime type - const testTimeout = type === "ssh" ? 90000 : 30000; - const streamTimeout = type === "ssh" ? 30000 : 15000; - const initWaitBuffer = type === "ssh" ? 10000 : 2000; - - // TODO: This test relies on sentEvents for channel-based event filtering and - // timestamp tracking which isn't available in the ORPC subscription model. - // Consider reimplementing with StreamCollector once timestamp tracking is added. - test.skip("file_read should wait for init hook before executing (even when init fails)", () => { - // Test body removed - relies on legacy sentEvents with channel filtering - // Original test verified: - // 1. file_read waits for init hook even when hook fails - // 2. Only one file_read call needed (no retries) - // 3. Second message after init completes is faster (no init wait) - void testTimeout; - void streamTimeout; - void initWaitBuffer; - void getRuntimeConfig; - }); - } - ); -}); diff --git a/tests/integration/orpcTestClient.ts b/tests/integration/orpcTestClient.ts deleted file mode 100644 index e56c88d59..000000000 --- a/tests/integration/orpcTestClient.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { createRouterClient, type RouterClient } from "@orpc/server"; -import { router, type AppRouter } from "@/node/orpc/router"; -import type { ORPCContext } from "@/node/orpc/context"; - -export type OrpcTestClient = RouterClient; - -export function createOrpcTestClient(context: ORPCContext): OrpcTestClient { - return createRouterClient(router(), { context }); -} diff --git a/tests/integration/projectRefactor.test.ts b/tests/integration/projectRefactor.test.ts deleted file mode 100644 index e7369ba52..000000000 --- a/tests/integration/projectRefactor.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import * as fs from "fs/promises"; -import * as path from "path"; -import * as os from "os"; -import { shouldRunIntegrationTests, createTestEnvironment, cleanupTestEnvironment } from "./setup"; -import { resolveOrpcClient } from "./helpers"; - -const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip; - -describeIntegration("ProjectService IPC Handlers", () => { - test.concurrent("should list projects including the created one", async () => { - const env = await createTestEnvironment(); - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-project-service-test-")); - const projectPath = path.join(tempDir, "test-project"); - - // Setup a valid project - await fs.mkdir(projectPath, { recursive: true }); - await fs.mkdir(path.join(projectPath, ".git")); - - // Create the project first - const client = resolveOrpcClient(env); - await client.projects.create({ projectPath }); - - const projects = await client.projects.list(); - const paths = projects.map((p: [string, unknown]) => p[0]); - expect(paths).toContain(projectPath); - - await cleanupTestEnvironment(env); - await fs.rm(tempDir, { recursive: true, force: true }); - }); - - test.concurrent("should list branches for a valid project", async () => { - const env = await createTestEnvironment(); - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-project-service-test-")); - const projectPath = path.join(tempDir, "test-project"); - - // Setup a valid project - await fs.mkdir(projectPath, { recursive: true }); - // We need to init git manually to have branches - - // Initialize git repo to have branches - const { exec } = require("child_process"); - const util = require("util"); - const execAsync = util.promisify(exec); - - await execAsync("git init", { cwd: projectPath }); - await execAsync("git config user.email 'test@example.com'", { cwd: projectPath }); - await execAsync("git config user.name 'Test User'", { cwd: projectPath }); - // Create initial commit to have a branch (usually main or master) - await execAsync("touch README.md", { cwd: projectPath }); - await execAsync("git add README.md", { cwd: projectPath }); - await execAsync("git commit -m 'Initial commit'", { cwd: projectPath }); - // Create another branch - await execAsync("git checkout -b feature-branch", { cwd: projectPath }); - - // Project must be created in Mux to list branches via IPC? - // The IPC PROJECT_LIST_BRANCHES takes a path, it doesn't strictly require the project to be in config, - // but usually we operate on known projects. The implementation validates path. - - const client = resolveOrpcClient(env); - const result = await client.projects.listBranches({ projectPath }); - // The current branch is feature-branch - expect(result.branches).toContain("feature-branch"); - // The trunk branch inference might depend on available branches. - expect(result.recommendedTrunk).toBeTruthy(); - - await cleanupTestEnvironment(env); - await fs.rm(tempDir, { recursive: true, force: true }); - }); - - test.concurrent("should handle secrets operations", async () => { - const env = await createTestEnvironment(); - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-project-service-test-")); - const projectPath = path.join(tempDir, "test-project"); - - await fs.mkdir(projectPath, { recursive: true }); - await fs.mkdir(path.join(projectPath, ".git")); - const client = resolveOrpcClient(env); - await client.projects.create({ projectPath }); - - const secrets = [ - { key: "API_KEY", value: "12345" }, - { key: "DB_URL", value: "postgres://localhost" }, - ]; - - // Update secrets - const updateResult = await client.projects.secrets.update({ projectPath, secrets }); - expect(updateResult.success).toBe(true); - - // Get secrets - const fetchedSecrets = await client.projects.secrets.get({ projectPath }); - expect(fetchedSecrets).toHaveLength(2); - expect(fetchedSecrets).toEqual(expect.arrayContaining(secrets)); - - await cleanupTestEnvironment(env); - await fs.rm(tempDir, { recursive: true, force: true }); - }); - - test.concurrent("should remove a project", async () => { - const env = await createTestEnvironment(); - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-project-service-test-")); - const projectPath = path.join(tempDir, "test-project"); - - await fs.mkdir(projectPath, { recursive: true }); - await fs.mkdir(path.join(projectPath, ".git")); - const client = resolveOrpcClient(env); - await client.projects.create({ projectPath }); - - const removeResult = await client.projects.remove({ projectPath }); - expect(removeResult.success).toBe(true); - - const projects = await client.projects.list(); - const paths = projects.map((p: [string, unknown]) => p[0]); - expect(paths).not.toContain(projectPath); - - await cleanupTestEnvironment(env); - await fs.rm(tempDir, { recursive: true, force: true }); - }); -}); diff --git a/tests/integration/streamCollector.ts b/tests/integration/streamCollector.ts deleted file mode 100644 index fa98c4e2a..000000000 --- a/tests/integration/streamCollector.ts +++ /dev/null @@ -1,564 +0,0 @@ -/** - * StreamCollector - Collects events from ORPC async generator subscriptions. - * - * This replaces the legacy EventCollector which polled sentEvents[]. - * StreamCollector directly iterates over the ORPC onChat subscription, - * which is how production clients consume events. - * - * Usage: - * const collector = createStreamCollector(env.orpc, workspaceId); - * collector.start(); - * await sendMessage(env, workspaceId, "hello"); - * await collector.waitForEvent("stream-end", 15000); - * collector.stop(); - * const events = collector.getEvents(); - */ - -import type { WorkspaceChatMessage } from "@/common/orpc/types"; -import type { OrpcTestClient } from "./orpcTestClient"; - -/** - * StreamCollector - Collects events from ORPC async generator subscriptions. - * - * Unlike the legacy EventCollector which polls sentEvents[], this class - * iterates over the actual ORPC subscription generator. - */ -export class StreamCollector { - private events: WorkspaceChatMessage[] = []; - private abortController: AbortController; - private iteratorPromise: Promise | null = null; - private started = false; - private stopped = false; - private subscriptionReady = false; - private subscriptionReadyResolve: (() => void) | null = null; - private waiters: Array<{ - eventType: string; - resolve: (event: WorkspaceChatMessage | null) => void; - timer: ReturnType; - }> = []; - - constructor( - private client: OrpcTestClient, - private workspaceId: string - ) { - this.abortController = new AbortController(); - } - - /** - * Start collecting events in background. - * Must be called before sending messages to capture all events. - * - * Note: After start() returns, the subscription may not be fully established yet. - * If you need to ensure the subscription is ready before sending messages, - * call waitForSubscription() after start(). - */ - start(): void { - if (this.started) { - throw new Error("StreamCollector already started"); - } - this.started = true; - this.iteratorPromise = this.collectLoop(); - } - - /** - * Wait for the ORPC subscription to be established. - * Call this after start() and before sending messages to avoid race conditions. - */ - async waitForSubscription(timeoutMs: number = 5000): Promise { - if (!this.started) { - throw new Error("StreamCollector not started. Call start() first."); - } - if (this.subscriptionReady) { - return; - } - - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - reject(new Error(`Subscription setup timed out after ${timeoutMs}ms`)); - }, timeoutMs); - - this.subscriptionReadyResolve = () => { - clearTimeout(timer); - resolve(); - }; - - // If already ready (race condition), resolve immediately - if (this.subscriptionReady) { - clearTimeout(timer); - resolve(); - } - }); - } - - /** - * Stop collecting and cleanup. - * Safe to call multiple times. - */ - stop(): void { - if (this.stopped) return; - this.stopped = true; - this.abortController.abort(); - - // Resolve any pending waiters with null - for (const waiter of this.waiters) { - clearTimeout(waiter.timer); - waiter.resolve(null); - } - this.waiters = []; - } - - /** - * Wait for the collector to fully stop. - * Useful for cleanup in tests. - */ - async waitForStop(): Promise { - this.stop(); - if (this.iteratorPromise) { - try { - await this.iteratorPromise; - } catch { - // Ignore abort errors - } - } - } - - /** - * Internal loop that collects events from the ORPC subscription. - */ - private async collectLoop(): Promise { - try { - // ORPC returns an async iterator from the subscription - const iterator = await this.client.workspace.onChat({ workspaceId: this.workspaceId }); - - // Note: The generator body (including onChatEvent subscription) doesn't run until - // we start iterating. We need to pull at least one value to ensure the subscription - // is established, then mark as ready. - let firstEventReceived = false; - - for await (const message of iterator) { - if (this.stopped) break; - - this.events.push(message); - - // After receiving the first event, the subscription is definitely established - if (!firstEventReceived) { - firstEventReceived = true; - this.subscriptionReady = true; - if (this.subscriptionReadyResolve) { - this.subscriptionReadyResolve(); - this.subscriptionReadyResolve = null; - } - } - - // Check if any waiters are satisfied - this.checkWaiters(message); - } - - // If we never received any events, still signal ready to prevent hangs - if (!firstEventReceived) { - this.subscriptionReady = true; - if (this.subscriptionReadyResolve) { - this.subscriptionReadyResolve(); - this.subscriptionReadyResolve = null; - } - } - } catch (error) { - // Ignore abort errors - they're expected when stop() is called - if (error instanceof Error && error.name === "AbortError") { - return; - } - // For other errors, log but don't throw (test will fail on timeout) - if (!this.stopped) { - console.error("[StreamCollector] Error in collect loop:", error); - } - } - } - - /** - * Check if any waiters are satisfied by the new message. - */ - private checkWaiters(message: WorkspaceChatMessage): void { - const msgType = "type" in message ? (message as { type: string }).type : null; - if (!msgType) return; - - const satisfiedIndices: number[] = []; - for (let i = 0; i < this.waiters.length; i++) { - const waiter = this.waiters[i]; - if (waiter.eventType === msgType) { - clearTimeout(waiter.timer); - waiter.resolve(message); - satisfiedIndices.push(i); - } - } - - // Remove satisfied waiters in reverse order to maintain indices - for (let i = satisfiedIndices.length - 1; i >= 0; i--) { - this.waiters.splice(satisfiedIndices[i], 1); - } - } - - /** - * Wait for a specific event type. - * Returns the event if found, or null on timeout. - */ - async waitForEvent( - eventType: string, - timeoutMs: number = 30000 - ): Promise { - if (!this.started) { - throw new Error("StreamCollector not started. Call start() first."); - } - - // First check if we already have the event - const existing = this.events.find( - (e) => "type" in e && (e as { type: string }).type === eventType - ); - if (existing) { - return existing; - } - - // Wait for the event - return new Promise((resolve) => { - const timer = setTimeout(() => { - // Remove this waiter - const idx = this.waiters.findIndex((w) => w.resolve === resolve); - if (idx !== -1) { - this.waiters.splice(idx, 1); - } - // Log diagnostics before returning null - this.logEventDiagnostics(`waitForEvent timeout: Expected "${eventType}"`); - resolve(null); - }, timeoutMs); - - this.waiters.push({ eventType, resolve, timer }); - }); - } - - /** - * Wait for the Nth occurrence of an event type (1-indexed). - * Use this when you expect multiple events of the same type (e.g., second stream-start). - */ - async waitForEventN( - eventType: string, - n: number, - timeoutMs: number = 30000 - ): Promise { - if (!this.started) { - throw new Error("StreamCollector not started. Call start() first."); - } - if (n < 1) { - throw new Error("n must be >= 1"); - } - - // Count existing events of this type - const countExisting = () => - this.events.filter((e) => "type" in e && (e as { type: string }).type === eventType).length; - - // If we already have enough events, return the Nth one - const existing = countExisting(); - if (existing >= n) { - const matches = this.events.filter( - (e) => "type" in e && (e as { type: string }).type === eventType - ); - return matches[n - 1]; - } - - // Poll for the Nth event - return new Promise((resolve) => { - const startTime = Date.now(); - - const check = () => { - if (this.stopped) { - resolve(null); - return; - } - - const matches = this.events.filter( - (e) => "type" in e && (e as { type: string }).type === eventType - ); - if (matches.length >= n) { - resolve(matches[n - 1]); - return; - } - - if (Date.now() - startTime >= timeoutMs) { - this.logEventDiagnostics( - `waitForEventN timeout: Expected ${n}x "${eventType}", got ${matches.length}` - ); - resolve(null); - return; - } - - setTimeout(check, 50); - }; - - check(); - }); - } - - /** - * Get all collected events. - */ - getEvents(): WorkspaceChatMessage[] { - return [...this.events]; - } - - /** - * Clear collected events. - * Useful between test phases. - */ - clear(): void { - this.events = []; - } - - /** - * Get the number of collected events. - */ - get eventCount(): number { - return this.events.length; - } - - /** - * Check if stream completed successfully (has stream-end event). - */ - hasStreamEnd(): boolean { - return this.events.some((e) => "type" in e && e.type === "stream-end"); - } - - /** - * Check if stream had an error. - */ - hasError(): boolean { - return this.events.some((e) => "type" in e && e.type === "stream-error"); - } - - /** - * Get all stream-delta events. - */ - getDeltas(): WorkspaceChatMessage[] { - return this.events.filter((e) => "type" in e && e.type === "stream-delta"); - } - - /** - * Get the final assistant message (from stream-end). - */ - getFinalMessage(): WorkspaceChatMessage | undefined { - return this.events.find((e) => "type" in e && e.type === "stream-end"); - } - - /** - * Get stream deltas concatenated as text. - */ - getStreamContent(): string { - return this.getDeltas() - .map((e) => ("delta" in e ? (e as { delta?: string }).delta || "" : "")) - .join(""); - } - - /** - * Log detailed event diagnostics for debugging. - * Includes timestamps, event types, tool calls, and error details. - */ - logEventDiagnostics(context: string): void { - console.error(`\n${"=".repeat(80)}`); - console.error(`EVENT DIAGNOSTICS: ${context}`); - console.error(`${"=".repeat(80)}`); - console.error(`Workspace: ${this.workspaceId}`); - console.error(`Total events: ${this.events.length}`); - console.error(`\nEvent sequence:`); - - // Log all events with details - this.events.forEach((event, idx) => { - const timestamp = - "timestamp" in event ? new Date(event.timestamp as number).toISOString() : "no-ts"; - const type = "type" in event ? (event as { type: string }).type : "no-type"; - - console.error(` [${idx}] ${timestamp} - ${type}`); - - // Log tool call details - if (type === "tool-call-start" && "toolName" in event) { - console.error(` Tool: ${event.toolName}`); - if ("args" in event) { - console.error(` Args: ${JSON.stringify(event.args)}`); - } - } - - if (type === "tool-call-end" && "toolName" in event) { - console.error(` Tool: ${event.toolName}`); - if ("result" in event) { - const result = - typeof event.result === "string" - ? event.result.length > 100 - ? `${event.result.substring(0, 100)}... (${event.result.length} chars)` - : event.result - : JSON.stringify(event.result); - console.error(` Result: ${result}`); - } - } - - // Log error details - if (type === "stream-error") { - if ("error" in event) { - console.error(` Error: ${event.error}`); - } - if ("errorType" in event) { - console.error(` Error Type: ${event.errorType}`); - } - } - - // Log delta content (first 100 chars) - if (type === "stream-delta" && "delta" in event) { - const delta = - typeof event.delta === "string" - ? event.delta.length > 100 - ? `${event.delta.substring(0, 100)}...` - : event.delta - : JSON.stringify(event.delta); - console.error(` Delta: ${delta}`); - } - - // Log final content (first 200 chars) - if (type === "stream-end" && "content" in event) { - const content = - typeof event.content === "string" - ? event.content.length > 200 - ? `${event.content.substring(0, 200)}... (${event.content.length} chars)` - : event.content - : JSON.stringify(event.content); - console.error(` Content: ${content}`); - } - }); - - // Summary - const eventTypeCounts = this.events.reduce( - (acc, e) => { - const type = "type" in e ? (e as { type: string }).type : "unknown"; - acc[type] = (acc[type] || 0) + 1; - return acc; - }, - {} as Record - ); - - console.error(`\nEvent type counts:`); - Object.entries(eventTypeCounts).forEach(([type, count]) => { - console.error(` ${type}: ${count}`); - }); - - console.error(`${"=".repeat(80)}\n`); - } -} - -/** - * Create a StreamCollector for a workspace. - * Remember to call start() before sending messages. - */ -export function createStreamCollector( - client: OrpcTestClient, - workspaceId: string -): StreamCollector { - return new StreamCollector(client, workspaceId); -} - -/** - * Assert that a stream completed successfully. - * Provides helpful error messages when assertions fail. - */ -export function assertStreamSuccess(collector: StreamCollector): void { - const allEvents = collector.getEvents(); - - // Check for stream-end - if (!collector.hasStreamEnd()) { - const errorEvent = allEvents.find((e) => "type" in e && e.type === "stream-error"); - if (errorEvent && "error" in errorEvent) { - collector.logEventDiagnostics( - `Stream did not complete successfully. Got stream-error: ${errorEvent.error}` - ); - throw new Error( - `Stream did not complete successfully. Got stream-error: ${errorEvent.error}\n` + - `See detailed event diagnostics above.` - ); - } - collector.logEventDiagnostics("Stream did not emit stream-end event"); - throw new Error( - `Stream did not emit stream-end event.\n` + `See detailed event diagnostics above.` - ); - } - - // Check for errors - if (collector.hasError()) { - const errorEvent = allEvents.find((e) => "type" in e && e.type === "stream-error"); - const errorMsg = errorEvent && "error" in errorEvent ? errorEvent.error : "unknown"; - collector.logEventDiagnostics(`Stream completed but also has error event: ${errorMsg}`); - throw new Error( - `Stream completed but also has error event: ${errorMsg}\n` + - `See detailed event diagnostics above.` - ); - } - - // Check for final message - const finalMessage = collector.getFinalMessage(); - if (!finalMessage) { - collector.logEventDiagnostics("Stream completed but final message is missing"); - throw new Error( - `Stream completed but final message is missing.\n` + `See detailed event diagnostics above.` - ); - } -} - -/** - * RAII-style helper that starts a collector, runs a function, and stops the collector. - * Ensures cleanup even if the function throws. - * - * @example - * const events = await withStreamCollection(env.orpc, workspaceId, async (collector) => { - * await sendMessage(env, workspaceId, "hello"); - * await collector.waitForEvent("stream-end", 15000); - * return collector.getEvents(); - * }); - */ -export async function withStreamCollection( - client: OrpcTestClient, - workspaceId: string, - fn: (collector: StreamCollector) => Promise -): Promise { - const collector = createStreamCollector(client, workspaceId); - collector.start(); - try { - return await fn(collector); - } finally { - await collector.waitForStop(); - } -} - -/** - * Wait for stream to complete successfully. - * Common pattern: create collector, wait for end, assert success. - */ -export async function waitForStreamSuccess( - client: OrpcTestClient, - workspaceId: string, - timeoutMs: number = 30000 -): Promise { - const collector = createStreamCollector(client, workspaceId); - collector.start(); - await collector.waitForEvent("stream-end", timeoutMs); - assertStreamSuccess(collector); - return collector; -} - -/** - * Extract text content from stream events. - * Filters for stream-delta events and concatenates the delta text. - */ -export function extractTextFromEvents(events: WorkspaceChatMessage[]): string { - return events - .filter((e: unknown) => { - const typed = e as { type?: string }; - return typed.type === "stream-delta"; - }) - .map((e: unknown) => { - const typed = e as { delta?: string }; - return typed.delta || ""; - }) - .join(""); -} diff --git a/tests/integration/usageDelta.test.ts b/tests/integration/usageDelta.test.ts deleted file mode 100644 index 62da16102..000000000 --- a/tests/integration/usageDelta.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { setupWorkspace, shouldRunIntegrationTests, validateApiKeys } from "./setup"; -import { - sendMessageWithModel, - createStreamCollector, - modelString, - assertStreamSuccess, -} from "./helpers"; -import { isUsageDelta } from "../../src/common/orpc/types"; -import { KNOWN_MODELS } from "../../src/common/constants/knownModels"; - -// Skip all tests if TEST_INTEGRATION is not set -const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip; - -// Validate API keys before running tests -if (shouldRunIntegrationTests()) { - validateApiKeys(["ANTHROPIC_API_KEY"]); -} - -describeIntegration("usage-delta events", () => { - // Enable retries in CI for flaky API tests - if (process.env.CI && typeof jest !== "undefined" && jest.retryTimes) { - jest.retryTimes(3, { logErrorsBeforeRetry: true }); - } - - // Only test with Anthropic - more reliable multi-step behavior - test.concurrent( - "should emit usage-delta events during multi-step tool call streams", - async () => { - const { env, workspaceId, cleanup } = await setupWorkspace("anthropic"); - const collector = createStreamCollector(env.orpc, workspaceId); - collector.start(); - - try { - // Ask the model to read a file - guaranteed to trigger tool use - const result = await sendMessageWithModel( - env, - workspaceId, - "Use the file_read tool to read README.md. Only read the first 5 lines.", - modelString("anthropic", KNOWN_MODELS.SONNET.providerModelId) - ); - - expect(result.success).toBe(true); - - // Wait for stream completion - await collector.waitForEvent("stream-end", 15000); - - // Verify usage-delta events were emitted - const allEvents = collector.getEvents(); - const usageDeltas = allEvents.filter(isUsageDelta); - - // Multi-step stream should emit at least one usage-delta (on finish-step) - expect(usageDeltas.length).toBeGreaterThan(0); - - // Each usage-delta should have valid usage data - for (const delta of usageDeltas) { - expect(delta.usage).toBeDefined(); - // inputTokens should be present and > 0 (full context) - expect(delta.usage.inputTokens).toBeGreaterThan(0); - // outputTokens may be 0 for some steps, but should be defined - expect(typeof delta.usage.outputTokens).toBe("number"); - } - - // Verify stream completed successfully - assertStreamSuccess(collector); - } finally { - collector.stop(); - await cleanup(); - } - }, - 30000 - ); -}); diff --git a/tests/integration/anthropic1MContext.test.ts b/tests/ipcMain/anthropic1MContext.test.ts similarity index 90% rename from tests/integration/anthropic1MContext.test.ts rename to tests/ipcMain/anthropic1MContext.test.ts index 9fed7c567..68b37b059 100644 --- a/tests/integration/anthropic1MContext.test.ts +++ b/tests/ipcMain/anthropic1MContext.test.ts @@ -1,7 +1,7 @@ import { setupWorkspace, shouldRunIntegrationTests, validateApiKeys } from "./setup"; import { sendMessageWithModel, - createStreamCollector, + createEventCollector, assertStreamSuccess, buildLargeHistory, modelString, @@ -15,7 +15,7 @@ if (shouldRunIntegrationTests()) { validateApiKeys(["ANTHROPIC_API_KEY"]); } -describeIntegration("Anthropic 1M context", () => { +describeIntegration("IpcMain anthropic 1M context integration tests", () => { test.concurrent( "should handle larger context with 1M flag enabled vs standard limits", async () => { @@ -33,11 +33,9 @@ describeIntegration("Anthropic 1M context", () => { }); // Phase 1: Try without 1M context flag - should fail with context limit error - const collectorWithout1M = createStreamCollector(env.orpc, workspaceId); - collectorWithout1M.start(); - + env.sentEvents.length = 0; const resultWithout1M = await sendMessageWithModel( - env, + env.mockIpcRenderer, workspaceId, "Summarize the context above in one word.", modelString("anthropic", "claude-sonnet-4-5"), @@ -52,6 +50,7 @@ describeIntegration("Anthropic 1M context", () => { expect(resultWithout1M.success).toBe(true); + const collectorWithout1M = createEventCollector(env.sentEvents, workspaceId); const resultType = await Promise.race([ collectorWithout1M.waitForEvent("stream-end", 30000).then(() => "success"), collectorWithout1M.waitForEvent("stream-error", 30000).then(() => "error"), @@ -64,15 +63,12 @@ describeIntegration("Anthropic 1M context", () => { .find((e) => "type" in e && e.type === "stream-error") as { error: string } | undefined; expect(errorEvent).toBeDefined(); expect(errorEvent!.error).toMatch(/too long|200000|maximum/i); - collectorWithout1M.stop(); // Phase 2: Try WITH 1M context flag // Should handle the large context better with beta header - const collectorWith1M = createStreamCollector(env.orpc, workspaceId); - collectorWith1M.start(); - + env.sentEvents.length = 0; const resultWith1M = await sendMessageWithModel( - env, + env.mockIpcRenderer, workspaceId, "Summarize the context above in one word.", modelString("anthropic", "claude-sonnet-4-5"), @@ -87,6 +83,7 @@ describeIntegration("Anthropic 1M context", () => { expect(resultWith1M.success).toBe(true); + const collectorWith1M = createEventCollector(env.sentEvents, workspaceId); await collectorWith1M.waitForEvent("stream-end", 30000); // With 1M context, should succeed @@ -105,7 +102,6 @@ describeIntegration("Anthropic 1M context", () => { // Should have some content (proves it processed the request) expect(content.length).toBeGreaterThan(0); } - collectorWith1M.stop(); } finally { await cleanup(); } diff --git a/tests/ipcMain/anthropicCacheStrategy.test.ts b/tests/ipcMain/anthropicCacheStrategy.test.ts new file mode 100644 index 000000000..bd8d710e3 --- /dev/null +++ b/tests/ipcMain/anthropicCacheStrategy.test.ts @@ -0,0 +1,88 @@ +import { setupWorkspace, shouldRunIntegrationTests } from "./setup"; +import { sendMessageWithModel, waitForStreamSuccess } from "./helpers"; + +// Skip tests unless TEST_INTEGRATION=1 AND required API keys are present +const hasAnthropicKey = Boolean(process.env.ANTHROPIC_API_KEY); +const shouldRunSuite = shouldRunIntegrationTests() && hasAnthropicKey; +const describeIntegration = shouldRunSuite ? describe : describe.skip; +const TEST_TIMEOUT_MS = 45000; // 45s total: setup + 2 messages at 15s each + +if (shouldRunIntegrationTests() && !shouldRunSuite) { + // eslint-disable-next-line no-console + console.warn("Skipping Anthropic cache strategy integration tests: missing ANTHROPIC_API_KEY"); +} + +describeIntegration("Anthropic cache strategy integration", () => { + test( + "should apply cache control to messages, system prompt, and tools for Anthropic models", + async () => { + const { env, workspaceId, cleanup } = await setupWorkspace("anthropic"); + + try { + const model = "anthropic:claude-haiku-4-5"; + + // Send an initial message to establish conversation history + const firstMessage = "Hello, can you help me with a coding task?"; + await sendMessageWithModel(env.mockIpcRenderer, workspaceId, firstMessage, model, { + additionalSystemInstructions: "Be concise and clear in your responses.", + thinkingLevel: "off", + }); + const firstCollector = await waitForStreamSuccess(env.sentEvents, workspaceId, 15000); + + // Send a second message to test cache reuse + const secondMessage = "What's the best way to handle errors in TypeScript?"; + await sendMessageWithModel(env.mockIpcRenderer, workspaceId, secondMessage, model, { + additionalSystemInstructions: "Be concise and clear in your responses.", + thinkingLevel: "off", + }); + const secondCollector = await waitForStreamSuccess(env.sentEvents, workspaceId, 15000); + + // Check that both streams completed successfully + const firstEndEvent = firstCollector.getEvents().find((e: any) => e.type === "stream-end"); + const secondEndEvent = secondCollector + .getEvents() + .find((e: any) => e.type === "stream-end"); + expect(firstEndEvent).toBeDefined(); + expect(secondEndEvent).toBeDefined(); + + // Verify cache control is being applied by checking the messages sent to the model + // Cache control adds cache_control markers to messages, system, and tools + // If usage data is available from the API, verify it; otherwise just ensure requests succeeded + const firstUsage = (firstEndEvent as any)?.metadata?.usage; + const firstProviderMetadata = (firstEndEvent as any)?.metadata?.providerMetadata?.anthropic; + const secondUsage = (secondEndEvent as any)?.metadata?.usage; + + // Verify cache creation - this proves our cache strategy is working + // We only check cache creation, not usage, because: + // 1. Cache has a warmup period (~5 min) before it can be read + // 2. What matters is that we're sending cache control headers correctly + // 3. If cache creation is happening, the strategy is working + const hasCacheCreation = + firstProviderMetadata?.cacheCreationInputTokens !== undefined && + firstProviderMetadata.cacheCreationInputTokens > 0; + + if (hasCacheCreation) { + // Success: Cache control headers are working + expect(firstProviderMetadata.cacheCreationInputTokens).toBeGreaterThan(0); + console.log( + `✓ Cache creation working: ${firstProviderMetadata.cacheCreationInputTokens} tokens cached` + ); + } else if (firstUsage && Object.keys(firstUsage).length > 0) { + // API returned usage data but no cache creation + // This shouldn't happen if cache control is working properly + throw new Error( + "Expected cache creation but got 0 tokens. Cache control may not be working." + ); + } else { + // No usage data from API (e.g., custom bridge that doesn't report metrics) + // Just ensure both requests completed successfully + console.log("Note: API did not return usage data. Skipping cache metrics verification."); + console.log("Test passes - both messages completed successfully."); + } + } finally { + await cleanup(); + } + }, + TEST_TIMEOUT_MS + ); +}); diff --git a/tests/integration/createWorkspace.test.ts b/tests/ipcMain/createWorkspace.test.ts similarity index 79% rename from tests/integration/createWorkspace.test.ts rename to tests/ipcMain/createWorkspace.test.ts index 3b0596432..edf044640 100644 --- a/tests/integration/createWorkspace.test.ts +++ b/tests/ipcMain/createWorkspace.test.ts @@ -16,13 +16,8 @@ import { exec } from "child_process"; import { promisify } from "util"; import { shouldRunIntegrationTests, createTestEnvironment, cleanupTestEnvironment } from "./setup"; import type { TestEnvironment } from "./setup"; -import { - createTempGitRepo, - cleanupTempGitRepo, - generateBranchName, - createStreamCollector, -} from "./helpers"; -import type { OrpcTestClient } from "./orpcTestClient"; +import { IPC_CHANNELS } from "../../src/common/constants/ipc-constants"; +import { createTempGitRepo, cleanupTempGitRepo, generateBranchName } from "./helpers"; import { detectDefaultTrunkBranch } from "../../src/node/git"; import { isDockerAvailable, @@ -40,22 +35,13 @@ const execAsync = promisify(exec); // Test constants const TEST_TIMEOUT_MS = 60000; -type ExecuteBashResult = Awaited>; - -function expectExecuteBashSuccess(result: ExecuteBashResult, context: string) { - expect(result.success).toBe(true); - if (!result.success || !result.data) { - const errorMessage = "error" in result ? result.error : "unknown error"; - throw new Error(`workspace.executeBash failed (${context}): ${errorMessage}`); - } - return result.data; -} const INIT_HOOK_WAIT_MS = 1500; // Wait for async init hook completion (local runtime) const SSH_INIT_WAIT_MS = 7000; // SSH init includes sync + checkout + hook, takes longer const MUX_DIR = ".mux"; const INIT_HOOK_FILENAME = "init"; // Event type constants +const EVENT_PREFIX_WORKSPACE_CHAT = "workspace:chat:"; const EVENT_TYPE_PREFIX_INIT = "init-"; const EVENT_TYPE_INIT_OUTPUT = "init-output"; const EVENT_TYPE_INIT_END = "init-end"; @@ -84,26 +70,34 @@ function isInitEvent(data: unknown): data is { type: string } { } /** - * Filter events by type. - * Works with WorkspaceChatMessage events from StreamCollector. + * Filter events by type */ -function filterEventsByType(events: T[], eventType: string): T[] { - return events.filter((e) => { - if (e && typeof e === "object" && "type" in e) { - return (e as { type: string }).type === eventType; - } - return false; - }); +function filterEventsByType( + events: Array<{ channel: string; data: unknown }>, + eventType: string +): Array<{ channel: string; data: { type: string } }> { + return events.filter((e) => isInitEvent(e.data) && e.data.type === eventType) as Array<{ + channel: string; + data: { type: string }; + }>; } /** - * Set up init event capture using StreamCollector. - * Init events are captured via ORPC subscription. + * Set up event capture for init events on workspace chat channel + * Returns array that will be populated with captured events */ -async function setupInitEventCapture(env: TestEnvironment, workspaceId: string) { - const collector = createStreamCollector(env.orpc, workspaceId); - collector.start(); - return collector; +function setupInitEventCapture(env: TestEnvironment): Array<{ channel: string; data: unknown }> { + const capturedEvents: Array<{ channel: string; data: unknown }> = []; + const originalSend = env.mockWindow.webContents.send; + + env.mockWindow.webContents.send = ((channel: string, data: unknown) => { + if (channel.startsWith(EVENT_PREFIX_WORKSPACE_CHAT) && isInitEvent(data)) { + capturedEvents.push({ channel, data }); + } + originalSend.call(env.mockWindow.webContents, channel, data); + }) as typeof originalSend; + + return capturedEvents; } /** @@ -141,21 +135,17 @@ async function createWorkspaceWithCleanup( | { success: false; error: string }; cleanup: () => Promise; }> { - const result = await env.orpc.workspace.create({ + const result = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_CREATE, projectPath, branchName, trunkBranch, - runtimeConfig, - }); - console.log("Create invoked, success:", result.success); - - // Note: Events are forwarded via test setup wiring in setup.ts: - // workspaceService.on("chat") -> windowService.send() -> webContents.send() - // No need for additional ORPC subscription pipe here. + runtimeConfig + ); const cleanup = async () => { if (result.success) { - await env.orpc.workspace.remove({ workspaceId: result.metadata.id }); + await env.mockIpcRenderer.invoke(IPC_CHANNELS.WORKSPACE_REMOVE, result.metadata.id); } }; @@ -335,34 +325,34 @@ describeIntegration("WORKSPACE_CREATE with both runtimes", () => { // Use WORKSPACE_EXECUTE_BASH to check files (works for both local and SSH runtimes) // Check that trunk-file.txt exists (from custom-trunk) - const checkTrunkFileResult = await env.orpc.workspace.executeBash({ - workspaceId: result.metadata.id, - script: `test -f trunk-file.txt && echo "exists" || echo "missing"`, - }); - const trunkFileData = expectExecuteBashSuccess( - checkTrunkFileResult, - "custom trunk: trunk-file" + const checkTrunkFileResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_EXECUTE_BASH, + result.metadata.id, + `test -f trunk-file.txt && echo "exists" || echo "missing"` ); - expect((trunkFileData.output ?? "").trim()).toBe("exists"); + expect(checkTrunkFileResult.success).toBe(true); + expect(checkTrunkFileResult.data.success).toBe(true); + expect(checkTrunkFileResult.data.output.trim()).toBe("exists"); // Check that other-file.txt does NOT exist (from other-branch) - const checkOtherFileResult = await env.orpc.workspace.executeBash({ - workspaceId: result.metadata.id, - script: `test -f other-file.txt && echo "exists" || echo "missing"`, - }); - const otherFileData = expectExecuteBashSuccess( - checkOtherFileResult, - "custom trunk: other-file" + const checkOtherFileResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_EXECUTE_BASH, + result.metadata.id, + `test -f other-file.txt && echo "exists" || echo "missing"` ); - expect((otherFileData.output ?? "").trim()).toBe("missing"); + expect(checkOtherFileResult.success).toBe(true); + expect(checkOtherFileResult.data.success).toBe(true); + expect(checkOtherFileResult.data.output.trim()).toBe("missing"); // Verify git log shows the custom trunk commit - const gitLogResult = await env.orpc.workspace.executeBash({ - workspaceId: result.metadata.id, - script: `git log --oneline --all`, - }); - const gitLogData = expectExecuteBashSuccess(gitLogResult, "custom trunk: git log"); - expect(gitLogData.output).toContain("Custom trunk commit"); + const gitLogResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_EXECUTE_BASH, + result.metadata.id, + `git log --oneline --all` + ); + expect(gitLogResult.success).toBe(true); + expect(gitLogResult.data.success).toBe(true); + expect(gitLogResult.data.output).toContain("Custom trunk commit"); await cleanup(); } finally { @@ -399,6 +389,9 @@ exit 0 const trunkBranch = await detectDefaultTrunkBranch(tempGitRepo); const runtimeConfig = getRuntimeConfig(branchName); + // Capture init events + const initEvents = setupInitEventCapture(env); + const { result, cleanup } = await createWorkspaceWithCleanup( env, tempGitRepo, @@ -412,29 +405,19 @@ exit 0 throw new Error(`Failed to create workspace with init hook: ${result.error}`); } - // Capture init events - subscription starts after workspace created - // Init hook runs async, so events still streaming - const workspaceId = result.metadata.id; - const collector = await setupInitEventCapture(env, workspaceId); - try { - // Wait for init hook to complete - await collector.waitForEvent("init-end", getInitWaitTime()); + // Wait for init hook to complete (runs asynchronously after workspace creation) + await new Promise((resolve) => setTimeout(resolve, getInitWaitTime())); - const initEvents = collector.getEvents(); + // Verify init events were emitted + expect(initEvents.length).toBeGreaterThan(0); - // Verify init events were emitted - expect(initEvents.length).toBeGreaterThan(0); + // Verify output events (stdout/stderr from hook) + const outputEvents = filterEventsByType(initEvents, EVENT_TYPE_INIT_OUTPUT); + expect(outputEvents.length).toBeGreaterThan(0); - // Verify output events (stdout/stderr from hook) - const outputEvents = filterEventsByType(initEvents, EVENT_TYPE_INIT_OUTPUT); - expect(outputEvents.length).toBeGreaterThan(0); - - // Verify completion event - const endEvents = filterEventsByType(initEvents, EVENT_TYPE_INIT_END); - expect(endEvents.length).toBe(1); - } finally { - collector.stop(); - } + // Verify completion event + const endEvents = filterEventsByType(initEvents, EVENT_TYPE_INIT_END); + expect(endEvents.length).toBe(1); await cleanup(); } finally { @@ -467,6 +450,9 @@ exit 1 const trunkBranch = await detectDefaultTrunkBranch(tempGitRepo); const runtimeConfig = getRuntimeConfig(branchName); + // Capture init events + const initEvents = setupInitEventCapture(env); + const { result, cleanup } = await createWorkspaceWithCleanup( env, tempGitRepo, @@ -481,25 +467,16 @@ exit 1 throw new Error(`Failed to create workspace with failing hook: ${result.error}`); } - // Capture init events - subscription starts after workspace created - const workspaceId = result.metadata.id; - const collector = await setupInitEventCapture(env, workspaceId); - try { - // Wait for init hook to complete - await collector.waitForEvent("init-end", getInitWaitTime()); - - const initEvents = collector.getEvents(); + // Wait for init hook to complete asynchronously + await new Promise((resolve) => setTimeout(resolve, getInitWaitTime())); - // Verify init-end event with non-zero exit code - const endEvents = filterEventsByType(initEvents, EVENT_TYPE_INIT_END); - expect(endEvents.length).toBe(1); + // Verify init-end event with non-zero exit code + const endEvents = filterEventsByType(initEvents, EVENT_TYPE_INIT_END); + expect(endEvents.length).toBe(1); - const endEventData = endEvents[0] as { type: string; exitCode: number }; - expect(endEventData.exitCode).not.toBe(0); - // Exit code can be 1 (script failure) or 127 (command not found on some systems) - } finally { - collector.stop(); - } + const endEventData = endEvents[0].data as { type: string; exitCode: number }; + expect(endEventData.exitCode).not.toBe(0); + // Exit code can be 1 (script failure) or 127 (command not found on some systems) await cleanup(); } finally { @@ -558,6 +535,9 @@ exit 1 const trunkBranch = await detectDefaultTrunkBranch(tempGitRepo); const runtimeConfig = getRuntimeConfig(branchName); + // Capture init events + const initEvents = setupInitEventCapture(env); + const { result, cleanup } = await createWorkspaceWithCleanup( env, tempGitRepo, @@ -571,45 +551,34 @@ exit 1 throw new Error(`Failed to create workspace for sync test: ${result.error}`); } - // Capture init events - subscription starts after workspace created - const workspaceId = result.metadata.id; - const collector = await setupInitEventCapture(env, workspaceId); - try { - // Wait for init to complete (includes sync + checkout) - await collector.waitForEvent("init-end", getInitWaitTime()); - - const allEvents = collector.getEvents(); - - // Verify init events contain sync and checkout steps - const outputEvents = filterEventsByType(allEvents, EVENT_TYPE_INIT_OUTPUT); - const outputLines = outputEvents.map((e) => { - const data = e as { line?: string; isError?: boolean }; - return data.line ?? ""; - }); - - // Debug: Print all output including errors - console.log("=== ALL INIT OUTPUT ==="); - outputEvents.forEach((e) => { - const data = e as { line?: string; isError?: boolean }; - const prefix = data.isError ? "[ERROR]" : "[INFO] "; - console.log(prefix + (data.line ?? "")); - }); - console.log("=== END INIT OUTPUT ==="); - - // Verify key init phases appear in output - expect(outputLines.some((line) => line.includes("Syncing project files"))).toBe( - true - ); - expect(outputLines.some((line) => line.includes("Checking out branch"))).toBe( - true - ); - - // Verify init-end event was emitted - const endEvents = filterEventsByType(allEvents, EVENT_TYPE_INIT_END); - expect(endEvents.length).toBe(1); - } finally { - collector.stop(); - } + // Wait for init to complete (includes sync + checkout) + await new Promise((resolve) => setTimeout(resolve, getInitWaitTime())); + + // Verify init events contain sync and checkout steps + const outputEvents = filterEventsByType(initEvents, EVENT_TYPE_INIT_OUTPUT); + const outputLines = outputEvents.map((e) => { + const data = e.data as { line?: string; isError?: boolean }; + return data.line ?? ""; + }); + + // Debug: Print all output including errors + console.log("=== ALL INIT OUTPUT ==="); + outputEvents.forEach((e) => { + const data = e.data as { line?: string; isError?: boolean }; + const prefix = data.isError ? "[ERROR]" : "[INFO] "; + console.log(prefix + (data.line ?? "")); + }); + console.log("=== END INIT OUTPUT ==="); + + // Verify key init phases appear in output + expect(outputLines.some((line) => line.includes("Syncing project files"))).toBe( + true + ); + expect(outputLines.some((line) => line.includes("Checking out branch"))).toBe(true); + + // Verify init-end event was emitted + const endEvents = filterEventsByType(initEvents, EVENT_TYPE_INIT_END); + expect(endEvents.length).toBe(1); await cleanup(); } finally { @@ -763,16 +732,21 @@ exit 1 // Try to execute a command in the workspace const workspaceId = result.metadata.id; - const execResult = await env.orpc.workspace.executeBash({ + const execResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_EXECUTE_BASH, workspaceId, - script: "pwd", - }); + "pwd" + ); - const execData = expectExecuteBashSuccess(execResult, "SSH immediate command"); + expect(execResult.success).toBe(true); + if (!execResult.success) { + throw new Error(`Failed to exec in workspace: ${execResult.error}`); + } // Verify we got output from the command - expect(execData.output).toBeDefined(); - expect(execData.output?.trim().length ?? 0).toBeGreaterThan(0); + expect(execResult.data).toBeDefined(); + expect(execResult.data.output).toBeDefined(); + expect(execResult.data.output!.trim().length).toBeGreaterThan(0); await cleanup(); } finally { diff --git a/tests/integration/doubleRegister.test.ts b/tests/ipcMain/doubleRegister.test.ts similarity index 56% rename from tests/integration/doubleRegister.test.ts rename to tests/ipcMain/doubleRegister.test.ts index 960c9a673..4c8290d73 100644 --- a/tests/integration/doubleRegister.test.ts +++ b/tests/ipcMain/doubleRegister.test.ts @@ -1,24 +1,24 @@ import { shouldRunIntegrationTests, createTestEnvironment, cleanupTestEnvironment } from "./setup"; -import { resolveOrpcClient } from "./helpers"; +import { IPC_CHANNELS } from "../../src/common/constants/ipc-constants"; const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip; -describeIntegration("Service double registration", () => { +describeIntegration("IpcMain double registration", () => { test.concurrent( "should not throw when register() is called multiple times", async () => { const env = await createTestEnvironment(); try { - // First setMainWindow already happened in createTestEnvironment() + // First register() already happened in createTestEnvironment() // Second call simulates window recreation (e.g., macOS activate event) expect(() => { - env.services.windowService.setMainWindow(env.mockWindow); + env.ipcMain.register(env.mockIpcMain, env.mockWindow); }).not.toThrow(); - // Verify handlers still work after second registration using ORPC client - const client = resolveOrpcClient(env); - const projectsList = await client.projects.list(); + // Verify handlers still work after second registration + const projectsList = await env.mockIpcRenderer.invoke(IPC_CHANNELS.PROJECT_LIST); + expect(projectsList).toBeDefined(); expect(Array.isArray(projectsList)).toBe(true); } finally { await cleanupTestEnvironment(env); @@ -36,17 +36,17 @@ describeIntegration("Service double registration", () => { // Multiple calls should be safe (window can be recreated on macOS) for (let i = 0; i < 3; i++) { expect(() => { - env.services.windowService.setMainWindow(env.mockWindow); + env.ipcMain.register(env.mockIpcMain, env.mockWindow); }).not.toThrow(); } - // Verify handlers still work via ORPC client - const client = resolveOrpcClient(env); - const projectsList = await client.projects.list(); + // Verify handlers still work + const projectsList = await env.mockIpcRenderer.invoke(IPC_CHANNELS.PROJECT_LIST); + expect(projectsList).toBeDefined(); expect(Array.isArray(projectsList)).toBe(true); - const workspaces = await client.workspace.list(); - expect(Array.isArray(workspaces)).toBe(true); + const listResult = await env.mockIpcRenderer.invoke(IPC_CHANNELS.WORKSPACE_LIST); + expect(Array.isArray(listResult)).toBe(true); } finally { await cleanupTestEnvironment(env); } diff --git a/tests/integration/executeBash.test.ts b/tests/ipcMain/executeBash.test.ts similarity index 64% rename from tests/integration/executeBash.test.ts rename to tests/ipcMain/executeBash.test.ts index 754a8f8c4..22750eef2 100644 --- a/tests/integration/executeBash.test.ts +++ b/tests/ipcMain/executeBash.test.ts @@ -1,6 +1,6 @@ import { shouldRunIntegrationTests, createTestEnvironment, cleanupTestEnvironment } from "./setup"; +import { IPC_CHANNELS } from "../../src/common/constants/ipc-constants"; import { createTempGitRepo, cleanupTempGitRepo, createWorkspace } from "./helpers"; -import { resolveOrpcClient } from "./helpers"; import type { WorkspaceMetadata } from "../../src/common/types/workspace"; type WorkspaceCreationResult = Awaited>; @@ -16,7 +16,7 @@ function expectWorkspaceCreationSuccess(result: WorkspaceCreationResult): Worksp // Skip all tests if TEST_INTEGRATION is not set const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip; -describeIntegration("executeBash", () => { +describeIntegration("IpcMain executeBash integration tests", () => { test.concurrent( "should execute bash command in workspace context", async () => { @@ -25,23 +25,25 @@ describeIntegration("executeBash", () => { try { // Create a workspace - const createResult = await createWorkspace(env, tempGitRepo, "test-bash"); + const createResult = await createWorkspace(env.mockIpcRenderer, tempGitRepo, "test-bash"); const metadata = expectWorkspaceCreationSuccess(createResult); const workspaceId = metadata.id; - const client = resolveOrpcClient(env); // Execute a simple bash command (pwd should return workspace path) - const pwdResult = await client.workspace.executeBash({ workspaceId, script: "pwd" }); + const pwdResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_EXECUTE_BASH, + workspaceId, + "pwd" + ); expect(pwdResult.success).toBe(true); - if (!pwdResult.success) return; expect(pwdResult.data.success).toBe(true); // Verify pwd output contains the workspace name (directories are named with workspace names) expect(pwdResult.data.output).toContain(metadata.name); expect(pwdResult.data.exitCode).toBe(0); // Clean up - await client.workspace.remove({ workspaceId }); + await env.mockIpcRenderer.invoke(IPC_CHANNELS.WORKSPACE_REMOVE, workspaceId); } finally { await cleanupTestEnvironment(env); await cleanupTempGitRepo(tempGitRepo); @@ -58,24 +60,27 @@ describeIntegration("executeBash", () => { try { // Create a workspace - const createResult = await createWorkspace(env, tempGitRepo, "test-git-status"); + const createResult = await createWorkspace( + env.mockIpcRenderer, + tempGitRepo, + "test-git-status" + ); const workspaceId = expectWorkspaceCreationSuccess(createResult).id; - const client = resolveOrpcClient(env); // Execute git status - const gitStatusResult = await client.workspace.executeBash({ + const gitStatusResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_EXECUTE_BASH, workspaceId, - script: "git status", - }); + "git status" + ); expect(gitStatusResult.success).toBe(true); - if (!gitStatusResult.success) return; expect(gitStatusResult.data.success).toBe(true); expect(gitStatusResult.data.output).toContain("On branch"); expect(gitStatusResult.data.exitCode).toBe(0); // Clean up - await client.workspace.remove({ workspaceId }); + await env.mockIpcRenderer.invoke(IPC_CHANNELS.WORKSPACE_REMOVE, workspaceId); } finally { await cleanupTestEnvironment(env); await cleanupTempGitRepo(tempGitRepo); @@ -92,26 +97,27 @@ describeIntegration("executeBash", () => { try { // Create a workspace - const createResult = await createWorkspace(env, tempGitRepo, "test-failure"); + const createResult = await createWorkspace( + env.mockIpcRenderer, + tempGitRepo, + "test-failure" + ); const workspaceId = expectWorkspaceCreationSuccess(createResult).id; - const client = resolveOrpcClient(env); // Execute a command that will fail - const failResult = await client.workspace.executeBash({ + const failResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_EXECUTE_BASH, workspaceId, - script: "exit 42", - }); + "exit 42" + ); expect(failResult.success).toBe(true); - if (!failResult.success) return; expect(failResult.data.success).toBe(false); - if (!failResult.data.success) { - expect(failResult.data.exitCode).toBe(42); - expect(failResult.data.error).toContain("exited with code 42"); - } + expect(failResult.data.exitCode).toBe(42); + expect(failResult.data.error).toContain("exited with code 42"); // Clean up - await client.workspace.remove({ workspaceId }); + await env.mockIpcRenderer.invoke(IPC_CHANNELS.WORKSPACE_REMOVE, workspaceId); } finally { await cleanupTestEnvironment(env); await cleanupTempGitRepo(tempGitRepo); @@ -128,26 +134,27 @@ describeIntegration("executeBash", () => { try { // Create a workspace - const createResult = await createWorkspace(env, tempGitRepo, "test-timeout"); + const createResult = await createWorkspace( + env.mockIpcRenderer, + tempGitRepo, + "test-timeout" + ); const workspaceId = expectWorkspaceCreationSuccess(createResult).id; - const client = resolveOrpcClient(env); // Execute a command that takes longer than the timeout - const timeoutResult = await client.workspace.executeBash({ + const timeoutResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_EXECUTE_BASH, workspaceId, - script: "while true; do sleep 0.1; done", - options: { timeout_secs: 1 }, - }); + "while true; do sleep 0.1; done", + { timeout_secs: 1 } + ); expect(timeoutResult.success).toBe(true); - if (!timeoutResult.success) return; expect(timeoutResult.data.success).toBe(false); - if (!timeoutResult.data.success) { - expect(timeoutResult.data.error).toContain("timeout"); - } + expect(timeoutResult.data.error).toContain("timeout"); // Clean up - await client.workspace.remove({ workspaceId }); + await env.mockIpcRenderer.invoke(IPC_CHANNELS.WORKSPACE_REMOVE, workspaceId); } finally { await cleanupTestEnvironment(env); await cleanupTempGitRepo(tempGitRepo); @@ -164,18 +171,21 @@ describeIntegration("executeBash", () => { try { // Create a workspace - const createResult = await createWorkspace(env, tempGitRepo, "test-large-output"); + const createResult = await createWorkspace( + env.mockIpcRenderer, + tempGitRepo, + "test-large-output" + ); const workspaceId = expectWorkspaceCreationSuccess(createResult).id; - const client = resolveOrpcClient(env); // Execute a command that generates 400 lines (well under 10K limit for IPC truncate policy) - const result = await client.workspace.executeBash({ + const result = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_EXECUTE_BASH, workspaceId, - script: "for i in {1..400}; do echo line$i; done", - }); + "for i in {1..400}; do echo line$i; done" + ); expect(result.success).toBe(true); - if (!result.success) return; expect(result.data.success).toBe(true); expect(result.data.exitCode).toBe(0); // Should return all 400 lines without truncation @@ -185,7 +195,7 @@ describeIntegration("executeBash", () => { expect(result.data.truncated).toBeUndefined(); // Clean up - await client.workspace.remove({ workspaceId }); + await env.mockIpcRenderer.invoke(IPC_CHANNELS.WORKSPACE_REMOVE, workspaceId); } finally { await cleanupTestEnvironment(env); await cleanupTempGitRepo(tempGitRepo); @@ -201,14 +211,13 @@ describeIntegration("executeBash", () => { try { // Execute bash command with non-existent workspace ID - const client = resolveOrpcClient(env); - const result = await client.workspace.executeBash({ - workspaceId: "nonexistent-workspace", - script: "echo test", - }); + const result = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_EXECUTE_BASH, + "nonexistent-workspace", + "echo test" + ); expect(result.success).toBe(false); - if (result.success) return; expect(result.error).toContain("Failed to get workspace metadata"); } finally { await cleanupTestEnvironment(env); @@ -225,34 +234,34 @@ describeIntegration("executeBash", () => { try { // Create a workspace - const createResult = await createWorkspace(env, tempGitRepo, "test-secrets"); + const createResult = await createWorkspace( + env.mockIpcRenderer, + tempGitRepo, + "test-secrets" + ); const workspaceId = expectWorkspaceCreationSuccess(createResult).id; - const client = resolveOrpcClient(env); // Set secrets for the project - await client.projects.secrets.update({ - projectPath: tempGitRepo, - secrets: [ - { key: "TEST_SECRET_KEY", value: "secret_value_123" }, - { key: "ANOTHER_SECRET", value: "another_value_456" }, - ], - }); + await env.mockIpcRenderer.invoke(IPC_CHANNELS.PROJECT_SECRETS_UPDATE, tempGitRepo, [ + { key: "TEST_SECRET_KEY", value: "secret_value_123" }, + { key: "ANOTHER_SECRET", value: "another_value_456" }, + ]); // Execute bash command that reads the environment variables - const echoResult = await client.workspace.executeBash({ + const echoResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_EXECUTE_BASH, workspaceId, - script: 'echo "KEY=$TEST_SECRET_KEY ANOTHER=$ANOTHER_SECRET"', - }); + 'echo "KEY=$TEST_SECRET_KEY ANOTHER=$ANOTHER_SECRET"' + ); expect(echoResult.success).toBe(true); - if (!echoResult.success) return; expect(echoResult.data.success).toBe(true); expect(echoResult.data.output).toContain("KEY=secret_value_123"); expect(echoResult.data.output).toContain("ANOTHER=another_value_456"); expect(echoResult.data.exitCode).toBe(0); // Clean up - await client.workspace.remove({ workspaceId }); + await env.mockIpcRenderer.invoke(IPC_CHANNELS.WORKSPACE_REMOVE, workspaceId); } finally { await cleanupTestEnvironment(env); await cleanupTempGitRepo(tempGitRepo); @@ -269,54 +278,54 @@ describeIntegration("executeBash", () => { try { // Create a workspace - const createResult = await createWorkspace(env, tempGitRepo, "test-git-env"); + const createResult = await createWorkspace( + env.mockIpcRenderer, + tempGitRepo, + "test-git-env" + ); const workspaceId = expectWorkspaceCreationSuccess(createResult).id; - const client = resolveOrpcClient(env); // Verify GIT_TERMINAL_PROMPT is set to 0 - const gitEnvResult = await client.workspace.executeBash({ + const gitEnvResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_EXECUTE_BASH, workspaceId, - script: 'echo "GIT_TERMINAL_PROMPT=$GIT_TERMINAL_PROMPT"', - }); + 'echo "GIT_TERMINAL_PROMPT=$GIT_TERMINAL_PROMPT"' + ); expect(gitEnvResult.success).toBe(true); - if (!gitEnvResult.success) return; expect(gitEnvResult.data.success).toBe(true); - if (gitEnvResult.data.success) { - expect(gitEnvResult.data.output).toContain("GIT_TERMINAL_PROMPT=0"); - expect(gitEnvResult.data.exitCode).toBe(0); - } + expect(gitEnvResult.data.output).toContain("GIT_TERMINAL_PROMPT=0"); + expect(gitEnvResult.data.exitCode).toBe(0); // Test 1: Verify that git fetch with invalid remote doesn't hang (should fail quickly) - const invalidFetchResult = await client.workspace.executeBash({ + const invalidFetchResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_EXECUTE_BASH, workspaceId, - script: - "git fetch https://invalid-remote-that-does-not-exist-12345.com/repo.git 2>&1 || true", - options: { timeout_secs: 5 }, - }); + "git fetch https://invalid-remote-that-does-not-exist-12345.com/repo.git 2>&1 || true", + { timeout_secs: 5 } + ); expect(invalidFetchResult.success).toBe(true); - if (!invalidFetchResult.success) return; expect(invalidFetchResult.data.success).toBe(true); // Test 2: Verify git fetch to real GitHub org repo doesn't hang // Uses OpenAI org - will fail if no auth configured, but should fail quickly without prompting - const githubFetchResult = await client.workspace.executeBash({ + const githubFetchResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_EXECUTE_BASH, workspaceId, - script: "git fetch https://github.com/openai/private-test-repo-nonexistent 2>&1 || true", - options: { timeout_secs: 5 }, - }); + "git fetch https://github.com/openai/private-test-repo-nonexistent 2>&1 || true", + { timeout_secs: 5 } + ); // Should complete quickly (not hang waiting for credentials) expect(githubFetchResult.success).toBe(true); - if (!githubFetchResult.success) return; // Command should complete within timeout - the "|| true" ensures success even if fetch fails expect(githubFetchResult.data.success).toBe(true); // Output should contain error message, not hang expect(githubFetchResult.data.output).toContain("fatal"); // Clean up - await client.workspace.remove({ workspaceId }); + await env.mockIpcRenderer.invoke(IPC_CHANNELS.WORKSPACE_REMOVE, workspaceId); } finally { await cleanupTestEnvironment(env); await cleanupTempGitRepo(tempGitRepo); diff --git a/tests/integration/forkWorkspace.test.ts b/tests/ipcMain/forkWorkspace.test.ts similarity index 74% rename from tests/integration/forkWorkspace.test.ts rename to tests/ipcMain/forkWorkspace.test.ts index d96c56f04..e51490713 100644 --- a/tests/integration/forkWorkspace.test.ts +++ b/tests/ipcMain/forkWorkspace.test.ts @@ -5,14 +5,15 @@ import { setupWorkspace, validateApiKeys, } from "./setup"; +import { IPC_CHANNELS } from "../../src/common/constants/ipc-constants"; import { createTempGitRepo, cleanupTempGitRepo, sendMessageWithModel, - createStreamCollector, + createEventCollector, assertStreamSuccess, + waitFor, modelString, - resolveOrpcClient, } from "./helpers"; import { detectDefaultTrunkBranch } from "../../src/node/git"; import { HistoryService } from "../../src/node/services/historyService"; @@ -26,7 +27,7 @@ if (shouldRunIntegrationTests()) { validateApiKeys(["ANTHROPIC_API_KEY"]); } -describeIntegration("Workspace fork", () => { +describeIntegration("IpcMain fork workspace integration tests", () => { test.concurrent( "should fail to fork workspace with invalid name", async () => { @@ -36,14 +37,13 @@ describeIntegration("Workspace fork", () => { try { // Create source workspace const trunkBranch = await detectDefaultTrunkBranch(tempGitRepo); - const client = resolveOrpcClient(env); - const createResult = await client.workspace.create({ - projectPath: tempGitRepo, - branchName: "source-workspace", - trunkBranch, - }); + const createResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_CREATE, + tempGitRepo, + "source-workspace", + trunkBranch + ); expect(createResult.success).toBe(true); - if (!createResult.success) return; const sourceWorkspaceId = createResult.metadata.id; // Test various invalid names @@ -56,17 +56,17 @@ describeIntegration("Workspace fork", () => { ]; for (const { name, expectedError } of invalidNames) { - const forkResult = await client.workspace.fork({ + const forkResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_FORK, sourceWorkspaceId, - newName: name, - }); + name + ); expect(forkResult.success).toBe(false); - if (forkResult.success) continue; expect(forkResult.error.toLowerCase()).toContain(expectedError.toLowerCase()); } // Cleanup - await client.workspace.remove({ workspaceId: sourceWorkspaceId }); + await env.mockIpcRenderer.invoke(IPC_CHANNELS.WORKSPACE_REMOVE, sourceWorkspaceId); } finally { await cleanupTestEnvironment(env); await cleanupTempGitRepo(tempGitRepo); @@ -82,20 +82,18 @@ describeIntegration("Workspace fork", () => { try { // Fork the workspace - const client = resolveOrpcClient(env); - const forkResult = await client.workspace.fork({ + const forkResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_FORK, sourceWorkspaceId, - newName: "forked-workspace", - }); + "forked-workspace" + ); expect(forkResult.success).toBe(true); - if (!forkResult.success) return; const forkedWorkspaceId = forkResult.metadata.id; // User expects: forked workspace is functional - can send messages to it - const collector = createStreamCollector(env.orpc, forkedWorkspaceId); - collector.start(); + env.sentEvents.length = 0; const sendResult = await sendMessageWithModel( - env, + env.mockIpcRenderer, forkedWorkspaceId, "What is 2+2? Answer with just the number.", modelString("anthropic", "claude-sonnet-4-5") @@ -103,12 +101,12 @@ describeIntegration("Workspace fork", () => { expect(sendResult.success).toBe(true); // Verify stream completes successfully + const collector = createEventCollector(env.sentEvents, forkedWorkspaceId); await collector.waitForEvent("stream-end", 30000); assertStreamSuccess(collector); const finalMessage = collector.getFinalMessage(); expect(finalMessage).toBeDefined(); - collector.stop(); } finally { await cleanup(); } @@ -136,21 +134,19 @@ describeIntegration("Workspace fork", () => { } // Fork the workspace - const client = resolveOrpcClient(env); - const forkResult = await client.workspace.fork({ + const forkResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_FORK, sourceWorkspaceId, - newName: "forked-with-history", - }); + "forked-with-history" + ); expect(forkResult.success).toBe(true); - if (!forkResult.success) return; const forkedWorkspaceId = forkResult.metadata.id; // User expects: forked workspace has access to history // Send a message that requires the historical context - const collector = createStreamCollector(env.orpc, forkedWorkspaceId); - collector.start(); + env.sentEvents.length = 0; const sendResult = await sendMessageWithModel( - env, + env.mockIpcRenderer, forkedWorkspaceId, "What word did I ask you to remember? Reply with just the word.", modelString("anthropic", "claude-sonnet-4-5") @@ -158,6 +154,7 @@ describeIntegration("Workspace fork", () => { expect(sendResult.success).toBe(true); // Verify stream completes successfully + const collector = createEventCollector(env.sentEvents, forkedWorkspaceId); await collector.waitForEvent("stream-end", 30000); assertStreamSuccess(collector); @@ -172,7 +169,6 @@ describeIntegration("Workspace fork", () => { .join(""); expect(content.toLowerCase()).toContain(uniqueWord.toLowerCase()); } - collector.stop(); } finally { await cleanup(); } @@ -187,32 +183,27 @@ describeIntegration("Workspace fork", () => { try { // Fork the workspace - const client = resolveOrpcClient(env); - const forkResult = await client.workspace.fork({ + const forkResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_FORK, sourceWorkspaceId, - newName: "forked-independent", - }); + "forked-independent" + ); expect(forkResult.success).toBe(true); - if (!forkResult.success) return; const forkedWorkspaceId = forkResult.metadata.id; // User expects: both workspaces work independently - // Start collectors before sending messages - const sourceCollector = createStreamCollector(env.orpc, sourceWorkspaceId); - const forkedCollector = createStreamCollector(env.orpc, forkedWorkspaceId); - sourceCollector.start(); - forkedCollector.start(); - // Send different messages to both concurrently + env.sentEvents.length = 0; + const [sourceResult, forkedResult] = await Promise.all([ sendMessageWithModel( - env, + env.mockIpcRenderer, sourceWorkspaceId, "What is 5+5? Answer with just the number.", modelString("anthropic", "claude-sonnet-4-5") ), sendMessageWithModel( - env, + env.mockIpcRenderer, forkedWorkspaceId, "What is 3+3? Answer with just the number.", modelString("anthropic", "claude-sonnet-4-5") @@ -223,6 +214,9 @@ describeIntegration("Workspace fork", () => { expect(forkedResult.success).toBe(true); // Verify both streams complete successfully + const sourceCollector = createEventCollector(env.sentEvents, sourceWorkspaceId); + const forkedCollector = createEventCollector(env.sentEvents, forkedWorkspaceId); + await Promise.all([ sourceCollector.waitForEvent("stream-end", 30000), forkedCollector.waitForEvent("stream-end", 30000), @@ -233,8 +227,6 @@ describeIntegration("Workspace fork", () => { expect(sourceCollector.getFinalMessage()).toBeDefined(); expect(forkedCollector.getFinalMessage()).toBeDefined(); - sourceCollector.stop(); - forkedCollector.stop(); } finally { await cleanup(); } @@ -248,44 +240,41 @@ describeIntegration("Workspace fork", () => { const { env, workspaceId: sourceWorkspaceId, cleanup } = await setupWorkspace("anthropic"); try { - // Start collector before starting stream - const sourceCollector = createStreamCollector(env.orpc, sourceWorkspaceId); - sourceCollector.start(); - // Start a stream in the source workspace (don't await) void sendMessageWithModel( - env, + env.mockIpcRenderer, sourceWorkspaceId, "Count from 1 to 10, one number per line. Then say 'Done counting.'", modelString("anthropic", "claude-sonnet-4-5") ); - // Wait for stream to start + // Wait for stream to start and produce some content + const sourceCollector = createEventCollector(env.sentEvents, sourceWorkspaceId); await sourceCollector.waitForEvent("stream-start", 5000); // Wait for some deltas to ensure we have partial content - await new Promise((resolve) => setTimeout(resolve, 2000)); + await waitFor(() => { + sourceCollector.collect(); + return sourceCollector.getDeltas().length > 2; + }, 10000); // Fork while stream is active (this should commit partial to history) - const client = resolveOrpcClient(env); - const forkResult = await client.workspace.fork({ + const forkResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_FORK, sourceWorkspaceId, - newName: "forked-mid-stream", - }); + "forked-mid-stream" + ); expect(forkResult.success).toBe(true); - if (!forkResult.success) return; const forkedWorkspaceId = forkResult.metadata.id; // Wait for source stream to complete await sourceCollector.waitForEvent("stream-end", 30000); - sourceCollector.stop(); // User expects: forked workspace is functional despite being forked mid-stream // Send a message to the forked workspace - const forkedCollector = createStreamCollector(env.orpc, forkedWorkspaceId); - forkedCollector.start(); + env.sentEvents.length = 0; const forkedSendResult = await sendMessageWithModel( - env, + env.mockIpcRenderer, forkedWorkspaceId, "What is 7+3? Answer with just the number.", modelString("anthropic", "claude-sonnet-4-5") @@ -293,11 +282,11 @@ describeIntegration("Workspace fork", () => { expect(forkedSendResult.success).toBe(true); // Verify forked workspace stream completes successfully + const forkedCollector = createEventCollector(env.sentEvents, forkedWorkspaceId); await forkedCollector.waitForEvent("stream-end", 30000); assertStreamSuccess(forkedCollector); expect(forkedCollector.getFinalMessage()).toBeDefined(); - forkedCollector.stop(); } finally { await cleanup(); } @@ -314,33 +303,32 @@ describeIntegration("Workspace fork", () => { try { // Create source workspace const trunkBranch = await detectDefaultTrunkBranch(tempGitRepo); - const client = resolveOrpcClient(env); - const createResult = await client.workspace.create({ - projectPath: tempGitRepo, - branchName: "source-workspace", - trunkBranch, - }); + const createResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_CREATE, + tempGitRepo, + "source-workspace", + trunkBranch + ); expect(createResult.success).toBe(true); - if (!createResult.success) return; const sourceWorkspaceId = createResult.metadata.id; // Fork the workspace - const forkResult = await client.workspace.fork({ + const forkResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_FORK, sourceWorkspaceId, - newName: "forked-workspace", - }); + "forked-workspace" + ); expect(forkResult.success).toBe(true); - if (!forkResult.success) return; // User expects: both workspaces appear in workspace list - const workspaces = await client.workspace.list(); + const workspaces = await env.mockIpcRenderer.invoke(IPC_CHANNELS.WORKSPACE_LIST); const workspaceIds = workspaces.map((w: { id: string }) => w.id); expect(workspaceIds).toContain(sourceWorkspaceId); expect(workspaceIds).toContain(forkResult.metadata.id); // Cleanup - await client.workspace.remove({ workspaceId: sourceWorkspaceId }); - await client.workspace.remove({ workspaceId: forkResult.metadata.id }); + await env.mockIpcRenderer.invoke(IPC_CHANNELS.WORKSPACE_REMOVE, sourceWorkspaceId); + await env.mockIpcRenderer.invoke(IPC_CHANNELS.WORKSPACE_REMOVE, forkResult.metadata.id); } finally { await cleanupTestEnvironment(env); await cleanupTempGitRepo(tempGitRepo); diff --git a/tests/ipcMain/helpers.ts b/tests/ipcMain/helpers.ts new file mode 100644 index 000000000..de27d7fae --- /dev/null +++ b/tests/ipcMain/helpers.ts @@ -0,0 +1,816 @@ +import type { IpcRenderer } from "electron"; +import { IPC_CHANNELS, getChatChannel } from "../../src/common/constants/ipc-constants"; +import type { + ImagePart, + SendMessageOptions, + WorkspaceChatMessage, + WorkspaceInitEvent, +} from "../../src/common/types/ipc"; +import { isInitStart, isInitOutput, isInitEnd } from "../../src/common/types/ipc"; +import type { Result } from "../../src/common/types/result"; +import type { SendMessageError } from "../../src/common/types/errors"; +import type { FrontendWorkspaceMetadata } from "../../src/common/types/workspace"; +import * as path from "path"; +import * as os from "os"; +import { detectDefaultTrunkBranch } from "../../src/node/git"; +import type { TestEnvironment } from "./setup"; +import type { RuntimeConfig } from "../../src/common/types/runtime"; +import { KNOWN_MODELS } from "../../src/common/constants/knownModels"; +import type { ToolPolicy } from "../../src/common/utils/tools/toolPolicy"; + +// Test constants - centralized for consistency across all tests +export const INIT_HOOK_WAIT_MS = 1500; // Wait for async init hook completion (local runtime) +export const SSH_INIT_WAIT_MS = 7000; // SSH init includes sync + checkout + hook, takes longer +export const HAIKU_MODEL = "anthropic:claude-haiku-4-5"; // Fast model for tests +export const GPT_5_MINI_MODEL = "openai:gpt-5-mini"; // Fastest model for performance-critical tests +export const TEST_TIMEOUT_LOCAL_MS = 25000; // Recommended timeout for local runtime tests +export const TEST_TIMEOUT_SSH_MS = 60000; // Recommended timeout for SSH runtime tests +export const STREAM_TIMEOUT_LOCAL_MS = 15000; // Stream timeout for local runtime +export const STREAM_TIMEOUT_SSH_MS = 25000; // Stream timeout for SSH runtime + +/** + * Generate a unique branch name + * Uses high-resolution time (nanosecond precision) to prevent collisions + */ +export function generateBranchName(prefix = "test"): string { + const hrTime = process.hrtime.bigint(); + const random = Math.random().toString(36).substring(2, 10); + return `${prefix}-${hrTime}-${random}`; +} + +/** + * Create a full model string from provider and model name + */ +export function modelString(provider: string, model: string): string { + return `${provider}:${model}`; +} + +/** + * Configure global test retries using Jest + * This helper isolates Jest-specific globals so they don't break other runners (like Bun) + */ +export function configureTestRetries(retries = 3): void { + if (process.env.CI && typeof jest !== "undefined" && jest.retryTimes) { + jest.retryTimes(retries, { logErrorsBeforeRetry: true }); + } +} + +/** + * Send a message via IPC + */ +type SendMessageWithModelOptions = Omit & { + imageParts?: Array<{ url: string; mediaType: string }>; +}; + +const DEFAULT_MODEL_ID = KNOWN_MODELS.SONNET.id; +const DEFAULT_PROVIDER = KNOWN_MODELS.SONNET.provider; + +export async function sendMessage( + mockIpcRenderer: IpcRenderer, + workspaceId: string, + message: string, + options?: SendMessageOptions & { imageParts?: ImagePart[] } +): Promise> { + return (await mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_SEND_MESSAGE, + workspaceId, + message, + options + )) as Result; +} + +/** + * Send a message with an explicit model id (defaults to SONNET). + */ +export async function sendMessageWithModel( + mockIpcRenderer: IpcRenderer, + workspaceId: string, + message: string, + modelId: string = DEFAULT_MODEL_ID, + options?: SendMessageWithModelOptions +): Promise> { + const resolvedModel = modelId.includes(":") ? modelId : modelString(DEFAULT_PROVIDER, modelId); + + return sendMessage(mockIpcRenderer, workspaceId, message, { + ...options, + model: resolvedModel, + }); +} + +/** + * Create a workspace via IPC + */ +export async function createWorkspace( + mockIpcRenderer: IpcRenderer, + projectPath: string, + branchName: string, + trunkBranch?: string, + runtimeConfig?: import("../../src/common/types/runtime").RuntimeConfig +): Promise< + { success: true; metadata: FrontendWorkspaceMetadata } | { success: false; error: string } +> { + const resolvedTrunk = + typeof trunkBranch === "string" && trunkBranch.trim().length > 0 + ? trunkBranch.trim() + : await detectDefaultTrunkBranch(projectPath); + + return (await mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_CREATE, + projectPath, + branchName, + resolvedTrunk, + runtimeConfig + )) as { success: true; metadata: FrontendWorkspaceMetadata } | { success: false; error: string }; +} + +/** + * Clear workspace history via IPC + */ +export async function clearHistory( + mockIpcRenderer: IpcRenderer, + workspaceId: string +): Promise> { + return (await mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_TRUNCATE_HISTORY, + workspaceId + )) as Result; +} + +/** + * Extract text content from stream events + * Filters for stream-delta events and concatenates the delta text + */ +export function extractTextFromEvents(events: WorkspaceChatMessage[]): string { + return events + .filter((e: any) => e.type === "stream-delta" && "delta" in e) + .map((e: any) => e.delta || "") + .join(""); +} + +/** + * Create workspace with optional init hook wait + * Enhanced version that can wait for init hook completion (needed for runtime tests) + */ +export async function createWorkspaceWithInit( + env: TestEnvironment, + projectPath: string, + branchName: string, + runtimeConfig?: RuntimeConfig, + waitForInit: boolean = false, + isSSH: boolean = false +): Promise<{ workspaceId: string; workspacePath: string; cleanup: () => Promise }> { + const trunkBranch = await detectDefaultTrunkBranch(projectPath); + + const result: any = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_CREATE, + projectPath, + branchName, + trunkBranch, + runtimeConfig + ); + + if (!result.success) { + throw new Error(`Failed to create workspace: ${result.error}`); + } + + const workspaceId = result.metadata.id; + const workspacePath = result.metadata.namedWorkspacePath; + + // Wait for init hook to complete if requested + if (waitForInit) { + const initTimeout = isSSH ? SSH_INIT_WAIT_MS : INIT_HOOK_WAIT_MS; + const collector = createEventCollector(env.sentEvents, workspaceId); + try { + await collector.waitForEvent("init-end", initTimeout); + } catch (err) { + // Init hook might not exist or might have already completed before we started waiting + // This is not necessarily an error - just log it + console.log( + `Note: init-end event not detected within ${initTimeout}ms (may have completed early)` + ); + } + } + + const cleanup = async () => { + await env.mockIpcRenderer.invoke(IPC_CHANNELS.WORKSPACE_REMOVE, workspaceId); + }; + + return { workspaceId, workspacePath, cleanup }; +} + +/** + * Send message and wait for stream completion + * Convenience helper that combines message sending with event collection + */ +export async function sendMessageAndWait( + env: TestEnvironment, + workspaceId: string, + message: string, + model: string, + toolPolicy?: ToolPolicy, + timeoutMs: number = STREAM_TIMEOUT_LOCAL_MS +): Promise { + // Clear previous events + env.sentEvents.length = 0; + + // Send message + const result = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_SEND_MESSAGE, + workspaceId, + message, + { + model, + toolPolicy, + thinkingLevel: "off", // Disable reasoning for fast test execution + mode: "exec", // Execute commands directly, don't propose plans + } + ); + + if (!result.success) { + throw new Error(`Failed to send message: ${JSON.stringify(result, null, 2)}`); + } + + // Wait for stream completion + const collector = createEventCollector(env.sentEvents, workspaceId); + const streamEnd = await collector.waitForEvent("stream-end", timeoutMs); + + if (!streamEnd) { + collector.logEventDiagnostics(`sendMessageAndWait timeout after ${timeoutMs}ms`); + throw new Error( + `sendMessageAndWait: Timeout waiting for stream-end after ${timeoutMs}ms.\n` + + `See detailed event diagnostics above.` + ); + } + + return collector.getEvents(); +} + +/** + * Event collector for capturing stream events + */ +export class EventCollector { + private events: WorkspaceChatMessage[] = []; + private sentEvents: Array<{ channel: string; data: unknown }>; + private workspaceId: string; + private chatChannel: string; + + constructor(sentEvents: Array<{ channel: string; data: unknown }>, workspaceId: string) { + this.sentEvents = sentEvents; + this.workspaceId = workspaceId; + this.chatChannel = getChatChannel(workspaceId); + } + + /** + * Collect all events for this workspace from the sent events array + */ + collect(): WorkspaceChatMessage[] { + this.events = this.sentEvents + .filter((e) => e.channel === this.chatChannel) + .map((e) => e.data as WorkspaceChatMessage); + return this.events; + } + + /** + * Get the collected events + */ + getEvents(): WorkspaceChatMessage[] { + return this.events; + } + + /** + * Wait for a specific event type with exponential backoff + */ + async waitForEvent(eventType: string, timeoutMs = 30000): Promise { + const startTime = Date.now(); + let pollInterval = 50; // Start with 50ms for faster detection + + while (Date.now() - startTime < timeoutMs) { + this.collect(); + const event = this.events.find((e) => "type" in e && e.type === eventType); + if (event) { + return event; + } + // Exponential backoff with max 500ms + await new Promise((resolve) => setTimeout(resolve, pollInterval)); + pollInterval = Math.min(pollInterval * 1.5, 500); + } + + // Timeout - log detailed diagnostic info + this.logEventDiagnostics(`waitForEvent timeout: Expected "${eventType}"`); + + return null; + } + + /** + * Log detailed event diagnostics for debugging + * Includes timestamps, event types, tool calls, and error details + */ + logEventDiagnostics(context: string): void { + console.error(`\n${"=".repeat(80)}`); + console.error(`EVENT DIAGNOSTICS: ${context}`); + console.error(`${"=".repeat(80)}`); + console.error(`Workspace: ${this.workspaceId}`); + console.error(`Total events: ${this.events.length}`); + console.error(`\nEvent sequence:`); + + // Log all events with details + this.events.forEach((event, idx) => { + const timestamp = + "timestamp" in event ? new Date(event.timestamp as number).toISOString() : "no-ts"; + const type = "type" in event ? (event as { type: string }).type : "no-type"; + + console.error(` [${idx}] ${timestamp} - ${type}`); + + // Log tool call details + if (type === "tool-call-start" && "toolName" in event) { + console.error(` Tool: ${event.toolName}`); + if ("args" in event) { + console.error(` Args: ${JSON.stringify(event.args)}`); + } + } + + if (type === "tool-call-end" && "toolName" in event) { + console.error(` Tool: ${event.toolName}`); + if ("result" in event) { + const result = + typeof event.result === "string" + ? event.result.length > 100 + ? `${event.result.substring(0, 100)}... (${event.result.length} chars)` + : event.result + : JSON.stringify(event.result); + console.error(` Result: ${result}`); + } + } + + // Log error details + if (type === "stream-error") { + if ("error" in event) { + console.error(` Error: ${event.error}`); + } + if ("errorType" in event) { + console.error(` Error Type: ${event.errorType}`); + } + } + + // Log delta content (first 100 chars) + if (type === "stream-delta" && "delta" in event) { + const delta = + typeof event.delta === "string" + ? event.delta.length > 100 + ? `${event.delta.substring(0, 100)}...` + : event.delta + : JSON.stringify(event.delta); + console.error(` Delta: ${delta}`); + } + + // Log final content (first 200 chars) + if (type === "stream-end" && "content" in event) { + const content = + typeof event.content === "string" + ? event.content.length > 200 + ? `${event.content.substring(0, 200)}... (${event.content.length} chars)` + : event.content + : JSON.stringify(event.content); + console.error(` Content: ${content}`); + } + }); + + // Summary + const eventTypeCounts = this.events.reduce( + (acc, e) => { + const type = "type" in e ? (e as { type: string }).type : "unknown"; + acc[type] = (acc[type] || 0) + 1; + return acc; + }, + {} as Record + ); + + console.error(`\nEvent type counts:`); + Object.entries(eventTypeCounts).forEach(([type, count]) => { + console.error(` ${type}: ${count}`); + }); + + console.error(`${"=".repeat(80)}\n`); + } + + /** + * Check if stream completed successfully + */ + hasStreamEnd(): boolean { + return this.events.some((e) => "type" in e && e.type === "stream-end"); + } + + /** + * Check if stream had an error + */ + hasError(): boolean { + return this.events.some((e) => "type" in e && e.type === "stream-error"); + } + + /** + * Get all stream-delta events + */ + getDeltas(): WorkspaceChatMessage[] { + return this.events.filter((e) => "type" in e && e.type === "stream-delta"); + } + + /** + * Get the final assistant message (from stream-end) + */ + getFinalMessage(): WorkspaceChatMessage | undefined { + return this.events.find((e) => "type" in e && e.type === "stream-end"); + } +} + +/** + * Create an event collector for a workspace + */ +export function createEventCollector( + sentEvents: Array<{ channel: string; data: unknown }>, + workspaceId: string +): EventCollector { + return new EventCollector(sentEvents, workspaceId); +} + +/** + * Assert that a stream completed successfully + * Provides helpful error messages when assertions fail + */ +export function assertStreamSuccess(collector: EventCollector): void { + const allEvents = collector.getEvents(); + + // Check for stream-end + if (!collector.hasStreamEnd()) { + const errorEvent = allEvents.find((e) => "type" in e && e.type === "stream-error"); + if (errorEvent && "error" in errorEvent) { + collector.logEventDiagnostics( + `Stream did not complete successfully. Got stream-error: ${errorEvent.error}` + ); + throw new Error( + `Stream did not complete successfully. Got stream-error: ${errorEvent.error}\n` + + `See detailed event diagnostics above.` + ); + } + collector.logEventDiagnostics("Stream did not emit stream-end event"); + throw new Error( + `Stream did not emit stream-end event.\n` + `See detailed event diagnostics above.` + ); + } + + // Check for errors + if (collector.hasError()) { + const errorEvent = allEvents.find((e) => "type" in e && e.type === "stream-error"); + const errorMsg = errorEvent && "error" in errorEvent ? errorEvent.error : "unknown"; + collector.logEventDiagnostics(`Stream completed but also has error event: ${errorMsg}`); + throw new Error( + `Stream completed but also has error event: ${errorMsg}\n` + + `See detailed event diagnostics above.` + ); + } + + // Check for final message + const finalMessage = collector.getFinalMessage(); + if (!finalMessage) { + collector.logEventDiagnostics("Stream completed but final message is missing"); + throw new Error( + `Stream completed but final message is missing.\n` + `See detailed event diagnostics above.` + ); + } +} + +/** + * Assert that a result has a specific error type + */ +export function assertError( + result: Result, + expectedErrorType: string +): void { + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.type).toBe(expectedErrorType); + } +} + +/** + * Poll for a condition with exponential backoff + * More robust than fixed sleeps for async operations + */ +export async function waitFor( + condition: () => boolean | Promise, + timeoutMs = 5000, + pollIntervalMs = 50 +): Promise { + const startTime = Date.now(); + let currentInterval = pollIntervalMs; + + while (Date.now() - startTime < timeoutMs) { + if (await condition()) { + return true; + } + await new Promise((resolve) => setTimeout(resolve, currentInterval)); + // Exponential backoff with max 500ms + currentInterval = Math.min(currentInterval * 1.5, 500); + } + + return false; +} + +/** + * Wait for a file to exist with retry logic + * Useful for checking file operations that may take time + */ +export async function waitForFileExists(filePath: string, timeoutMs = 5000): Promise { + const fs = await import("fs/promises"); + return waitFor(async () => { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } + }, timeoutMs); +} + +/** + * Wait for init hook to complete by watching for init-end event + * More reliable than static sleeps + * Based on workspaceInitHook.test.ts pattern + */ +export async function waitForInitComplete( + env: import("./setup").TestEnvironment, + workspaceId: string, + timeoutMs = 5000 +): Promise { + const startTime = Date.now(); + let pollInterval = 50; + + while (Date.now() - startTime < timeoutMs) { + // Check for init-end event in sentEvents + const initEndEvent = env.sentEvents.find( + (e) => + e.channel === getChatChannel(workspaceId) && + typeof e.data === "object" && + e.data !== null && + "type" in e.data && + e.data.type === "init-end" + ); + + if (initEndEvent) { + // Check if init succeeded (exitCode === 0) + const exitCode = (initEndEvent.data as any).exitCode; + if (exitCode !== 0) { + // Collect all init output for debugging + const initOutputEvents = env.sentEvents.filter( + (e) => + e.channel === getChatChannel(workspaceId) && + typeof e.data === "object" && + e.data !== null && + "type" in e.data && + (e.data as any).type === "init-output" + ); + const output = initOutputEvents + .map((e) => (e.data as any).line) + .filter(Boolean) + .join("\n"); + throw new Error(`Init hook failed with exit code ${exitCode}:\n${output}`); + } + return; + } + + await new Promise((resolve) => setTimeout(resolve, pollInterval)); + pollInterval = Math.min(pollInterval * 1.5, 500); + } + + // Throw error on timeout - workspace creation must complete for tests to be valid + throw new Error(`Init did not complete within ${timeoutMs}ms - workspace may not be ready`); +} + +/** + * Collect all init events for a workspace. + * Filters sentEvents for init-start, init-output, and init-end events. + * Returns the events in chronological order. + */ +export function collectInitEvents( + env: import("./setup").TestEnvironment, + workspaceId: string +): WorkspaceInitEvent[] { + return env.sentEvents + .filter((e) => e.channel === getChatChannel(workspaceId)) + .map((e) => e.data as WorkspaceChatMessage) + .filter( + (msg) => isInitStart(msg) || isInitOutput(msg) || isInitEnd(msg) + ) as WorkspaceInitEvent[]; +} + +/** + * Wait for init-end event without checking exit code. + * Use this when you want to test failure cases or inspect the exit code yourself. + * For success-only tests, use waitForInitComplete() which throws on failure. + */ +export async function waitForInitEnd( + env: import("./setup").TestEnvironment, + workspaceId: string, + timeoutMs = 5000 +): Promise { + const startTime = Date.now(); + let pollInterval = 50; + + while (Date.now() - startTime < timeoutMs) { + // Check for init-end event in sentEvents + const initEndEvent = env.sentEvents.find( + (e) => + e.channel === getChatChannel(workspaceId) && + typeof e.data === "object" && + e.data !== null && + "type" in e.data && + e.data.type === "init-end" + ); + + if (initEndEvent) { + return; // Found end event, regardless of exit code + } + + await new Promise((resolve) => setTimeout(resolve, pollInterval)); + pollInterval = Math.min(pollInterval * 1.5, 500); + } + + // Throw error on timeout + throw new Error(`Init did not complete within ${timeoutMs}ms`); +} + +/** + * Wait for stream to complete successfully + * Common pattern: create collector, wait for end, assert success + */ +export async function waitForStreamSuccess( + sentEvents: Array<{ channel: string; data: unknown }>, + workspaceId: string, + timeoutMs = 30000 +): Promise { + const collector = createEventCollector(sentEvents, workspaceId); + await collector.waitForEvent("stream-end", timeoutMs); + assertStreamSuccess(collector); + return collector; +} + +/** + * Read and parse chat history from disk + */ +export async function readChatHistory( + tempDir: string, + workspaceId: string +): Promise }>> { + const fsPromises = await import("fs/promises"); + const historyPath = path.join(tempDir, "sessions", workspaceId, "chat.jsonl"); + const historyContent = await fsPromises.readFile(historyPath, "utf-8"); + return historyContent + .trim() + .split("\n") + .map((line: string) => JSON.parse(line)); +} + +/** + * Test image fixtures (1x1 pixel PNGs) + */ +export const TEST_IMAGES: Record = { + RED_PIXEL: { + url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==", + mediaType: "image/png", + }, + BLUE_PIXEL: { + url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M/wHwAEBgIApD5fRAAAAABJRU5ErkJggg==", + mediaType: "image/png", + }, +}; + +/** + * Wait for a file to NOT exist with retry logic + */ +export async function waitForFileNotExists(filePath: string, timeoutMs = 5000): Promise { + const fs = await import("fs/promises"); + return waitFor(async () => { + try { + await fs.access(filePath); + return false; + } catch { + return true; + } + }, timeoutMs); +} + +/** + * Create a temporary git repository for testing + */ +export async function createTempGitRepo(): Promise { + const fs = await import("fs/promises"); + const { exec } = await import("child_process"); + const { promisify } = await import("util"); + // eslint-disable-next-line local/no-unsafe-child-process + const execAsync = promisify(exec); + + // Use mkdtemp to avoid race conditions and ensure unique directory + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-test-repo-")); + + // Use promisify(exec) for test setup - DisposableExec has issues in CI + // TODO: Investigate why DisposableExec causes empty git output in CI + await execAsync(`git init`, { cwd: tempDir }); + await execAsync(`git config user.email "test@example.com" && git config user.name "Test User"`, { + cwd: tempDir, + }); + await execAsync( + `echo "test" > README.md && git add . && git commit -m "Initial commit" && git branch test-branch`, + { cwd: tempDir } + ); + + return tempDir; +} + +/** + * Add a git submodule to a repository + * @param repoPath - Path to the repository to add the submodule to + * @param submoduleUrl - URL of the submodule repository (defaults to leftpad) + * @param submoduleName - Name/path for the submodule + */ +export async function addSubmodule( + repoPath: string, + submoduleUrl: string = "https://github.com/left-pad/left-pad.git", + submoduleName: string = "vendor/left-pad" +): Promise { + const { exec } = await import("child_process"); + const { promisify } = await import("util"); + const execAsync = promisify(exec); + + await execAsync(`git submodule add "${submoduleUrl}" "${submoduleName}"`, { cwd: repoPath }); + await execAsync(`git commit -m "Add submodule ${submoduleName}"`, { cwd: repoPath }); +} + +/** + * Cleanup temporary git repository with retry logic + */ +export async function cleanupTempGitRepo(repoPath: string): Promise { + const fs = await import("fs/promises"); + const maxRetries = 3; + let lastError: unknown; + + for (let i = 0; i < maxRetries; i++) { + try { + await fs.rm(repoPath, { recursive: true, force: true }); + return; + } catch (error) { + lastError = error; + // Wait before retry (files might be locked temporarily) + if (i < maxRetries - 1) { + await new Promise((resolve) => setTimeout(resolve, 100 * (i + 1))); + } + } + } + console.warn(`Failed to cleanup temp git repo after ${maxRetries} attempts:`, lastError); +} + +/** + * Build large conversation history to test context limits + * + * This is a test-only utility that uses HistoryService directly to quickly + * populate history without making API calls. Real application code should + * NEVER bypass IPC like this. + * + * @param workspaceId - Workspace to populate + * @param config - Config instance for HistoryService + * @param options - Configuration for history size + * @returns Promise that resolves when history is built + */ +export async function buildLargeHistory( + workspaceId: string, + config: { getSessionDir: (id: string) => string }, + options: { + messageSize?: number; + messageCount?: number; + textPrefix?: string; + } = {} +): Promise { + const fs = await import("fs/promises"); + const path = await import("path"); + const { createMuxMessage } = await import("../../src/common/types/message"); + + const messageSize = options.messageSize ?? 50_000; + const messageCount = options.messageCount ?? 80; + const textPrefix = options.textPrefix ?? ""; + + const largeText = textPrefix + "A".repeat(messageSize); + const sessionDir = config.getSessionDir(workspaceId); + const chatPath = path.join(sessionDir, "chat.jsonl"); + + let content = ""; + + // Build conversation history with alternating user/assistant messages + for (let i = 0; i < messageCount; i++) { + const isUser = i % 2 === 0; + const role = isUser ? "user" : "assistant"; + const message = createMuxMessage(`history-msg-${i}`, role, largeText, {}); + content += JSON.stringify(message) + "\n"; + } + + // Ensure session directory exists and write file directly for performance + await fs.mkdir(sessionDir, { recursive: true }); + await fs.writeFile(chatPath, content, "utf-8"); +} diff --git a/tests/ipcMain/initWorkspace.test.ts b/tests/ipcMain/initWorkspace.test.ts new file mode 100644 index 000000000..3e7c8b21e --- /dev/null +++ b/tests/ipcMain/initWorkspace.test.ts @@ -0,0 +1,718 @@ +import { + shouldRunIntegrationTests, + createTestEnvironment, + cleanupTestEnvironment, + validateApiKeys, + getApiKey, + setupProviders, + type TestEnvironment, +} from "./setup"; +import { IPC_CHANNELS, getChatChannel } from "../../src/common/constants/ipc-constants"; +import { + generateBranchName, + createWorkspace, + waitForInitComplete, + waitForInitEnd, + collectInitEvents, + waitFor, +} from "./helpers"; +import type { WorkspaceChatMessage, WorkspaceInitEvent } from "../../src/common/types/ipc"; +import { isInitStart, isInitOutput, isInitEnd } from "../../src/common/types/ipc"; +import * as path from "path"; +import * as os from "os"; +import { + isDockerAvailable, + startSSHServer, + stopSSHServer, + type SSHServerConfig, +} from "../runtime/ssh-fixture"; +import type { RuntimeConfig } from "../../src/common/types/runtime"; + +// Skip all tests if TEST_INTEGRATION is not set +const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip; + +// Validate API keys for AI tests +if (shouldRunIntegrationTests()) { + validateApiKeys(["ANTHROPIC_API_KEY"]); +} + +/** + * Create a temp git repo with a .mux/init hook that writes to stdout/stderr and exits with a given code + */ +async function createTempGitRepoWithInitHook(options: { + exitCode: number; + stdoutLines?: string[]; + stderrLines?: string[]; + sleepBetweenLines?: number; // milliseconds + customScript?: string; // Optional custom script content (overrides stdout/stderr) +}): Promise { + const fs = await import("fs/promises"); + const { exec } = await import("child_process"); + const { promisify } = await import("util"); + const execAsync = promisify(exec); + + // Use mkdtemp to avoid race conditions + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-test-init-hook-")); + + // Initialize git repo + await execAsync(`git init`, { cwd: tempDir }); + await execAsync(`git config user.email "test@example.com" && git config user.name "Test User"`, { + cwd: tempDir, + }); + await execAsync(`echo "test" > README.md && git add . && git commit -m "Initial commit"`, { + cwd: tempDir, + }); + + // Create .mux directory + const muxDir = path.join(tempDir, ".mux"); + await fs.mkdir(muxDir, { recursive: true }); + + // Create init hook script + const hookPath = path.join(muxDir, "init"); + + let scriptContent: string; + if (options.customScript) { + scriptContent = `#!/bin/bash\n${options.customScript}\nexit ${options.exitCode}\n`; + } else { + const sleepCmd = options.sleepBetweenLines ? `sleep ${options.sleepBetweenLines / 1000}` : ""; + + const stdoutCmds = (options.stdoutLines ?? []) + .map((line, idx) => { + const needsSleep = sleepCmd && idx < (options.stdoutLines?.length ?? 0) - 1; + return `echo "${line}"${needsSleep ? `\n${sleepCmd}` : ""}`; + }) + .join("\n"); + + const stderrCmds = (options.stderrLines ?? []).map((line) => `echo "${line}" >&2`).join("\n"); + + scriptContent = `#!/bin/bash\n${stdoutCmds}\n${stderrCmds}\nexit ${options.exitCode}\n`; + } + + await fs.writeFile(hookPath, scriptContent, { mode: 0o755 }); + + // Commit the init hook (required for SSH runtime - git worktree syncs committed files) + await execAsync(`git add -A && git commit -m "Add init hook"`, { cwd: tempDir }); + + return tempDir; +} + +/** + * Cleanup temporary git repository + */ +async function cleanupTempGitRepo(repoPath: string): Promise { + const fs = await import("fs/promises"); + const maxRetries = 3; + let lastError: unknown; + + for (let i = 0; i < maxRetries; i++) { + try { + await fs.rm(repoPath, { recursive: true, force: true }); + return; + } catch (error) { + lastError = error; + if (i < maxRetries - 1) { + await new Promise((resolve) => setTimeout(resolve, 100 * (i + 1))); + } + } + } + console.warn(`Failed to cleanup temp git repo after ${maxRetries} attempts:`, lastError); +} + +describeIntegration("IpcMain workspace init hook integration tests", () => { + test.concurrent( + "should stream init hook output and allow workspace usage on hook success", + async () => { + const env = await createTestEnvironment(); + const tempGitRepo = await createTempGitRepoWithInitHook({ + exitCode: 0, + stdoutLines: ["Installing dependencies...", "Build complete!"], + stderrLines: ["Warning: deprecated package"], + }); + + try { + const branchName = generateBranchName("init-hook-success"); + + // Create workspace (which will trigger the hook) + const createResult = await createWorkspace(env.mockIpcRenderer, tempGitRepo, branchName); + expect(createResult.success).toBe(true); + if (!createResult.success) return; + + const workspaceId = createResult.metadata.id; + + // Wait for hook to complete + await waitForInitComplete(env, workspaceId, 10000); + + // Collect all init events for verification + const initEvents = collectInitEvents(env, workspaceId); + + // Verify event sequence + expect(initEvents.length).toBeGreaterThan(0); + + // First event should be start + const startEvent = initEvents.find((e) => isInitStart(e)); + expect(startEvent).toBeDefined(); + if (startEvent && isInitStart(startEvent)) { + // Hook path should be the project path (where .mux/init exists) + expect(startEvent.hookPath).toBeTruthy(); + } + + // Should have output and error lines + const outputEvents = initEvents.filter((e) => isInitOutput(e) && !e.isError) as Extract< + WorkspaceInitEvent, + { type: "init-output" } + >[]; + const errorEvents = initEvents.filter((e) => isInitOutput(e) && e.isError) as Extract< + WorkspaceInitEvent, + { type: "init-output" } + >[]; + + // Should have workspace creation logs + hook output + expect(outputEvents.length).toBeGreaterThanOrEqual(2); + + // Verify hook output is present (may have workspace creation logs before it) + const outputLines = outputEvents.map((e) => e.line); + expect(outputLines).toContain("Installing dependencies..."); + expect(outputLines).toContain("Build complete!"); + + expect(errorEvents.length).toBe(1); + expect(errorEvents[0].line).toBe("Warning: deprecated package"); + + // Last event should be end with exitCode 0 + const finalEvent = initEvents[initEvents.length - 1]; + expect(isInitEnd(finalEvent)).toBe(true); + if (isInitEnd(finalEvent)) { + expect(finalEvent.exitCode).toBe(0); + } + + // Workspace should be usable - verify getInfo succeeds + const info = await env.mockIpcRenderer.invoke(IPC_CHANNELS.WORKSPACE_GET_INFO, workspaceId); + expect(info).not.toBeNull(); + expect(info.id).toBe(workspaceId); + } finally { + await cleanupTestEnvironment(env); + await cleanupTempGitRepo(tempGitRepo); + } + }, + 15000 + ); + + test.concurrent( + "should stream init hook output and allow workspace usage on hook failure", + async () => { + const env = await createTestEnvironment(); + const tempGitRepo = await createTempGitRepoWithInitHook({ + exitCode: 1, + stdoutLines: ["Starting setup..."], + stderrLines: ["ERROR: Failed to install dependencies"], + }); + + try { + const branchName = generateBranchName("init-hook-failure"); + + // Create workspace + const createResult = await createWorkspace(env.mockIpcRenderer, tempGitRepo, branchName); + expect(createResult.success).toBe(true); + if (!createResult.success) return; + + const workspaceId = createResult.metadata.id; + + // Wait for hook to complete (without throwing on failure) + await waitForInitEnd(env, workspaceId, 10000); + + // Collect all init events for verification + const initEvents = collectInitEvents(env, workspaceId); + + // Verify we got events + expect(initEvents.length).toBeGreaterThan(0); + + // Should have start event + const failureStartEvent = initEvents.find((e) => isInitStart(e)); + expect(failureStartEvent).toBeDefined(); + + // Should have output and error + const failureOutputEvents = initEvents.filter((e) => isInitOutput(e) && !e.isError); + const failureErrorEvents = initEvents.filter((e) => isInitOutput(e) && e.isError); + expect(failureOutputEvents.length).toBeGreaterThanOrEqual(1); + expect(failureErrorEvents.length).toBeGreaterThanOrEqual(1); + + // Last event should be end with exitCode 1 + const failureFinalEvent = initEvents[initEvents.length - 1]; + expect(isInitEnd(failureFinalEvent)).toBe(true); + if (isInitEnd(failureFinalEvent)) { + expect(failureFinalEvent.exitCode).toBe(1); + } + + // CRITICAL: Workspace should remain usable even after hook failure + const info = await env.mockIpcRenderer.invoke(IPC_CHANNELS.WORKSPACE_GET_INFO, workspaceId); + expect(info).not.toBeNull(); + expect(info.id).toBe(workspaceId); + } finally { + await cleanupTestEnvironment(env); + await cleanupTempGitRepo(tempGitRepo); + } + }, + 15000 + ); + + test.concurrent( + "should not emit meta events when no init hook exists", + async () => { + const env = await createTestEnvironment(); + // Create repo without .mux/init hook + const fs = await import("fs/promises"); + const { exec } = await import("child_process"); + const { promisify } = await import("util"); + const execAsync = promisify(exec); + + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-test-no-hook-")); + + try { + // Initialize git repo without hook + await execAsync(`git init`, { cwd: tempDir }); + await execAsync( + `git config user.email "test@example.com" && git config user.name "Test User"`, + { cwd: tempDir } + ); + await execAsync(`echo "test" > README.md && git add . && git commit -m "Initial commit"`, { + cwd: tempDir, + }); + + const branchName = generateBranchName("no-hook"); + + // Create workspace + const createResult = await createWorkspace(env.mockIpcRenderer, tempDir, branchName); + expect(createResult.success).toBe(true); + if (!createResult.success) return; + + const workspaceId = createResult.metadata.id; + + // Wait a bit to ensure no events are emitted + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Verify init events were sent (workspace creation logs even without hook) + const initEvents = collectInitEvents(env, workspaceId); + + // Should have init-start event (always emitted, even without hook) + const startEvent = initEvents.find((e) => isInitStart(e)); + expect(startEvent).toBeDefined(); + + // Should have workspace creation logs (e.g., "Creating git worktree...") + const outputEvents = initEvents.filter((e) => isInitOutput(e)); + expect(outputEvents.length).toBeGreaterThan(0); + + // Should have completion event with exit code 0 (success, no hook) + const endEvent = initEvents.find((e) => isInitEnd(e)); + expect(endEvent).toBeDefined(); + if (endEvent && isInitEnd(endEvent)) { + expect(endEvent.exitCode).toBe(0); + } + + // Workspace should still be usable + const info = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_GET_INFO, + createResult.metadata.id + ); + expect(info).not.toBeNull(); + } finally { + await cleanupTestEnvironment(env); + await cleanupTempGitRepo(tempDir); + } + }, + 15000 + ); + + test.concurrent( + "should persist init state to disk for replay across page reloads", + async () => { + const env = await createTestEnvironment(); + const fs = await import("fs/promises"); + const repoPath = await createTempGitRepoWithInitHook({ + exitCode: 0, + stdoutLines: ["Installing dependencies", "Done!"], + stderrLines: [], + }); + + try { + const branchName = generateBranchName("replay-test"); + const createResult = await createWorkspace(env.mockIpcRenderer, repoPath, branchName); + expect(createResult.success).toBe(true); + if (!createResult.success) return; + + const workspaceId = createResult.metadata.id; + + // Wait for init hook to complete + await waitForInitComplete(env, workspaceId, 5000); + + // Verify init-status.json exists on disk + const initStatusPath = path.join(env.config.getSessionDir(workspaceId), "init-status.json"); + const statusExists = await fs + .access(initStatusPath) + .then(() => true) + .catch(() => false); + expect(statusExists).toBe(true); + + // Read and verify persisted state + const statusContent = await fs.readFile(initStatusPath, "utf-8"); + const status = JSON.parse(statusContent); + expect(status.status).toBe("success"); + expect(status.exitCode).toBe(0); + + // Should include workspace creation logs + hook output + expect(status.lines).toEqual( + expect.arrayContaining([ + { line: "Creating git worktree...", isError: false, timestamp: expect.any(Number) }, + { + line: "Worktree created successfully", + isError: false, + timestamp: expect.any(Number), + }, + expect.objectContaining({ + line: expect.stringMatching(/Running init hook:/), + isError: false, + }), + { line: "Installing dependencies", isError: false, timestamp: expect.any(Number) }, + { line: "Done!", isError: false, timestamp: expect.any(Number) }, + ]) + ); + expect(status.hookPath).toBeTruthy(); // Project path where hook exists + expect(status.startTime).toBeGreaterThan(0); + expect(status.endTime).toBeGreaterThan(status.startTime); + } finally { + await cleanupTestEnvironment(env); + await cleanupTempGitRepo(repoPath); + } + }, + 15000 + ); +}); + +test.concurrent( + "should receive init events with natural timing (not batched)", + async () => { + const env = await createTestEnvironment(); + + // Create project with slow init hook (100ms sleep between lines) + const tempGitRepo = await createTempGitRepoWithInitHook({ + exitCode: 0, + stdoutLines: ["Line 1", "Line 2", "Line 3", "Line 4"], + sleepBetweenLines: 100, // 100ms between each echo + }); + + try { + const branchName = generateBranchName("timing-test"); + const startTime = Date.now(); + + // Create workspace - init hook will start immediately + const createResult = await createWorkspace(env.mockIpcRenderer, tempGitRepo, branchName); + expect(createResult.success).toBe(true); + if (!createResult.success) return; + + const workspaceId = createResult.metadata.id; + + // Wait for all init events to arrive + await waitForInitComplete(env, workspaceId, 10000); + + // Collect timestamped output events + const allOutputEvents = env.sentEvents + .filter((e) => e.channel === getChatChannel(workspaceId)) + .filter((e) => isInitOutput(e.data as WorkspaceChatMessage)) + .map((e) => ({ + timestamp: e.timestamp, // Use timestamp from when event was sent + line: (e.data as { line: string }).line, + })); + + // Filter to only hook output lines (exclude workspace creation logs) + const initOutputEvents = allOutputEvents.filter((e) => e.line.startsWith("Line ")); + + expect(initOutputEvents.length).toBe(4); + + // Calculate time between consecutive events + const timeDiffs = initOutputEvents + .slice(1) + .map((event, i) => event.timestamp - initOutputEvents[i].timestamp); + + // ASSERTION: If streaming in real-time, events should be ~100ms apart + // If batched/replayed, events will be <10ms apart + const avgTimeDiff = timeDiffs.reduce((a, b) => a + b, 0) / timeDiffs.length; + + // Real-time streaming: expect at least 70ms average (accounting for variance) + // Batched replay: would be <10ms + expect(avgTimeDiff).toBeGreaterThan(70); + + // Also verify first event arrives early (not waiting for hook to complete) + const firstEventDelay = initOutputEvents[0].timestamp - startTime; + expect(firstEventDelay).toBeLessThan(1000); // Should arrive reasonably quickly (bash startup + git worktree setup) + } finally { + await cleanupTestEnvironment(env); + await cleanupTempGitRepo(tempGitRepo); + } + }, + 15000 +); + +// SSH server config for runtime matrix tests +let sshConfig: SSHServerConfig | undefined; + +// ============================================================================ +// Runtime Matrix Tests - Init Queue Behavior +// ============================================================================ + +describeIntegration("Init Queue - Runtime Matrix", () => { + beforeAll(async () => { + // Only start SSH server if Docker is available + if (await isDockerAvailable()) { + console.log("Starting SSH server container for init queue tests..."); + sshConfig = await startSSHServer(); + console.log(`SSH server ready on port ${sshConfig.port}`); + } else { + console.log("Docker not available - SSH tests will be skipped"); + } + }, 60000); + + afterAll(async () => { + if (sshConfig) { + console.log("Stopping SSH server container..."); + await stopSSHServer(sshConfig); + } + }, 30000); + + // Test matrix: Run tests for both local and SSH runtimes + describe.each<{ type: "local" | "ssh" }>([{ type: "local" }, { type: "ssh" }])( + "Runtime: $type", + ({ type }) => { + // Helper to build runtime config + const getRuntimeConfig = (branchName: string): RuntimeConfig | undefined => { + if (type === "ssh" && sshConfig) { + return { + type: "ssh", + host: `testuser@localhost`, + srcBaseDir: `${sshConfig.workdir}/${branchName}`, + identityFile: sshConfig.privateKeyPath, + port: sshConfig.port, + }; + } + return undefined; // undefined = defaults to local + }; + + // Timeouts vary by runtime type + const testTimeout = type === "ssh" ? 90000 : 30000; + const streamTimeout = type === "ssh" ? 30000 : 15000; + const initWaitBuffer = type === "ssh" ? 10000 : 2000; + + test.concurrent( + "file_read should wait for init hook before executing (even when init fails)", + async () => { + // Skip SSH test if Docker not available + if (type === "ssh" && !sshConfig) { + console.log("Skipping SSH test - Docker not available"); + return; + } + + const env = await createTestEnvironment(); + const branchName = generateBranchName("init-wait-file-read"); + + // Setup API provider + await setupProviders(env.mockIpcRenderer, { + anthropic: { + apiKey: getApiKey("ANTHROPIC_API_KEY"), + }, + }); + + // Create repo with init hook that sleeps 5s, writes a file, then FAILS + // This tests that tools proceed even when init hook fails (exit code 1) + const tempGitRepo = await createTempGitRepoWithInitHook({ + exitCode: 1, // EXIT WITH FAILURE + customScript: ` +echo "Starting init..." +sleep 5 +echo "Writing file before exit..." +echo "Hello from init hook!" > init_created_file.txt +echo "File written, now exiting with error" +exit 1 + `, + }); + + try { + // Create workspace with runtime config + const runtimeConfig = getRuntimeConfig(branchName); + const createResult = await createWorkspace( + env.mockIpcRenderer, + tempGitRepo, + branchName, + undefined, + runtimeConfig + ); + expect(createResult.success).toBe(true); + if (!createResult.success) return; + + const workspaceId = createResult.metadata.id; + + // Clear sent events to isolate AI message events + env.sentEvents.length = 0; + + // IMMEDIATELY ask AI to read the file (before init completes) + const sendResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_SEND_MESSAGE, + workspaceId, + "Read the file init_created_file.txt and tell me what it says", + { + model: "anthropic:claude-haiku-4-5", + } + ); + + expect(sendResult.success).toBe(true); + + // Wait for stream completion + await waitFor(() => { + const chatChannel = getChatChannel(workspaceId); + return env.sentEvents + .filter((e) => e.channel === chatChannel) + .some( + (e) => + typeof e.data === "object" && + e.data !== null && + "type" in e.data && + e.data.type === "stream-end" + ); + }, streamTimeout); + + // Extract all tool call end events from the stream + const chatChannel = getChatChannel(workspaceId); + const toolCallEndEvents = env.sentEvents + .filter((e) => e.channel === chatChannel) + .map((e) => e.data as WorkspaceChatMessage) + .filter( + (msg) => + typeof msg === "object" && + msg !== null && + "type" in msg && + msg.type === "tool-call-end" + ); + + // Count file_read tool calls + const fileReadCalls = toolCallEndEvents.filter( + (msg: any) => msg.toolName === "file_read" + ); + + // ASSERTION 1: Should have exactly ONE file_read call (no retries) + // This proves the tool waited for init to complete (even though init failed) + expect(fileReadCalls.length).toBe(1); + + // ASSERTION 2: The file_read should have succeeded + // Init failure doesn't block tools - they proceed and fail/succeed naturally + const fileReadResult = fileReadCalls[0] as any; + expect(fileReadResult.result?.success).toBe(true); + + // ASSERTION 3: Should contain the expected content + // File was created before init exited with error, so read succeeds + const content = fileReadResult.result?.content; + expect(content).toContain("Hello from init hook!"); + + // Wait for init to complete (with failure) + await waitForInitEnd(env, workspaceId, initWaitBuffer); + + // Verify init completed with FAILURE (exit code 1) + const initEvents = collectInitEvents(env, workspaceId); + const initEndEvent = initEvents.find((e) => isInitEnd(e)); + expect(initEndEvent).toBeDefined(); + if (initEndEvent && isInitEnd(initEndEvent)) { + expect(initEndEvent.exitCode).toBe(1); + } + + // ======================================================================== + // SECOND MESSAGE: Verify init state persistence (with failed init) + // ======================================================================== + // After init completes (even with failure), subsequent operations should + // NOT wait for init. This tests that waitForInit() correctly returns + // immediately when state.status !== "running" (whether "success" OR "error") + // ======================================================================== + + // Clear events to isolate second message + env.sentEvents.length = 0; + + const startSecondMessage = Date.now(); + + // Send another message to read the same file + const sendResult2 = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_SEND_MESSAGE, + workspaceId, + "Read init_created_file.txt again and confirm the content", + { + model: "anthropic:claude-haiku-4-5", + } + ); + + expect(sendResult2.success).toBe(true); + + // Wait for stream completion + const deadline2 = Date.now() + streamTimeout; + let streamComplete2 = false; + + while (Date.now() < deadline2 && !streamComplete2) { + const chatChannel = getChatChannel(workspaceId); + const chatEvents = env.sentEvents.filter((e) => e.channel === chatChannel); + + streamComplete2 = chatEvents.some( + (e) => + typeof e.data === "object" && + e.data !== null && + "type" in e.data && + e.data.type === "stream-end" + ); + + if (!streamComplete2) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } + + expect(streamComplete2).toBe(true); + + // Extract tool calls from second message + const toolCallEndEvents2 = env.sentEvents + .filter((e) => e.channel === chatChannel) + .map((e) => e.data as WorkspaceChatMessage) + .filter( + (msg) => + typeof msg === "object" && + msg !== null && + "type" in msg && + msg.type === "tool-call-end" + ); + + const fileReadCalls2 = toolCallEndEvents2.filter( + (msg: any) => msg.toolName === "file_read" + ); + + // ASSERTION 4: Second message should also have exactly ONE file_read + expect(fileReadCalls2.length).toBe(1); + + // ASSERTION 5: Second file_read should succeed (init already complete) + const fileReadResult2 = fileReadCalls2[0] as any; + expect(fileReadResult2.result?.success).toBe(true); + + // ASSERTION 6: Content should still be correct + const content2 = fileReadResult2.result?.content; + expect(content2).toContain("Hello from init hook!"); + + // ASSERTION 7: Second message should be MUCH faster than first + // First message had to wait ~5 seconds for init. Second should be instant. + const secondMessageDuration = Date.now() - startSecondMessage; + // Allow 15 seconds for API round-trip but should be way less than first message + // Increased timeout to account for CI runner variability + expect(secondMessageDuration).toBeLessThan(15000); + + // Log timing for debugging + console.log(`Second message completed in ${secondMessageDuration}ms (no init wait)`); + + // Cleanup workspace + await env.mockIpcRenderer.invoke(IPC_CHANNELS.WORKSPACE_REMOVE, workspaceId); + } finally { + await cleanupTestEnvironment(env); + await cleanupTempGitRepo(tempGitRepo); + } + }, + testTimeout + ); + } + ); +}); diff --git a/tests/integration/modelNotFound.test.ts b/tests/ipcMain/modelNotFound.test.ts similarity index 67% rename from tests/integration/modelNotFound.test.ts rename to tests/ipcMain/modelNotFound.test.ts index 99e6e620c..821c1d077 100644 --- a/tests/integration/modelNotFound.test.ts +++ b/tests/ipcMain/modelNotFound.test.ts @@ -1,6 +1,9 @@ import { setupWorkspace, shouldRunIntegrationTests, validateApiKeys } from "./setup"; -import { sendMessageWithModel, createStreamCollector, modelString } from "./helpers"; -import type { StreamErrorMessage } from "@/common/orpc/types"; +import { sendMessageWithModel, createEventCollector, waitFor, modelString } from "./helpers"; +import { IPC_CHANNELS } from "../../src/common/constants/ipc-constants"; +import type { Result } from "../../src/common/types/result"; +import type { SendMessageError } from "../../src/common/types/errors"; +import type { StreamErrorMessage } from "../../src/common/types/ipc"; // Skip all tests if TEST_INTEGRATION is not set const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip; @@ -10,25 +13,27 @@ if (shouldRunIntegrationTests()) { validateApiKeys(["ANTHROPIC_API_KEY", "OPENAI_API_KEY"]); } -describeIntegration("model_not_found error handling", () => { +describeIntegration("IpcMain model_not_found error handling", () => { test.concurrent( "should classify Anthropic 404 as model_not_found (not retryable)", async () => { const { env, workspaceId, cleanup } = await setupWorkspace("anthropic"); - const collector = createStreamCollector(env.orpc, workspaceId); - collector.start(); try { // Send a message with a non-existent model // Anthropic returns 404 with error.type === 'not_found_error' void sendMessageWithModel( - env, + env.mockIpcRenderer, workspaceId, "Hello", modelString("anthropic", "invalid-model-that-does-not-exist-xyz123") ); - // Wait for error event - await collector.waitForEvent("stream-error", 10000); + // Collect events to verify error classification + const collector = createEventCollector(env.sentEvents, workspaceId); + await waitFor(() => { + collector.collect(); + return collector.getEvents().some((e) => "type" in e && e.type === "stream-error"); + }, 10000); const events = collector.getEvents(); const errorEvent = events.find((e) => "type" in e && e.type === "stream-error") as @@ -41,7 +46,6 @@ describeIntegration("model_not_found error handling", () => { // This ensures it's marked as non-retryable in retryEligibility.ts expect(errorEvent?.errorType).toBe("model_not_found"); } finally { - collector.stop(); await cleanup(); } }, @@ -52,20 +56,22 @@ describeIntegration("model_not_found error handling", () => { "should classify OpenAI 400 model_not_found as model_not_found (not retryable)", async () => { const { env, workspaceId, cleanup } = await setupWorkspace("openai"); - const collector = createStreamCollector(env.orpc, workspaceId); - collector.start(); try { // Send a message with a non-existent model // OpenAI returns 400 with error.code === 'model_not_found' void sendMessageWithModel( - env, + env.mockIpcRenderer, workspaceId, "Hello", modelString("openai", "gpt-nonexistent-model-xyz123") ); - // Wait for error event - await collector.waitForEvent("stream-error", 10000); + // Collect events to verify error classification + const collector = createEventCollector(env.sentEvents, workspaceId); + await waitFor(() => { + collector.collect(); + return collector.getEvents().some((e) => "type" in e && e.type === "stream-error"); + }, 10000); const events = collector.getEvents(); const errorEvent = events.find((e) => "type" in e && e.type === "stream-error") as @@ -77,7 +83,6 @@ describeIntegration("model_not_found error handling", () => { // Bug: Error should be classified as 'model_not_found', not 'api' or 'unknown' expect(errorEvent?.errorType).toBe("model_not_found"); } finally { - collector.stop(); await cleanup(); } }, diff --git a/tests/integration/ollama.test.ts b/tests/ipcMain/ollama.test.ts similarity index 87% rename from tests/integration/ollama.test.ts rename to tests/ipcMain/ollama.test.ts index dfb7c48a9..690bf6afd 100644 --- a/tests/integration/ollama.test.ts +++ b/tests/ipcMain/ollama.test.ts @@ -1,13 +1,13 @@ import { setupWorkspace, shouldRunIntegrationTests } from "./setup"; import { sendMessageWithModel, - createStreamCollector, + createEventCollector, assertStreamSuccess, extractTextFromEvents, modelString, + configureTestRetries, } from "./helpers"; import { spawn } from "child_process"; -import { loadTokenizerModules } from "../../src/node/utils/main/tokenizer"; // Skip all tests if TEST_INTEGRATION or TEST_OLLAMA is not set const shouldRunOllamaTests = shouldRunIntegrationTests() && process.env.TEST_OLLAMA === "1"; @@ -17,7 +17,9 @@ const describeOllama = shouldRunOllamaTests ? describe : describe.skip; // Tests require Ollama to be running and will pull models idempotently // Set TEST_OLLAMA=1 to enable these tests -const OLLAMA_MODEL = "gpt-oss:20b"; +// Use a smaller model for CI to reduce resource usage and download time +// while maintaining sufficient capability for tool calling tests +const OLLAMA_MODEL = "llama3.2:3b"; /** * Ensure Ollama model is available (idempotent). @@ -82,31 +84,27 @@ async function ensureOllamaModel(model: string): Promise { }); } -describeOllama("Ollama integration", () => { +describeOllama("IpcMain Ollama integration tests", () => { // Enable retries in CI for potential network flakiness with Ollama - if (process.env.CI && typeof jest !== "undefined" && jest.retryTimes) { - jest.retryTimes(3, { logErrorsBeforeRetry: true }); - } + configureTestRetries(3); // Load tokenizer modules and ensure model is available before all tests beforeAll(async () => { // Load tokenizers (takes ~14s) - + const { loadTokenizerModules } = await import("../../src/node/utils/main/tokenizer"); await loadTokenizerModules(); // Ensure Ollama model is available (idempotent - fast if cached) await ensureOllamaModel(OLLAMA_MODEL); - }, 150000); // 150s timeout for tokenizer loading + potential model pull + }); // 150s timeout handling managed internally or via global config test("should successfully send message to Ollama and receive response", async () => { // Setup test environment const { env, workspaceId, cleanup } = await setupWorkspace("ollama"); - const collector = createStreamCollector(env.orpc, workspaceId); - collector.start(); try { // Send a simple message to verify basic connectivity const result = await sendMessageWithModel( - env, + env.mockIpcRenderer, workspaceId, "Say 'hello' and nothing else", modelString("ollama", OLLAMA_MODEL) @@ -115,10 +113,11 @@ describeOllama("Ollama integration", () => { // Verify the IPC call succeeded expect(result.success).toBe(true); - // Wait for stream completion - const streamEnd = await collector.waitForEvent("stream-end", 30000); + // Collect and verify stream events + const collector = createEventCollector(env.sentEvents, workspaceId); + const streamEnd = await collector.waitForEvent("stream-end", 60000); - expect(streamEnd).toBeDefined(); + expect(streamEnd).not.toBeNull(); assertStreamSuccess(collector); // Verify we received deltas @@ -129,19 +128,16 @@ describeOllama("Ollama integration", () => { const text = extractTextFromEvents(deltas).toLowerCase(); expect(text).toMatch(/hello/i); } finally { - collector.stop(); await cleanup(); } }, 45000); // Ollama can be slower than cloud APIs, especially first run test("should successfully call tools with Ollama", async () => { const { env, workspaceId, cleanup } = await setupWorkspace("ollama"); - const collector = createStreamCollector(env.orpc, workspaceId); - collector.start(); try { // Ask for current time which should trigger bash tool const result = await sendMessageWithModel( - env, + env.mockIpcRenderer, workspaceId, "What is the current date and time? Use the bash tool to find out.", modelString("ollama", OLLAMA_MODEL) @@ -150,6 +146,7 @@ describeOllama("Ollama integration", () => { expect(result.success).toBe(true); // Wait for stream to complete + const collector = createEventCollector(env.sentEvents, workspaceId); await collector.waitForEvent("stream-end", 60000); assertStreamSuccess(collector); @@ -169,19 +166,16 @@ describeOllama("Ollama integration", () => { // Should mention time or date in response expect(responseText).toMatch(/time|date|am|pm|2024|2025/i); } finally { - collector.stop(); await cleanup(); } }, 90000); // Tool calling can take longer test("should handle file operations with Ollama", async () => { const { env, workspaceId, cleanup } = await setupWorkspace("ollama"); - const collector = createStreamCollector(env.orpc, workspaceId); - collector.start(); try { // Ask to read a file that should exist const result = await sendMessageWithModel( - env, + env.mockIpcRenderer, workspaceId, "Read the README.md file and tell me what the first heading says.", modelString("ollama", OLLAMA_MODEL) @@ -190,7 +184,8 @@ describeOllama("Ollama integration", () => { expect(result.success).toBe(true); // Wait for stream to complete - await collector.waitForEvent("stream-end", 60000); + const collector = createEventCollector(env.sentEvents, workspaceId); + await collector.waitForEvent("stream-end", 90000); assertStreamSuccess(collector); @@ -208,19 +203,16 @@ describeOllama("Ollama integration", () => { expect(responseText).toMatch(/mux|readme|heading/i); } finally { - collector.stop(); await cleanup(); } }, 90000); // File operations with reasoning test("should handle errors gracefully when Ollama is not running", async () => { const { env, workspaceId, cleanup } = await setupWorkspace("ollama"); - const collector = createStreamCollector(env.orpc, workspaceId); - collector.start(); try { // Override baseUrl to point to non-existent server const result = await sendMessageWithModel( - env, + env.mockIpcRenderer, workspaceId, "This should fail", modelString("ollama", OLLAMA_MODEL), @@ -237,10 +229,10 @@ describeOllama("Ollama integration", () => { expect(result.error).toBeDefined(); } else { // If it succeeds, that's fine - Ollama is running + const collector = createEventCollector(env.sentEvents, workspaceId); await collector.waitForEvent("stream-end", 30000); } } finally { - collector.stop(); await cleanup(); } }, 45000); diff --git a/tests/integration/openai-web-search.test.ts b/tests/ipcMain/openai-web-search.test.ts similarity index 81% rename from tests/integration/openai-web-search.test.ts rename to tests/ipcMain/openai-web-search.test.ts index dafea5581..13da4d61e 100644 --- a/tests/integration/openai-web-search.test.ts +++ b/tests/ipcMain/openai-web-search.test.ts @@ -1,9 +1,10 @@ import { setupWorkspace, shouldRunIntegrationTests, validateApiKeys } from "./setup"; import { sendMessageWithModel, - createStreamCollector, + createEventCollector, assertStreamSuccess, modelString, + configureTestRetries, } from "./helpers"; // Skip all tests if TEST_INTEGRATION is not set @@ -16,17 +17,13 @@ if (shouldRunIntegrationTests()) { describeIntegration("OpenAI web_search integration tests", () => { // Enable retries in CI for flaky API tests - if (process.env.CI && typeof jest !== "undefined" && jest.retryTimes) { - jest.retryTimes(3, { logErrorsBeforeRetry: true }); - } + configureTestRetries(3); test.concurrent( "should handle reasoning + web_search without itemId errors", async () => { // Setup test environment with OpenAI const { env, workspaceId, cleanup } = await setupWorkspace("openai"); - const collector = createStreamCollector(env.orpc, workspaceId); - collector.start(); try { // This prompt reliably triggers the reasoning + web_search bug: // 1. Weather search triggers web_search (real-time data) @@ -35,21 +32,24 @@ describeIntegration("OpenAI web_search integration tests", () => { // This combination exposed the itemId bug on main branch // Note: Previous prompt (gold price + Collatz) caused excessive tool loops in CI const result = await sendMessageWithModel( - env, + env.mockIpcRenderer, workspaceId, "Use web search to find the current weather in San Francisco. " + "Then tell me if it's a good day for a picnic.", modelString("openai", "gpt-5.1-codex-mini"), { - thinkingLevel: "medium", // Ensure reasoning without excessive deliberation + thinkingLevel: "low", // Ensure reasoning without excessive deliberation } ); // Verify the IPC call succeeded expect(result.success).toBe(true); - // Wait for stream to complete (90s should be enough for simple weather + analysis) - const streamEnd = await collector.waitForEvent("stream-end", 90000); + // Collect and verify stream events + const collector = createEventCollector(env.sentEvents, workspaceId); + + // Wait for stream to complete (150s should be enough for simple weather + analysis) + const streamEnd = await collector.waitForEvent("stream-end", 150000); expect(streamEnd).toBeDefined(); // Verify no errors occurred - this is the KEY test @@ -57,7 +57,8 @@ describeIntegration("OpenAI web_search integration tests", () => { // "Item 'ws_...' of type 'web_search_call' was provided without its required 'reasoning' item" assertStreamSuccess(collector); - // Get all events and verify both reasoning and web_search occurred + // Collect all events and verify both reasoning and web_search occurred + collector.collect(); const events = collector.getEvents(); // Verify we got reasoning (this is what triggers the bug) @@ -80,10 +81,9 @@ describeIntegration("OpenAI web_search integration tests", () => { const deltas = collector.getDeltas(); expect(deltas.length).toBeGreaterThan(0); } finally { - collector.stop(); await cleanup(); } }, - 120000 // 120 second timeout - reasoning + web_search should complete faster with simpler task + 180000 // 180 second timeout - reasoning + web_search should complete faster with simpler task ); }); diff --git a/tests/integration/projectCreate.test.ts b/tests/ipcMain/projectCreate.test.ts similarity index 74% rename from tests/integration/projectCreate.test.ts rename to tests/ipcMain/projectCreate.test.ts index 20be21e4f..def98596e 100644 --- a/tests/integration/projectCreate.test.ts +++ b/tests/ipcMain/projectCreate.test.ts @@ -12,7 +12,7 @@ import * as path from "path"; import * as os from "os"; import { shouldRunIntegrationTests, createTestEnvironment, cleanupTestEnvironment } from "./setup"; import type { TestEnvironment } from "./setup"; -import { resolveOrpcClient } from "./helpers"; +import { IPC_CHANNELS } from "../../src/common/constants/ipc-constants"; const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip; @@ -30,17 +30,17 @@ describeIntegration("PROJECT_CREATE IPC Handler", () => { try { // Try to create project with tilde path const tildeProjectPath = `~/${testDirName}`; - const client = resolveOrpcClient(env); - const result = await client.projects.create({ projectPath: tildeProjectPath }); + const result = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.PROJECT_CREATE, + tildeProjectPath + ); // Should succeed - if (!result.success) { - throw new Error(`Expected success but got: ${result.error}`); - } + expect(result.success).toBe(true); expect(result.data.normalizedPath).toBe(homeProjectPath); // Verify the project was added with expanded path (not tilde path) - const projectsList = await client.projects.list(); + const projectsList = await env.mockIpcRenderer.invoke(IPC_CHANNELS.PROJECT_LIST); const projectPaths = projectsList.map((p: [string, unknown]) => p[0]); // Should contain the expanded path @@ -59,12 +59,9 @@ describeIntegration("PROJECT_CREATE IPC Handler", () => { const env = await createTestEnvironment(); const tempProjectDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-project-test-")); const nonExistentPath = "/this/path/definitely/does/not/exist/mux-test-12345"; - const client = resolveOrpcClient(env); - const result = await client.projects.create({ projectPath: nonExistentPath }); + const result = await env.mockIpcRenderer.invoke(IPC_CHANNELS.PROJECT_CREATE, nonExistentPath); - if (result.success) { - throw new Error("Expected failure but got success"); - } + expect(result.success).toBe(false); expect(result.error).toContain("does not exist"); await cleanupTestEnvironment(env); @@ -75,12 +72,12 @@ describeIntegration("PROJECT_CREATE IPC Handler", () => { const env = await createTestEnvironment(); const tempProjectDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-project-test-")); const nonExistentTildePath = "~/this-directory-should-not-exist-mux-test-12345"; - const client = resolveOrpcClient(env); - const result = await client.projects.create({ projectPath: nonExistentTildePath }); + const result = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.PROJECT_CREATE, + nonExistentTildePath + ); - if (result.success) { - throw new Error("Expected failure but got success"); - } + expect(result.success).toBe(false); expect(result.error).toContain("does not exist"); await cleanupTestEnvironment(env); @@ -93,12 +90,9 @@ describeIntegration("PROJECT_CREATE IPC Handler", () => { const testFile = path.join(tempProjectDir, "test-file.txt"); await fs.writeFile(testFile, "test content"); - const client = resolveOrpcClient(env); - const result = await client.projects.create({ projectPath: testFile }); + const result = await env.mockIpcRenderer.invoke(IPC_CHANNELS.PROJECT_CREATE, testFile); - if (result.success) { - throw new Error("Expected failure but got success"); - } + expect(result.success).toBe(false); expect(result.error).toContain("not a directory"); await cleanupTestEnvironment(env); @@ -109,12 +103,9 @@ describeIntegration("PROJECT_CREATE IPC Handler", () => { const env = await createTestEnvironment(); const tempProjectDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-project-test-")); - const client = resolveOrpcClient(env); - const result = await client.projects.create({ projectPath: tempProjectDir }); + const result = await env.mockIpcRenderer.invoke(IPC_CHANNELS.PROJECT_CREATE, tempProjectDir); - if (result.success) { - throw new Error("Expected failure but got success"); - } + expect(result.success).toBe(false); expect(result.error).toContain("Not a git repository"); await cleanupTestEnvironment(env); @@ -127,16 +118,13 @@ describeIntegration("PROJECT_CREATE IPC Handler", () => { // Create .git directory to make it a valid git repo await fs.mkdir(path.join(tempProjectDir, ".git")); - const client = resolveOrpcClient(env); - const result = await client.projects.create({ projectPath: tempProjectDir }); + const result = await env.mockIpcRenderer.invoke(IPC_CHANNELS.PROJECT_CREATE, tempProjectDir); - if (!result.success) { - throw new Error(`Expected success but got: ${result.error}`); - } + expect(result.success).toBe(true); expect(result.data.normalizedPath).toBe(tempProjectDir); // Verify project was added - const projectsList = await client.projects.list(); + const projectsList = await env.mockIpcRenderer.invoke(IPC_CHANNELS.PROJECT_LIST); const projectPaths = projectsList.map((p: [string, unknown]) => p[0]); expect(projectPaths).toContain(tempProjectDir); @@ -152,16 +140,13 @@ describeIntegration("PROJECT_CREATE IPC Handler", () => { // Create a path with .. that resolves to tempProjectDir const pathWithDots = path.join(tempProjectDir, "..", path.basename(tempProjectDir)); - const client = resolveOrpcClient(env); - const result = await client.projects.create({ projectPath: pathWithDots }); + const result = await env.mockIpcRenderer.invoke(IPC_CHANNELS.PROJECT_CREATE, pathWithDots); - if (!result.success) { - throw new Error(`Expected success but got: ${result.error}`); - } + expect(result.success).toBe(true); expect(result.data.normalizedPath).toBe(tempProjectDir); // Verify project was added with normalized path - const projectsList = await client.projects.list(); + const projectsList = await env.mockIpcRenderer.invoke(IPC_CHANNELS.PROJECT_LIST); const projectPaths = projectsList.map((p: [string, unknown]) => p[0]); expect(projectPaths).toContain(tempProjectDir); @@ -176,17 +161,14 @@ describeIntegration("PROJECT_CREATE IPC Handler", () => { await fs.mkdir(path.join(tempProjectDir, ".git")); // Create first project - const client = resolveOrpcClient(env); - const result1 = await client.projects.create({ projectPath: tempProjectDir }); + const result1 = await env.mockIpcRenderer.invoke(IPC_CHANNELS.PROJECT_CREATE, tempProjectDir); expect(result1.success).toBe(true); // Try to create the same project with a path that has .. const pathWithDots = path.join(tempProjectDir, "..", path.basename(tempProjectDir)); - const result2 = await client.projects.create({ projectPath: pathWithDots }); + const result2 = await env.mockIpcRenderer.invoke(IPC_CHANNELS.PROJECT_CREATE, pathWithDots); - if (result2.success) { - throw new Error("Expected failure but got success"); - } + expect(result2.success).toBe(false); expect(result2.error).toContain("already exists"); await cleanupTestEnvironment(env); diff --git a/tests/integration/queuedMessages.test.ts b/tests/ipcMain/queuedMessages.test.ts similarity index 55% rename from tests/integration/queuedMessages.test.ts rename to tests/ipcMain/queuedMessages.test.ts index bbf650ae0..7e1a72b45 100644 --- a/tests/integration/queuedMessages.test.ts +++ b/tests/ipcMain/queuedMessages.test.ts @@ -2,19 +2,19 @@ import { setupWorkspace, shouldRunIntegrationTests, validateApiKeys } from "./se import { sendMessageWithModel, sendMessage, - createStreamCollector, + createEventCollector, waitFor, TEST_IMAGES, modelString, - resolveOrpcClient, - StreamCollector, } from "./helpers"; -import { isQueuedMessageChanged, isRestoreToInput } from "@/common/orpc/types"; -import type { WorkspaceChatMessage } from "@/common/orpc/types"; - -// Type aliases for queued message events (extracted from schema union) -type QueuedMessageChangedEvent = Extract; -type RestoreToInputEvent = Extract; +import type { EventCollector } from "./helpers"; +import { + IPC_CHANNELS, + isQueuedMessageChanged, + isRestoreToInput, + QueuedMessageChangedEvent, + RestoreToInputEvent, +} from "@/common/types/ipc"; // Skip all tests if TEST_INTEGRATION is not set const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip; @@ -25,18 +25,10 @@ if (shouldRunIntegrationTests()) { } // Helper: Get queued messages from latest queued-message-changed event -// If wait=true, waits for a new event first (use when expecting a change) -// If wait=false, returns current state immediately (use when checking final state) -async function getQueuedMessages( - collector: StreamCollector, - options: { wait?: boolean; timeoutMs?: number } = {} -): Promise { - const { wait = true, timeoutMs = 5000 } = options; - - if (wait) { - await waitForQueuedMessageEvent(collector, timeoutMs); - } +async function getQueuedMessages(collector: EventCollector, timeoutMs = 5000): Promise { + await waitForQueuedMessageEvent(collector, timeoutMs); + collector.collect(); const events = collector.getEvents(); const queuedEvents = events.filter(isQueuedMessageChanged); @@ -49,33 +41,21 @@ async function getQueuedMessages( return latestEvent.queuedMessages; } -// Helper: Wait for a NEW queued-message-changed event (one that wasn't seen before) +// Helper: Wait for queued-message-changed event async function waitForQueuedMessageEvent( - collector: StreamCollector, + collector: EventCollector, timeoutMs = 5000 ): Promise { - // Get current count of queued-message-changed events - const currentEvents = collector.getEvents().filter(isQueuedMessageChanged); - const currentCount = currentEvents.length; - - // Wait for a new event - const startTime = Date.now(); - while (Date.now() - startTime < timeoutMs) { - const events = collector.getEvents().filter(isQueuedMessageChanged); - if (events.length > currentCount) { - // Return the newest event - return events[events.length - 1]; - } - await new Promise((resolve) => setTimeout(resolve, 100)); + const event = await collector.waitForEvent("queued-message-changed", timeoutMs); + if (!event || !isQueuedMessageChanged(event)) { + return null; } - - // Timeout - return null - return null; + return event; } // Helper: Wait for restore-to-input event async function waitForRestoreToInputEvent( - collector: StreamCollector, + collector: EventCollector, timeoutMs = 5000 ): Promise { const event = await collector.waitForEvent("restore-to-input", timeoutMs); @@ -85,12 +65,7 @@ async function waitForRestoreToInputEvent( return event; } -describeIntegration("Queued messages", () => { - // Enable retries in CI for flaky API tests - if (process.env.CI && typeof jest !== "undefined" && jest.retryTimes) { - jest.retryTimes(3, { logErrorsBeforeRetry: true }); - } - +describeIntegration("IpcMain queuedMessages integration tests", () => { test.concurrent( "should queue message during streaming and auto-send on stream end", async () => { @@ -98,19 +73,18 @@ describeIntegration("Queued messages", () => { try { // Start initial stream void sendMessageWithModel( - env, + env.mockIpcRenderer, workspaceId, "Say 'FIRST' and nothing else", modelString("anthropic", "claude-sonnet-4-5") ); - const collector1 = createStreamCollector(env.orpc, workspaceId); - collector1.start(); + const collector1 = createEventCollector(env.sentEvents, workspaceId); await collector1.waitForEvent("stream-start", 5000); // Queue a message while streaming const queueResult = await sendMessageWithModel( - env, + env.mockIpcRenderer, workspaceId, "Say 'SECOND' and nothing else", modelString("anthropic", "claude-sonnet-4-5") @@ -126,22 +100,27 @@ describeIntegration("Queued messages", () => { // Wait for first stream to complete (this triggers auto-send) await collector1.waitForEvent("stream-end", 15000); - // Wait for queue to be cleared (happens before auto-send starts new stream) - // The sendQueuedMessages() clears queue and emits event before sending - const clearEvent = await waitForQueuedMessageEvent(collector1, 5000); - expect(clearEvent?.queuedMessages).toEqual([]); - // Wait for auto-send to emit second user message (happens async after stream-end) - // The second stream starts after auto-send - wait for the second stream-start - await collector1.waitForEvent("stream-start", 5000); + const autoSendHappened = await waitFor(() => { + collector1.collect(); + const userMessages = collector1 + .getEvents() + .filter((e) => "role" in e && e.role === "user"); + return userMessages.length === 2; // First + auto-sent + }, 5000); + expect(autoSendHappened).toBe(true); + + // Clear events to track second stream separately + env.sentEvents.length = 0; // Wait for second stream to complete - await collector1.waitForEvent("stream-end", 15000); + const collector2 = createEventCollector(env.sentEvents, workspaceId); + await collector2.waitForEvent("stream-start", 5000); + await collector2.waitForEvent("stream-end", 15000); - // Verify queue is still empty (check current state) - const queuedAfter = await getQueuedMessages(collector1, { wait: false }); + // Verify queue was cleared after auto-send + const queuedAfter = await getQueuedMessages(collector2); expect(queuedAfter).toEqual([]); - collector1.stop(); } finally { await cleanup(); } @@ -156,19 +135,18 @@ describeIntegration("Queued messages", () => { try { // Start a stream void sendMessageWithModel( - env, + env.mockIpcRenderer, workspaceId, "Count to 10 slowly", modelString("anthropic", "claude-sonnet-4-5") ); - const collector = createStreamCollector(env.orpc, workspaceId); - collector.start(); + const collector = createEventCollector(env.sentEvents, workspaceId); await collector.waitForEvent("stream-start", 5000); // Queue a message await sendMessageWithModel( - env, + env.mockIpcRenderer, workspaceId, "This message should be restored", modelString("anthropic", "claude-sonnet-4-5") @@ -179,32 +157,29 @@ describeIntegration("Queued messages", () => { expect(queued).toEqual(["This message should be restored"]); // Interrupt the stream - const client = resolveOrpcClient(env); - const interruptResult = await client.workspace.interruptStream({ workspaceId }); + const interruptResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_INTERRUPT_STREAM, + workspaceId + ); expect(interruptResult.success).toBe(true); // Wait for stream abort await collector.waitForEvent("stream-abort", 5000); - // Wait for queue to be cleared (happens before restore-to-input) - const clearEvent = await waitForQueuedMessageEvent(collector, 5000); - expect(clearEvent?.queuedMessages).toEqual([]); - // Wait for restore-to-input event const restoreEvent = await waitForRestoreToInputEvent(collector); expect(restoreEvent).toBeDefined(); expect(restoreEvent?.text).toBe("This message should be restored"); expect(restoreEvent?.workspaceId).toBe(workspaceId); - // Verify queue is still empty - const queuedAfter = await getQueuedMessages(collector, { wait: false }); + // Verify queue was cleared + const queuedAfter = await getQueuedMessages(collector); expect(queuedAfter).toEqual([]); - collector.stop(); } finally { await cleanup(); } }, - 30000 // Increased timeout for abort handling + 20000 ); test.concurrent( @@ -214,46 +189,48 @@ describeIntegration("Queued messages", () => { try { // Start a stream void sendMessageWithModel( - env, + env.mockIpcRenderer, workspaceId, "Say 'FIRST' and nothing else", modelString("anthropic", "claude-sonnet-4-5") ); - const collector1 = createStreamCollector(env.orpc, workspaceId); - collector1.start(); + const collector1 = createEventCollector(env.sentEvents, workspaceId); await collector1.waitForEvent("stream-start", 5000); - // Queue multiple messages, waiting for each queued-message-changed event - await sendMessage(env, workspaceId, "Message 1"); - await waitForQueuedMessageEvent(collector1); - - await sendMessage(env, workspaceId, "Message 2"); - await waitForQueuedMessageEvent(collector1); + // Queue multiple messages + await sendMessage(env.mockIpcRenderer, workspaceId, "Message 1"); + await sendMessage(env.mockIpcRenderer, workspaceId, "Message 2"); + await sendMessage(env.mockIpcRenderer, workspaceId, "Message 3"); - await sendMessage(env, workspaceId, "Message 3"); - await waitForQueuedMessageEvent(collector1); + // Verify all messages queued + // Wait until we have 3 messages in the queue state + const success = await waitFor(async () => { + const msgs = await getQueuedMessages(collector1, 500); + return msgs.length === 3; + }, 5000); + expect(success).toBe(true); - // Verify all messages queued (check current state, don't wait for new event) - const queued = await getQueuedMessages(collector1, { wait: false }); + const queued = await getQueuedMessages(collector1); expect(queued).toEqual(["Message 1", "Message 2", "Message 3"]); // Wait for first stream to complete (this triggers auto-send) await collector1.waitForEvent("stream-end", 15000); - // Wait for the SECOND stream-start (auto-send creates a new stream) - await collector1.waitForEventN("stream-start", 2, 10000); - - const userMessages = collector1 - .getEvents() - .filter((e: WorkspaceChatMessage) => "role" in e && e.role === "user"); - expect(userMessages.length).toBe(2); // First message + auto-sent combined message - collector1.stop(); + // Wait for auto-send to emit the combined message + const autoSendHappened = await waitFor(() => { + collector1.collect(); + const userMessages = collector1 + .getEvents() + .filter((e) => "role" in e && e.role === "user"); + return userMessages.length === 2; // First message + auto-sent combined message + }, 5000); + expect(autoSendHappened).toBe(true); } finally { await cleanup(); } }, - 45000 // Increased timeout for multiple messages + 30000 ); test.concurrent( @@ -263,18 +240,17 @@ describeIntegration("Queued messages", () => { try { // Start a stream void sendMessageWithModel( - env, + env.mockIpcRenderer, workspaceId, "Say 'FIRST' and nothing else", modelString("anthropic", "claude-sonnet-4-5") ); - const collector1 = createStreamCollector(env.orpc, workspaceId); - collector1.start(); + const collector1 = createEventCollector(env.sentEvents, workspaceId); await collector1.waitForEvent("stream-start", 5000); // Queue message with image - await sendMessage(env, workspaceId, "Describe this image", { + await sendMessage(env.mockIpcRenderer, workspaceId, "Describe this image", { model: "anthropic:claude-sonnet-4-5", imageParts: [TEST_IMAGES.RED_PIXEL], }); @@ -288,18 +264,27 @@ describeIntegration("Queued messages", () => { // Wait for first stream to complete (this triggers auto-send) await collector1.waitForEvent("stream-end", 15000); - // Wait for queue to be cleared - const clearEvent = await waitForQueuedMessageEvent(collector1, 5000); - expect(clearEvent?.queuedMessages).toEqual([]); + // Wait for auto-send to emit the message with image + const autoSendHappened = await waitFor(() => { + collector1.collect(); + const userMessages = collector1 + .getEvents() + .filter((e) => "role" in e && e.role === "user"); + return userMessages.length === 2; + }, 5000); + expect(autoSendHappened).toBe(true); - // Wait for auto-send stream to start and complete - await collector1.waitForEvent("stream-start", 5000); - await collector1.waitForEvent("stream-end", 15000); + // Clear events to track second stream separately + env.sentEvents.length = 0; - // Verify queue is still empty - const queuedAfter = await getQueuedMessages(collector1, { wait: false }); + // Wait for auto-send stream + const collector2 = createEventCollector(env.sentEvents, workspaceId); + await collector2.waitForEvent("stream-start", 5000); + await collector2.waitForEvent("stream-end", 15000); + + // Verify queue was cleared after auto-send + const queuedAfter = await getQueuedMessages(collector2); expect(queuedAfter).toEqual([]); - collector1.stop(); } finally { await cleanup(); } @@ -314,18 +299,17 @@ describeIntegration("Queued messages", () => { try { // Start a stream void sendMessageWithModel( - env, + env.mockIpcRenderer, workspaceId, "Say 'FIRST' and nothing else", modelString("anthropic", "claude-sonnet-4-5") ); - const collector1 = createStreamCollector(env.orpc, workspaceId); - collector1.start(); + const collector1 = createEventCollector(env.sentEvents, workspaceId); await collector1.waitForEvent("stream-start", 5000); // Queue image-only message (empty text) - await sendMessage(env, workspaceId, "", { + await sendMessage(env.mockIpcRenderer, workspaceId, "", { model: "anthropic:claude-sonnet-4-5", imageParts: [TEST_IMAGES.RED_PIXEL], }); @@ -339,15 +323,27 @@ describeIntegration("Queued messages", () => { // Wait for first stream to complete (this triggers auto-send) await collector1.waitForEvent("stream-end", 15000); - // Wait for auto-send stream to start and complete - await collector1.waitForEvent("stream-start", 5000); - await collector1.waitForEvent("stream-end", 15000); + // Wait for auto-send to emit the image-only message + const autoSendHappened = await waitFor(() => { + collector1.collect(); + const userMessages = collector1 + .getEvents() + .filter((e) => "role" in e && e.role === "user"); + return userMessages.length === 2; + }, 5000); + expect(autoSendHappened).toBe(true); + + // Clear events to track second stream separately + env.sentEvents.length = 0; + + // Wait for auto-send stream + const collector2 = createEventCollector(env.sentEvents, workspaceId); + await collector2.waitForEvent("stream-start", 5000); + await collector2.waitForEvent("stream-end", 15000); // Verify queue was cleared after auto-send - // Use wait: false since the queue-clearing event already happened - const queuedAfter = await getQueuedMessages(collector1, { wait: false }); + const queuedAfter = await getQueuedMessages(collector2); expect(queuedAfter).toEqual([]); - collector1.stop(); } finally { await cleanup(); } @@ -362,22 +358,21 @@ describeIntegration("Queued messages", () => { try { // Start a stream void sendMessageWithModel( - env, + env.mockIpcRenderer, workspaceId, "Say 'FIRST' and nothing else", modelString("anthropic", "claude-sonnet-4-5") ); - const collector1 = createStreamCollector(env.orpc, workspaceId); - collector1.start(); + const collector1 = createEventCollector(env.sentEvents, workspaceId); await collector1.waitForEvent("stream-start", 5000); // Queue messages with different options - await sendMessage(env, workspaceId, "Message 1", { + await sendMessage(env.mockIpcRenderer, workspaceId, "Message 1", { model: "anthropic:claude-haiku-4-5", thinkingLevel: "off", }); - await sendMessage(env, workspaceId, "Message 2", { + await sendMessage(env.mockIpcRenderer, workspaceId, "Message 2", { model: "anthropic:claude-sonnet-4-5", thinkingLevel: "high", }); @@ -385,14 +380,28 @@ describeIntegration("Queued messages", () => { // Wait for first stream to complete (this triggers auto-send) await collector1.waitForEvent("stream-end", 15000); - // Wait for auto-send stream to start (verifies the second stream began) - const streamStart = await collector1.waitForEvent("stream-start", 5000); + // Wait for auto-send to emit the combined message + const autoSendHappened = await waitFor(() => { + collector1.collect(); + const userMessages = collector1 + .getEvents() + .filter((e) => "role" in e && e.role === "user"); + return userMessages.length === 2; + }, 5000); + expect(autoSendHappened).toBe(true); + + // Clear events to track second stream separately + env.sentEvents.length = 0; + + // Wait for auto-send stream + const collector2 = createEventCollector(env.sentEvents, workspaceId); + const streamStart = await collector2.waitForEvent("stream-start", 5000); + if (streamStart && "model" in streamStart) { expect(streamStart.model).toContain("claude-sonnet-4-5"); } - await collector1.waitForEvent("stream-end", 15000); - collector1.stop(); + await collector2.waitForEvent("stream-end", 15000); } finally { await cleanup(); } @@ -407,14 +416,13 @@ describeIntegration("Queued messages", () => { try { // Start a stream void sendMessageWithModel( - env, + env.mockIpcRenderer, workspaceId, "Say 'FIRST' and nothing else", modelString("anthropic", "claude-sonnet-4-5") ); - const collector1 = createStreamCollector(env.orpc, workspaceId); - collector1.start(); + const collector1 = createEventCollector(env.sentEvents, workspaceId); await collector1.waitForEvent("stream-start", 5000); // Queue a compaction request @@ -424,10 +432,15 @@ describeIntegration("Queued messages", () => { parsed: { maxOutputTokens: 3000 }, }; - await sendMessage(env, workspaceId, "Summarize this conversation into a compact form...", { - model: "anthropic:claude-sonnet-4-5", - muxMetadata: compactionMetadata, - }); + await sendMessage( + env.mockIpcRenderer, + workspaceId, + "Summarize this conversation into a compact form...", + { + model: "anthropic:claude-sonnet-4-5", + muxMetadata: compactionMetadata, + } + ); // Wait for queued-message-changed event const queuedEvent = await waitForQueuedMessageEvent(collector1); @@ -436,18 +449,27 @@ describeIntegration("Queued messages", () => { // Wait for first stream to complete (this triggers auto-send) await collector1.waitForEvent("stream-end", 15000); - // Wait for queue to be cleared - const clearEvent = await waitForQueuedMessageEvent(collector1, 5000); - expect(clearEvent?.queuedMessages).toEqual([]); + // Wait for auto-send to emit the compaction message + const autoSendHappened = await waitFor(() => { + collector1.collect(); + const userMessages = collector1 + .getEvents() + .filter((e) => "role" in e && e.role === "user"); + return userMessages.length === 2; + }, 5000); + expect(autoSendHappened).toBe(true); - // Wait for auto-send stream to start and complete - await collector1.waitForEvent("stream-start", 5000); - await collector1.waitForEvent("stream-end", 15000); + // Clear events to track second stream separately + env.sentEvents.length = 0; - // Verify queue is still empty - const queuedAfter = await getQueuedMessages(collector1, { wait: false }); + // Wait for auto-send stream + const collector2 = createEventCollector(env.sentEvents, workspaceId); + await collector2.waitForEvent("stream-start", 5000); + await collector2.waitForEvent("stream-end", 15000); + + // Verify queue was cleared after auto-send + const queuedAfter = await getQueuedMessages(collector2); expect(queuedAfter).toEqual([]); - collector1.stop(); } finally { await cleanup(); } diff --git a/tests/integration/removeWorkspace.test.ts b/tests/ipcMain/removeWorkspace.test.ts similarity index 89% rename from tests/integration/removeWorkspace.test.ts rename to tests/ipcMain/removeWorkspace.test.ts index a54e1c5cc..b27e651e4 100644 --- a/tests/integration/removeWorkspace.test.ts +++ b/tests/ipcMain/removeWorkspace.test.ts @@ -13,6 +13,7 @@ import { shouldRunIntegrationTests, type TestEnvironment, } from "./setup"; +import { IPC_CHANNELS } from "../../src/common/constants/ipc-constants"; import { createTempGitRepo, cleanupTempGitRepo, @@ -53,15 +54,19 @@ async function executeBash( workspaceId: string, command: string ): Promise<{ output: string; exitCode: number }> { - const result = await env.orpc.workspace.executeBash({ workspaceId, script: command }); + const result = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_EXECUTE_BASH, + workspaceId, + command + ); - if (!result.success || !result.data) { - const errorMessage = "error" in result ? result.error : "unknown error"; - throw new Error(`Bash execution failed: ${errorMessage}`); + if (!result.success) { + throw new Error(`Bash execution failed: ${result.error}`); } + // Result is wrapped in Ok(), so data is the BashToolResult const bashResult = result.data; - return { output: bashResult.output ?? "", exitCode: bashResult.exitCode }; + return { output: bashResult.output, exitCode: bashResult.exitCode }; } /** @@ -165,7 +170,10 @@ describeIntegration("Workspace deletion integration tests", () => { expect(existsBefore).toBe(true); // Delete the workspace - const deleteResult = await env.orpc.workspace.remove({ workspaceId }); + const deleteResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_REMOVE, + workspaceId + ); if (!deleteResult.success) { console.error("Delete failed:", deleteResult.error); @@ -194,9 +202,10 @@ describeIntegration("Workspace deletion integration tests", () => { try { // Try to delete a workspace that doesn't exist - const deleteResult = await env.orpc.workspace.remove({ - workspaceId: "non-existent-workspace-id", - }); + const deleteResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_REMOVE, + "non-existent-workspace-id" + ); // Should succeed (idempotent operation) expect(deleteResult.success).toBe(true); @@ -231,8 +240,11 @@ describeIntegration("Workspace deletion integration tests", () => { // Verify it's gone (note: workspace is deleted, so we can't use executeBash on workspaceId anymore) // We'll verify via the delete operation and config check - // Delete via ORPC - should succeed and prune stale metadata - const deleteResult = await env.orpc.workspace.remove({ workspaceId }); + // Delete via IPC - should succeed and prune stale metadata + const deleteResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_REMOVE, + workspaceId + ); expect(deleteResult.success).toBe(true); // Verify workspace is no longer in config @@ -272,7 +284,10 @@ describeIntegration("Workspace deletion integration tests", () => { await makeWorkspaceDirty(env, workspaceId); // Attempt to delete without force should fail - const deleteResult = await env.orpc.workspace.remove({ workspaceId }); + const deleteResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_REMOVE, + workspaceId + ); expect(deleteResult.success).toBe(false); expect(deleteResult.error).toMatch( /uncommitted changes|worktree contains modified|contains modified or untracked files/i @@ -283,7 +298,9 @@ describeIntegration("Workspace deletion integration tests", () => { expect(stillExists).toBe(true); // Cleanup: force delete for cleanup - await env.orpc.workspace.remove({ workspaceId, options: { force: true } }); + await env.mockIpcRenderer.invoke(IPC_CHANNELS.WORKSPACE_REMOVE, workspaceId, { + force: true, + }); } finally { await cleanupTestEnvironment(env); await cleanupTempGitRepo(tempGitRepo); @@ -314,10 +331,11 @@ describeIntegration("Workspace deletion integration tests", () => { await makeWorkspaceDirty(env, workspaceId); // Delete with force should succeed - const deleteResult = await env.orpc.workspace.remove({ + const deleteResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_REMOVE, workspaceId, - options: { force: true }, - }); + { force: true } + ); expect(deleteResult.success).toBe(true); // Verify workspace is no longer in config @@ -369,10 +387,11 @@ describeIntegration("Workspace deletion integration tests", () => { expect(submoduleExists).toBe(true); // Worktree has submodule - need force flag to delete via rm -rf fallback - const deleteResult = await env.orpc.workspace.remove({ + const deleteResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_REMOVE, workspaceId, - options: { force: true }, - }); + { force: true } + ); if (!deleteResult.success) { console.error("Delete with submodule failed:", deleteResult.error); } @@ -417,7 +436,10 @@ describeIntegration("Workspace deletion integration tests", () => { await fs.appendFile(path.join(workspacePath, "README.md"), "\nmodified"); // First attempt should fail (dirty worktree with submodules) - const deleteResult = await env.orpc.workspace.remove({ workspaceId }); + const deleteResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_REMOVE, + workspaceId + ); expect(deleteResult.success).toBe(false); expect(deleteResult.error).toMatch(/submodule/i); @@ -429,10 +451,11 @@ describeIntegration("Workspace deletion integration tests", () => { expect(stillExists).toBe(true); // Retry with force should succeed - const forceDeleteResult = await env.orpc.workspace.remove({ + const forceDeleteResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_REMOVE, workspaceId, - options: { force: true }, - }); + { force: true } + ); expect(forceDeleteResult.success).toBe(true); // Verify workspace was deleted @@ -504,7 +527,10 @@ describeIntegration("Workspace deletion integration tests", () => { expect(statusResult.output.trim()).toBe(""); // Should be clean // Attempt to delete without force should fail - const deleteResult = await env.orpc.workspace.remove({ workspaceId }); + const deleteResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_REMOVE, + workspaceId + ); expect(deleteResult.success).toBe(false); expect(deleteResult.error).toMatch(/unpushed.*commit|unpushed.*ref/i); @@ -513,7 +539,9 @@ describeIntegration("Workspace deletion integration tests", () => { expect(stillExists).toBe(true); // Cleanup: force delete for cleanup - await env.orpc.workspace.remove({ workspaceId, options: { force: true } }); + await env.mockIpcRenderer.invoke(IPC_CHANNELS.WORKSPACE_REMOVE, workspaceId, { + force: true, + }); } finally { await cleanupTestEnvironment(env); await cleanupTempGitRepo(tempGitRepo); @@ -562,10 +590,11 @@ describeIntegration("Workspace deletion integration tests", () => { expect(statusResult.output.trim()).toBe(""); // Should be clean // Delete with force should succeed - const deleteResult = await env.orpc.workspace.remove({ + const deleteResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_REMOVE, workspaceId, - options: { force: true }, - }); + { force: true } + ); expect(deleteResult.success).toBe(true); // Verify workspace was removed from config @@ -622,7 +651,10 @@ describeIntegration("Workspace deletion integration tests", () => { await executeBash(env, workspaceId, 'git commit -m "Second commit"'); // Attempt to delete - const deleteResult = await env.orpc.workspace.remove({ workspaceId }); + const deleteResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_REMOVE, + workspaceId + ); // Should fail with error containing commit details expect(deleteResult.success).toBe(false); @@ -631,7 +663,9 @@ describeIntegration("Workspace deletion integration tests", () => { expect(deleteResult.error).toContain("Second commit"); // Cleanup: force delete for cleanup - await env.orpc.workspace.remove({ workspaceId, options: { force: true } }); + await env.mockIpcRenderer.invoke(IPC_CHANNELS.WORKSPACE_REMOVE, workspaceId, { + force: true, + }); } finally { await cleanupTestEnvironment(env); await cleanupTempGitRepo(tempGitRepo); diff --git a/tests/integration/renameWorkspace.test.ts b/tests/ipcMain/renameWorkspace.test.ts similarity index 81% rename from tests/integration/renameWorkspace.test.ts rename to tests/ipcMain/renameWorkspace.test.ts index b417f853f..67203931b 100644 --- a/tests/integration/renameWorkspace.test.ts +++ b/tests/ipcMain/renameWorkspace.test.ts @@ -16,6 +16,7 @@ import { exec } from "child_process"; import { promisify } from "util"; import { shouldRunIntegrationTests, createTestEnvironment, cleanupTestEnvironment } from "./setup"; import type { TestEnvironment } from "./setup"; +import { IPC_CHANNELS } from "../../src/common/constants/ipc-constants"; import { createTempGitRepo, cleanupTempGitRepo, @@ -31,7 +32,6 @@ import { stopSSHServer, type SSHServerConfig, } from "../runtime/ssh-fixture"; -import { resolveOrpcClient } from "./helpers"; import type { RuntimeConfig } from "../../src/common/types/runtime"; const execAsync = promisify(exec); @@ -115,17 +115,24 @@ describeIntegration("WORKSPACE_RENAME with both runtimes", () => { const oldWorkspacePath = workspacePath; const oldSessionDir = env.config.getSessionDir(workspaceId); + // Clear events before rename + env.sentEvents.length = 0; + // Rename the workspace const newName = "renamed-branch"; - const client = resolveOrpcClient(env); - const renameResult = await client.workspace.rename({ workspaceId, newName }); + const renameResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_RENAME, + workspaceId, + newName + ); if (!renameResult.success) { - throw new Error(`Rename failed: ${renameResult.error}`); + console.error("Rename failed:", renameResult.error); } + expect(renameResult.success).toBe(true); // Get new workspace ID from backend (NEVER construct it in frontend) - expect(renameResult.data.newWorkspaceId).toBeDefined(); + expect(renameResult.data?.newWorkspaceId).toBeDefined(); const newWorkspaceId = renameResult.data.newWorkspaceId; // With stable IDs, workspace ID should NOT change during rename @@ -136,13 +143,16 @@ describeIntegration("WORKSPACE_RENAME with both runtimes", () => { expect(sessionDir).toBe(oldSessionDir); // Verify metadata was updated (name changed, path changed, but ID stays the same) - const newMetadataResult = await client.workspace.getInfo({ workspaceId }); + const newMetadataResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_GET_INFO, + workspaceId // Use same workspace ID + ); expect(newMetadataResult).toBeTruthy(); - expect(newMetadataResult?.id).toBe(workspaceId); // ID unchanged - expect(newMetadataResult?.name).toBe(newName); // Name updated + expect(newMetadataResult.id).toBe(workspaceId); // ID unchanged + expect(newMetadataResult.name).toBe(newName); // Name updated // Path DOES change (directory is renamed from old name to new name) - const newWorkspacePath = newMetadataResult?.namedWorkspacePath ?? ""; + const newWorkspacePath = newMetadataResult.namedWorkspacePath; expect(newWorkspacePath).not.toBe(oldWorkspacePath); expect(newWorkspacePath).toContain(newName); // New path includes new name @@ -160,8 +170,11 @@ describeIntegration("WORKSPACE_RENAME with both runtimes", () => { } expect(foundWorkspace).toBe(true); - // Note: Metadata events are now consumed via ORPC onMetadata subscription - // We verified the metadata update via getInfo() above + // Verify metadata event was emitted (update existing workspace) + const metadataEvents = env.sentEvents.filter( + (e) => e.channel === IPC_CHANNELS.WORKSPACE_METADATA + ); + expect(metadataEvents.length).toBe(1); await cleanup(); } finally { @@ -205,22 +218,21 @@ describeIntegration("WORKSPACE_RENAME with both runtimes", () => { ); // Try to rename first workspace to the second workspace's name - const client = resolveOrpcClient(env); - const renameResult = await client.workspace.rename({ - workspaceId: firstWorkspaceId, - newName: secondBranchName, - }); + const renameResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_RENAME, + firstWorkspaceId, + secondBranchName + ); expect(renameResult.success).toBe(false); - if (!renameResult.success) { - expect(renameResult.error).toContain("already exists"); - } + expect(renameResult.error).toContain("already exists"); // Verify original workspace still exists and wasn't modified - const metadataResult = await client.workspace.getInfo({ - workspaceId: firstWorkspaceId, - }); + const metadataResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_GET_INFO, + firstWorkspaceId + ); expect(metadataResult).toBeTruthy(); - expect(metadataResult?.id).toBe(firstWorkspaceId); + expect(metadataResult.id).toBe(firstWorkspaceId); await firstCleanup(); await secondCleanup(); diff --git a/tests/integration/resumeStream.test.ts b/tests/ipcMain/resumeStream.test.ts similarity index 54% rename from tests/integration/resumeStream.test.ts rename to tests/ipcMain/resumeStream.test.ts index 38facaee6..e43cc6e0d 100644 --- a/tests/integration/resumeStream.test.ts +++ b/tests/ipcMain/resumeStream.test.ts @@ -1,9 +1,10 @@ import { setupWorkspace, shouldRunIntegrationTests, validateApiKeys } from "./setup"; -import { sendMessageWithModel, createStreamCollector, modelString } from "./helpers"; -import { resolveOrpcClient } from "./helpers"; +import { sendMessageWithModel, createEventCollector, waitFor, modelString } from "./helpers"; +import { IPC_CHANNELS } from "../../src/common/constants/ipc-constants"; +import type { Result } from "../../src/common/types/result"; +import type { SendMessageError } from "../../src/common/types/errors"; import { HistoryService } from "../../src/node/services/historyService"; import { createMuxMessage } from "../../src/common/types/message"; -import type { WorkspaceChatMessage } from "@/common/orpc/types"; // Skip all tests if TEST_INTEGRATION is not set const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip; @@ -13,92 +14,96 @@ if (shouldRunIntegrationTests()) { validateApiKeys(["ANTHROPIC_API_KEY"]); } -describeIntegration("resumeStream", () => { - // Enable retries in CI for flaky API tests - if (process.env.CI && typeof jest !== "undefined" && jest.retryTimes) { - jest.retryTimes(3, { logErrorsBeforeRetry: true }); - } - +describeIntegration("IpcMain resumeStream integration tests", () => { test.concurrent( "should resume interrupted stream without new user message", async () => { const { env, workspaceId, cleanup } = await setupWorkspace("anthropic"); - const collector1 = createStreamCollector(env.orpc, workspaceId); - collector1.start(); try { // Start a stream with a bash command that outputs a specific word const expectedWord = "RESUMPTION_TEST_SUCCESS"; void sendMessageWithModel( - env, + env.mockIpcRenderer, workspaceId, `Run this bash command: for i in 1 2 3; do sleep 0.5; done && echo '${expectedWord}'`, modelString("anthropic", "claude-sonnet-4-5") ); // Wait for stream to start + const collector1 = createEventCollector(env.sentEvents, workspaceId); const streamStartEvent = await collector1.waitForEvent("stream-start", 5000); - expect(streamStartEvent).toBeDefined(); - - // Wait for at least some content or tool call - await new Promise((resolve) => setTimeout(resolve, 2000)); + expect(streamStartEvent).not.toBeNull(); + + // Wait for at least some content or tool call to start + await waitFor(() => { + collector1.collect(); + const hasToolCallStart = collector1 + .getEvents() + .some((e) => "type" in e && e.type === "tool-call-start"); + const hasContent = collector1 + .getEvents() + .some((e) => "type" in e && e.type === "stream-delta"); + return hasToolCallStart || hasContent; + }, 10000); // Interrupt the stream with interruptStream() - const client = resolveOrpcClient(env); - const interruptResult = await client.workspace.interruptStream({ workspaceId }); + const interruptResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_INTERRUPT_STREAM, + workspaceId + ); expect(interruptResult.success).toBe(true); // Wait for stream to be interrupted (abort or end event) - const abortOrEnd = await Promise.race([ - collector1.waitForEvent("stream-abort", 5000), - collector1.waitForEvent("stream-end", 5000), - ]); - expect(abortOrEnd).toBeDefined(); + const streamInterrupted = await waitFor(() => { + collector1.collect(); + const hasAbort = collector1 + .getEvents() + .some((e) => "type" in e && e.type === "stream-abort"); + const hasEnd = collector1.getEvents().some((e) => "type" in e && e.type === "stream-end"); + return hasAbort || hasEnd; + }, 5000); + expect(streamInterrupted).toBe(true); // Count user messages before resume (should be 1) + collector1.collect(); const userMessagesBefore = collector1 .getEvents() - .filter((e: WorkspaceChatMessage) => "role" in e && e.role === "user"); + .filter((e) => "role" in e && e.role === "user"); expect(userMessagesBefore.length).toBe(1); - collector1.stop(); - // Create a new collector for resume events - const collector2 = createStreamCollector(env.orpc, workspaceId); - collector2.start(); - - // Wait for history replay to complete (caught-up event) - await collector2.waitForEvent("caught-up", 5000); - - // Count user messages from history replay (should be 1 - the original message) - const userMessagesFromReplay = collector2 - .getEvents() - .filter((e: WorkspaceChatMessage) => "role" in e && e.role === "user"); - expect(userMessagesFromReplay.length).toBe(1); + // Clear events to track only resume events + env.sentEvents.length = 0; // Resume the stream (no new user message) - const resumeResult = await client.workspace.resumeStream({ + const resumeResult = (await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_RESUME_STREAM, workspaceId, - options: { model: "anthropic:claude-sonnet-4-5" }, - }); + { model: "anthropic:claude-sonnet-4-5" } + )) as Result; expect(resumeResult.success).toBe(true); + // Collect events after resume + const collector2 = createEventCollector(env.sentEvents, workspaceId); + // Wait for new stream to start const resumeStreamStart = await collector2.waitForEvent("stream-start", 5000); - expect(resumeStreamStart).toBeDefined(); + expect(resumeStreamStart).not.toBeNull(); // Wait for stream to complete const streamEnd = await collector2.waitForEvent("stream-end", 30000); - expect(streamEnd).toBeDefined(); + expect(streamEnd).not.toBeNull(); - // Verify no NEW user message was created after resume (total should still be 1) + // Verify no new user message was created + collector2.collect(); const userMessagesAfter = collector2 .getEvents() - .filter((e: WorkspaceChatMessage) => "role" in e && e.role === "user"); - expect(userMessagesAfter.length).toBe(1); // Still only the original user message + .filter((e) => "role" in e && e.role === "user"); + expect(userMessagesAfter.length).toBe(0); // No new user messages // Verify stream completed successfully (without errors) const streamErrors = collector2 .getEvents() - .filter((e: WorkspaceChatMessage) => "type" in e && e.type === "stream-error"); + .filter((e) => "type" in e && e.type === "stream-error"); expect(streamErrors.length).toBe(0); // Verify we received stream deltas (actual content) @@ -115,11 +120,10 @@ describeIntegration("resumeStream", () => { // Verify we received the expected word in the output // This proves the bash command completed successfully after resume const allText = deltas - .filter((d: WorkspaceChatMessage) => "delta" in d) - .map((d: WorkspaceChatMessage) => ("delta" in d ? (d as { delta: string }).delta : "")) + .filter((d) => "delta" in d) + .map((d) => ("delta" in d ? d.delta : "")) .join(""); expect(allText).toContain(expectedWord); - collector2.stop(); } finally { await cleanup(); } @@ -131,8 +135,6 @@ describeIntegration("resumeStream", () => { "should resume from single assistant message (post-compaction scenario)", async () => { const { env, workspaceId, cleanup } = await setupWorkspace("anthropic"); - const collector = createStreamCollector(env.orpc, workspaceId); - collector.start(); try { // Create a history service to write directly to chat.jsonl const historyService = new HistoryService(env.config); @@ -153,52 +155,62 @@ describeIntegration("resumeStream", () => { const appendResult = await historyService.appendToHistory(workspaceId, summaryMessage); expect(appendResult.success).toBe(true); - // Wait a moment for events to settle - await new Promise((resolve) => setTimeout(resolve, 100)); + // Create event collector + const collector = createEventCollector(env.sentEvents, workspaceId); + + // Subscribe to chat channel to receive events + env.mockIpcRenderer.send("workspace:chat:subscribe", workspaceId); + + // Wait for subscription to complete by waiting for caught-up event + const caughtUpEvent = await collector.waitForEvent("caught-up", 5000); + expect(caughtUpEvent).toBeDefined(); // Resume the stream (should continue from the summary message) - const client = resolveOrpcClient(env); - const resumeResult = await client.workspace.resumeStream({ + const resumeResult = (await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_RESUME_STREAM, workspaceId, - options: { model: "anthropic:claude-sonnet-4-5" }, - }); + { model: "anthropic:claude-sonnet-4-5" } + )) as Result; expect(resumeResult.success).toBe(true); // Wait for stream to start const streamStart = await collector.waitForEvent("stream-start", 10000); - expect(streamStart).toBeDefined(); + expect(streamStart).not.toBeNull(); // Wait for stream to complete const streamEnd = await collector.waitForEvent("stream-end", 30000); - expect(streamEnd).toBeDefined(); + expect(streamEnd).not.toBeNull(); // Verify no user message was created (resumeStream should not add one) - const userMessages = collector - .getEvents() - .filter((e: WorkspaceChatMessage) => "role" in e && e.role === "user"); + collector.collect(); + const userMessages = collector.getEvents().filter((e) => "role" in e && e.role === "user"); expect(userMessages.length).toBe(0); - // Verify we received content deltas (the actual assistant response during streaming) - const deltas = collector.getDeltas(); - expect(deltas.length).toBeGreaterThan(0); + // Verify we got an assistant response + const assistantMessages = collector + .getEvents() + .filter((e) => "role" in e && e.role === "assistant"); + expect(assistantMessages.length).toBeGreaterThan(0); // Verify no stream errors const streamErrors = collector .getEvents() - .filter((e: WorkspaceChatMessage) => "type" in e && e.type === "stream-error"); + .filter((e) => "type" in e && e.type === "stream-error"); expect(streamErrors.length).toBe(0); - // Verify the assistant responded with actual content and said the verification word - const allText = deltas - .filter((d: WorkspaceChatMessage) => "delta" in d) - .map((d: WorkspaceChatMessage) => ("delta" in d ? (d as { delta: string }).delta : "")) - .join(""); - expect(allText.length).toBeGreaterThan(0); + // Get the final message content from stream-end parts + // StreamEndEvent has parts: Array + const finalMessage = collector.getFinalMessage() as any; + expect(finalMessage).toBeDefined(); + const textParts = (finalMessage?.parts ?? []).filter( + (p: any) => p.type === "text" && p.text + ); + const finalContent = textParts.map((p: any) => p.text).join(""); + expect(finalContent.length).toBeGreaterThan(0); // Verify the assistant followed the instruction and said the verification word // This proves resumeStream properly loaded history and continued from it - expect(allText).toContain(verificationWord); - collector.stop(); + expect(finalContent).toContain(verificationWord); } finally { await cleanup(); } diff --git a/tests/ipcMain/runtimeExecuteBash.test.ts b/tests/ipcMain/runtimeExecuteBash.test.ts new file mode 100644 index 000000000..2010bf28b --- /dev/null +++ b/tests/ipcMain/runtimeExecuteBash.test.ts @@ -0,0 +1,407 @@ +/** + * Integration tests for bash execution across Local and SSH runtimes + * + * Tests bash tool using real IPC handlers on both LocalRuntime and SSHRuntime. + * + * Reuses test infrastructure from runtimeFileEditing.test.ts + */ + +import { + createTestEnvironment, + cleanupTestEnvironment, + shouldRunIntegrationTests, + validateApiKeys, + getApiKey, + setupProviders, +} from "./setup"; +import { IPC_CHANNELS } from "../../src/common/constants/ipc-constants"; +import { + createTempGitRepo, + cleanupTempGitRepo, + generateBranchName, + createWorkspaceWithInit, + sendMessageAndWait, + extractTextFromEvents, + HAIKU_MODEL, + TEST_TIMEOUT_LOCAL_MS, + TEST_TIMEOUT_SSH_MS, +} from "./helpers"; +import { + isDockerAvailable, + startSSHServer, + stopSSHServer, + type SSHServerConfig, +} from "../runtime/ssh-fixture"; +import type { RuntimeConfig } from "../../src/common/types/runtime"; +import type { WorkspaceChatMessage } from "../../src/common/types/ipc"; +import type { ToolPolicy } from "../../src/common/utils/tools/toolPolicy"; + +// Tool policy: Only allow bash tool +const BASH_ONLY: ToolPolicy = [ + { regex_match: "bash", action: "enable" }, + { regex_match: "file_.*", action: "disable" }, +]; + +function collectToolOutputs(events: WorkspaceChatMessage[], toolName: string): string { + return events + .filter((event: any) => event.type === "tool-call-end" && event.toolName === toolName) + .map((event: any) => { + const output = event.result?.output; + return typeof output === "string" ? output : ""; + }) + .join("\n"); +} + +// Helper to calculate tool execution duration from captured events +function getToolDuration( + env: { sentEvents: Array<{ channel: string; data: unknown; timestamp: number }> }, + toolName: string +): number { + const startEvent = env.sentEvents.find((e) => { + const msg = e.data as any; + return msg.type === "tool-call-start" && msg.toolName === toolName; + }); + + const endEvent = env.sentEvents.find((e) => { + const msg = e.data as any; + return msg.type === "tool-call-end" && msg.toolName === toolName; + }); + + if (startEvent && endEvent) { + return endEvent.timestamp - startEvent.timestamp; + } + return -1; +} + +// Skip all tests if TEST_INTEGRATION is not set +const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip; + +// Validate API keys before running tests +if (shouldRunIntegrationTests()) { + validateApiKeys(["ANTHROPIC_API_KEY"]); +} + +// SSH server config (shared across all SSH tests) +let sshConfig: SSHServerConfig | undefined; + +describeIntegration("Runtime Bash Execution", () => { + beforeAll(async () => { + // Check if Docker is available (required for SSH tests) + if (!(await isDockerAvailable())) { + throw new Error( + "Docker is required for SSH runtime tests. Please install Docker or skip tests by unsetting TEST_INTEGRATION." + ); + } + + // Start SSH server (shared across all tests for speed) + console.log("Starting SSH server container for bash tests..."); + sshConfig = await startSSHServer(); + console.log(`SSH server ready on port ${sshConfig.port}`); + }, 60000); + + afterAll(async () => { + if (sshConfig) { + console.log("Stopping SSH server container..."); + await stopSSHServer(sshConfig); + } + }, 30000); + + // Test matrix: Run tests for both local and SSH runtimes + describe.each<{ type: "local" | "ssh" }>([{ type: "local" }, { type: "ssh" }])( + "Runtime: $type", + ({ type }) => { + // Helper to build runtime config + const getRuntimeConfig = (branchName: string): RuntimeConfig | undefined => { + if (type === "ssh" && sshConfig) { + return { + type: "ssh", + host: `testuser@localhost`, + srcBaseDir: `${sshConfig.workdir}/${branchName}`, + identityFile: sshConfig.privateKeyPath, + port: sshConfig.port, + }; + } + return undefined; // undefined = defaults to local + }; + + test.concurrent( + "should execute simple bash command", + async () => { + const env = await createTestEnvironment(); + const tempGitRepo = await createTempGitRepo(); + + try { + // Setup provider + await setupProviders(env.mockIpcRenderer, { + anthropic: { + apiKey: getApiKey("ANTHROPIC_API_KEY"), + }, + }); + + // Create workspace + const branchName = generateBranchName("bash-simple"); + const runtimeConfig = getRuntimeConfig(branchName); + const { workspaceId, cleanup } = await createWorkspaceWithInit( + env, + tempGitRepo, + branchName, + runtimeConfig, + true, // waitForInit + type === "ssh" + ); + + try { + // Ask AI to run a simple command + const events = await sendMessageAndWait( + env, + workspaceId, + 'Run the bash command "echo Hello World"', + HAIKU_MODEL, + BASH_ONLY + ); + + // Extract response text + const responseText = extractTextFromEvents(events); + + // Verify the command output appears in the response + expect(responseText.toLowerCase()).toContain("hello world"); + + // Verify bash tool was called + // Tool calls now emit tool-call-start and tool-call-end events (not tool-call-delta) + const toolCallStarts = events.filter((e: any) => e.type === "tool-call-start"); + const bashCall = toolCallStarts.find((e: any) => e.toolName === "bash"); + expect(bashCall).toBeDefined(); + } finally { + await cleanup(); + } + } finally { + await cleanupTempGitRepo(tempGitRepo); + await cleanupTestEnvironment(env); + } + }, + type === "ssh" ? TEST_TIMEOUT_SSH_MS : TEST_TIMEOUT_LOCAL_MS + ); + + test.concurrent( + "should handle bash command with environment variables", + async () => { + const env = await createTestEnvironment(); + const tempGitRepo = await createTempGitRepo(); + + try { + // Setup provider + await setupProviders(env.mockIpcRenderer, { + anthropic: { + apiKey: getApiKey("ANTHROPIC_API_KEY"), + }, + }); + + // Create workspace + const branchName = generateBranchName("bash-env"); + const runtimeConfig = getRuntimeConfig(branchName); + const { workspaceId, cleanup } = await createWorkspaceWithInit( + env, + tempGitRepo, + branchName, + runtimeConfig, + true, // waitForInit + type === "ssh" + ); + + try { + // Ask AI to run command that sets and uses env var + const events = await sendMessageAndWait( + env, + workspaceId, + 'Run bash command: export TEST_VAR="test123" && echo "Value: $TEST_VAR"', + HAIKU_MODEL, + BASH_ONLY + ); + + // Extract response text + const responseText = extractTextFromEvents(events); + + // Verify the env var value appears + expect(responseText).toContain("test123"); + + // Verify bash tool was called + // Tool calls now emit tool-call-start and tool-call-end events (not tool-call-delta) + const toolCallStarts = events.filter((e: any) => e.type === "tool-call-start"); + const bashCall = toolCallStarts.find((e: any) => e.toolName === "bash"); + expect(bashCall).toBeDefined(); + } finally { + await cleanup(); + } + } finally { + await cleanupTempGitRepo(tempGitRepo); + await cleanupTestEnvironment(env); + } + }, + type === "ssh" ? TEST_TIMEOUT_SSH_MS : TEST_TIMEOUT_LOCAL_MS + ); + + test.concurrent( + "should not hang on commands that read stdin without input", + async () => { + const env = await createTestEnvironment(); + const tempGitRepo = await createTempGitRepo(); + + try { + // Setup provider + await setupProviders(env.mockIpcRenderer, { + anthropic: { + apiKey: getApiKey("ANTHROPIC_API_KEY"), + }, + }); + + // Create workspace + const branchName = generateBranchName("bash-stdin"); + const runtimeConfig = getRuntimeConfig(branchName); + const { workspaceId, cleanup } = await createWorkspaceWithInit( + env, + tempGitRepo, + branchName, + runtimeConfig, + true, // waitForInit + type === "ssh" + ); + + try { + // Create a test file with JSON content + // Using gpt-5-mini for speed (bash tool tests don't need reasoning power) + await sendMessageAndWait( + env, + workspaceId, + 'Run bash: echo \'{"test": "data"}\' > /tmp/test.json', + HAIKU_MODEL, + BASH_ONLY + ); + + // Test command that pipes file through stdin-reading command (grep) + // This would hang forever if stdin.close() was used instead of stdin.abort() + // Regression test for: https://github.com/coder/mux/issues/503 + const events = await sendMessageAndWait( + env, + workspaceId, + "Run bash: cat /tmp/test.json | grep test", + HAIKU_MODEL, + BASH_ONLY, + 30000 // Relaxed timeout for CI stability (was 10s) + ); + + // Calculate actual tool execution duration + const toolDuration = getToolDuration(env, "bash"); + + // Extract response text + const responseText = extractTextFromEvents(events); + + // Verify command completed successfully (not timeout) + // We primarily check bashOutput to ensure the tool executed and didn't hang + const bashOutput = collectToolOutputs(events, "bash"); + expect(bashOutput).toContain('"test": "data"'); + + // responseText might be empty if the model decides not to comment on the output + // so we make this check optional or less strict if the tool output is correct + if (responseText) { + expect(responseText).toContain("test"); + } + + // Verify command completed quickly (not hanging until timeout) + expect(toolDuration).toBeGreaterThan(0); + const maxDuration = 10000; + expect(toolDuration).toBeLessThan(maxDuration); + + // Verify bash tool was called + const toolCallStarts = events.filter((e: any) => e.type === "tool-call-start"); + const bashCalls = toolCallStarts.filter((e: any) => e.toolName === "bash"); + expect(bashCalls.length).toBeGreaterThan(0); + } finally { + await cleanup(); + } + } finally { + await cleanupTempGitRepo(tempGitRepo); + await cleanupTestEnvironment(env); + } + }, + type === "ssh" ? TEST_TIMEOUT_SSH_MS : TEST_TIMEOUT_LOCAL_MS + ); + + test.concurrent( + "should not hang on grep | head pattern over SSH", + async () => { + const env = await createTestEnvironment(); + const tempGitRepo = await createTempGitRepo(); + + try { + // Setup provider + await setupProviders(env.mockIpcRenderer, { + anthropic: { + apiKey: getApiKey("ANTHROPIC_API_KEY"), + }, + }); + + // Create workspace + const branchName = generateBranchName("bash-grep-head"); + const runtimeConfig = getRuntimeConfig(branchName); + const { workspaceId, cleanup } = await createWorkspaceWithInit( + env, + tempGitRepo, + branchName, + runtimeConfig, + true, // waitForInit + type === "ssh" + ); + + try { + // Create some test files to search through + await sendMessageAndWait( + env, + workspaceId, + 'Run bash: for i in {1..1000}; do echo "terminal bench line $i" >> testfile.txt; done', + HAIKU_MODEL, + BASH_ONLY + ); + + // Test grep | head pattern - this historically hangs over SSH + // This is a regression test for the bash hang issue + const events = await sendMessageAndWait( + env, + workspaceId, + 'Run bash: grep -n "terminal bench" testfile.txt | head -n 200', + HAIKU_MODEL, + BASH_ONLY, + 30000 // Relaxed timeout for CI stability (was 15s) + ); + + // Calculate actual tool execution duration + const toolDuration = getToolDuration(env, "bash"); + + // Extract response text + const responseText = extractTextFromEvents(events); + + // Verify command completed successfully (not timeout) + expect(responseText).toContain("terminal bench"); + + // Verify command completed quickly (not hanging until timeout) + // SSH runtime should complete in <10s even with high latency + expect(toolDuration).toBeGreaterThan(0); + const maxDuration = 15000; + expect(toolDuration).toBeLessThan(maxDuration); + + // Verify bash tool was called + const toolCallStarts = events.filter((e: any) => e.type === "tool-call-start"); + const bashCalls = toolCallStarts.filter((e: any) => e.toolName === "bash"); + expect(bashCalls.length).toBeGreaterThan(0); + } finally { + await cleanup(); + } + } finally { + await cleanupTempGitRepo(tempGitRepo); + await cleanupTestEnvironment(env); + } + }, + type === "ssh" ? TEST_TIMEOUT_SSH_MS : TEST_TIMEOUT_LOCAL_MS + ); + } + ); +}); diff --git a/tests/integration/runtimeFileEditing.test.ts b/tests/ipcMain/runtimeFileEditing.test.ts similarity index 98% rename from tests/integration/runtimeFileEditing.test.ts rename to tests/ipcMain/runtimeFileEditing.test.ts index 1ea6021ff..3a19b6ab8 100644 --- a/tests/integration/runtimeFileEditing.test.ts +++ b/tests/ipcMain/runtimeFileEditing.test.ts @@ -18,6 +18,7 @@ import { setupProviders, type TestEnvironment, } from "./setup"; +import { IPC_CHANNELS } from "../../src/common/constants/ipc-constants"; import { createTempGitRepo, cleanupTempGitRepo, @@ -109,7 +110,7 @@ describeIntegration("Runtime File Editing Tools", () => { try { // Setup provider - await setupProviders(env, { + await setupProviders(env.mockIpcRenderer, { anthropic: { apiKey: getApiKey("ANTHROPIC_API_KEY"), }, @@ -192,7 +193,7 @@ describeIntegration("Runtime File Editing Tools", () => { try { // Setup provider - await setupProviders(env, { + await setupProviders(env.mockIpcRenderer, { anthropic: { apiKey: getApiKey("ANTHROPIC_API_KEY"), }, @@ -281,7 +282,7 @@ describeIntegration("Runtime File Editing Tools", () => { try { // Setup provider - await setupProviders(env, { + await setupProviders(env.mockIpcRenderer, { anthropic: { apiKey: getApiKey("ANTHROPIC_API_KEY"), }, @@ -371,7 +372,7 @@ describeIntegration("Runtime File Editing Tools", () => { try { // Setup provider - await setupProviders(env, { + await setupProviders(env.mockIpcRenderer, { anthropic: { apiKey: getApiKey("ANTHROPIC_API_KEY"), }, diff --git a/tests/ipcMain/sendMessage.basic.test.ts b/tests/ipcMain/sendMessage.basic.test.ts new file mode 100644 index 000000000..5a9fa585f --- /dev/null +++ b/tests/ipcMain/sendMessage.basic.test.ts @@ -0,0 +1,523 @@ +import * as fs from "fs/promises"; +import * as path from "path"; +import { setupWorkspace, shouldRunIntegrationTests, validateApiKeys } from "./setup"; +import { + sendMessageWithModel, + sendMessage, + createEventCollector, + assertStreamSuccess, + assertError, + waitFor, + buildLargeHistory, + waitForStreamSuccess, + readChatHistory, + TEST_IMAGES, + modelString, + configureTestRetries, +} from "./helpers"; +import { + createSharedRepo, + cleanupSharedRepo, + withSharedWorkspace, + withSharedWorkspaceNoProvider, +} from "./sendMessageTestHelpers"; +import type { StreamDeltaEvent } from "../../src/common/types/stream"; +import { IPC_CHANNELS } from "../../src/common/constants/ipc-constants"; + +// Skip all tests if TEST_INTEGRATION is not set +const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip; + +// Validate API keys before running tests +if (shouldRunIntegrationTests()) { + validateApiKeys(["OPENAI_API_KEY", "ANTHROPIC_API_KEY"]); +} + +import { KNOWN_MODELS } from "@/common/constants/knownModels"; + +// Test both providers with their respective models +const PROVIDER_CONFIGS: Array<[string, string]> = [ + ["openai", KNOWN_MODELS.GPT_MINI.providerModelId], + ["anthropic", KNOWN_MODELS.SONNET.providerModelId], +]; + +// Integration test timeout guidelines: +// - Individual tests should complete within 10 seconds when possible +// - Use tight timeouts (5-10s) for event waiting to fail fast +// - Longer running tests (tool calls, multiple edits) can take up to 30s +// - Test timeout values (in describe/test) should be 2-3x the expected duration + +beforeAll(createSharedRepo); +afterAll(cleanupSharedRepo); +describeIntegration("IpcMain sendMessage integration tests", () => { + configureTestRetries(3); + + // Run tests for each provider concurrently + describe.each(PROVIDER_CONFIGS)("%s:%s provider tests", (provider, model) => { + test.concurrent( + "should successfully send message and receive response", + async () => { + await withSharedWorkspace(provider, async ({ env, workspaceId }) => { + // Send a simple message + const result = await sendMessageWithModel( + env.mockIpcRenderer, + workspaceId, + "Say 'hello' and nothing else", + modelString(provider, model) + ); + + // Verify the IPC call succeeded + expect(result.success).toBe(true); + + // Collect and verify stream events + const collector = createEventCollector(env.sentEvents, workspaceId); + const streamEnd = await collector.waitForEvent("stream-end"); + + expect(streamEnd).toBeDefined(); + assertStreamSuccess(collector); + + // Verify we received deltas + const deltas = collector.getDeltas(); + expect(deltas.length).toBeGreaterThan(0); + }); + }, + 15000 + ); + + test.concurrent( + "should interrupt streaming with interruptStream()", + async () => { + await withSharedWorkspace(provider, async ({ env, workspaceId }) => { + // Start a long-running stream with a bash command that takes time + const longMessage = "Run this bash command: while true; do sleep 1; done"; + void sendMessageWithModel( + env.mockIpcRenderer, + workspaceId, + longMessage, + modelString(provider, model) + ); + + // Wait for stream to start + const collector = createEventCollector(env.sentEvents, workspaceId); + await collector.waitForEvent("stream-start", 5000); + + // Use interruptStream() to interrupt + const interruptResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_INTERRUPT_STREAM, + workspaceId + ); + + // Should succeed (interrupt is not an error) + expect(interruptResult.success).toBe(true); + + // Wait for abort or end event + const abortOrEndReceived = await waitFor(() => { + collector.collect(); + const hasAbort = collector + .getEvents() + .some((e) => "type" in e && e.type === "stream-abort"); + const hasEnd = collector.hasStreamEnd(); + return hasAbort || hasEnd; + }, 5000); + + expect(abortOrEndReceived).toBe(true); + }); + }, + 15000 + ); + + test.concurrent( + "should interrupt stream with pending bash tool call near-instantly", + async () => { + await withSharedWorkspace(provider, async ({ env, workspaceId }) => { + // Ask the model to run a long-running bash command + // Use explicit instruction to ensure tool call happens + const message = "Use the bash tool to run: sleep 60"; + void sendMessageWithModel( + env.mockIpcRenderer, + workspaceId, + message, + modelString(provider, model) + ); + + // Wait for stream to start (more reliable than waiting for tool-call-start) + const collector = createEventCollector(env.sentEvents, workspaceId); + await collector.waitForEvent("stream-start", 10000); + + // Give model time to start calling the tool (sleep command should be in progress) + // This ensures we're actually interrupting a running command + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Record interrupt time + const interruptStartTime = performance.now(); + + // Interrupt the stream + const interruptResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_INTERRUPT_STREAM, + workspaceId + ); + + const interruptDuration = performance.now() - interruptStartTime; + + // Should succeed + expect(interruptResult.success).toBe(true); + + // Interrupt should complete near-instantly (< 2 seconds) + // This validates that we don't wait for the sleep 60 command to finish + expect(interruptDuration).toBeLessThan(2000); + + // Wait for abort event + const abortOrEndReceived = await waitFor(() => { + collector.collect(); + const hasAbort = collector + .getEvents() + .some((e) => "type" in e && e.type === "stream-abort"); + const hasEnd = collector.hasStreamEnd(); + return hasAbort || hasEnd; + }, 5000); + + expect(abortOrEndReceived).toBe(true); + }); + }, + 25000 + ); + + test.concurrent( + "should include tokens and timestamp in delta events", + async () => { + await withSharedWorkspace(provider, async ({ env, workspaceId }) => { + // Send a message that will generate text deltas + // Disable reasoning for this test to avoid flakiness and encrypted content issues in CI + void sendMessageWithModel( + env.mockIpcRenderer, + workspaceId, + "Write a short paragraph about TypeScript", + modelString(provider, model), + { thinkingLevel: "off" } + ); + + // Wait for stream to start + const collector = createEventCollector(env.sentEvents, workspaceId); + await collector.waitForEvent("stream-start", 5000); + + // Wait for first delta event + const deltaEvent = await collector.waitForEvent("stream-delta", 5000); + expect(deltaEvent).toBeDefined(); + + // Verify delta event has tokens and timestamp + if (deltaEvent && "type" in deltaEvent && deltaEvent.type === "stream-delta") { + expect("tokens" in deltaEvent).toBe(true); + expect("timestamp" in deltaEvent).toBe(true); + expect("delta" in deltaEvent).toBe(true); + + // Verify types + if ("tokens" in deltaEvent) { + expect(typeof deltaEvent.tokens).toBe("number"); + expect(deltaEvent.tokens).toBeGreaterThanOrEqual(0); + } + if ("timestamp" in deltaEvent) { + expect(typeof deltaEvent.timestamp).toBe("number"); + expect(deltaEvent.timestamp).toBeGreaterThan(0); + } + } + + // Collect all events and sum tokens + await collector.waitForEvent("stream-end", 10000); + const allEvents = collector.getEvents(); + const deltaEvents = allEvents.filter( + (e) => + "type" in e && + (e.type === "stream-delta" || + e.type === "reasoning-delta" || + e.type === "tool-call-delta") + ); + + // Should have received multiple delta events + expect(deltaEvents.length).toBeGreaterThan(0); + + // Calculate total tokens from deltas + let totalTokens = 0; + for (const event of deltaEvents) { + if ("tokens" in event && typeof event.tokens === "number") { + totalTokens += event.tokens; + } + } + + // Total should be greater than 0 + expect(totalTokens).toBeGreaterThan(0); + + // Verify stream completed successfully + assertStreamSuccess(collector); + }); + }, + 30000 // Increased timeout for OpenAI models which can be slower in CI + ); + + test.concurrent( + "should include usage data in stream-abort events", + async () => { + await withSharedWorkspace(provider, async ({ env, workspaceId }) => { + // Start a stream that will generate some tokens + const message = "Write a haiku about coding"; + void sendMessageWithModel( + env.mockIpcRenderer, + workspaceId, + message, + modelString(provider, model) + ); + + // Wait for stream to start and get some deltas + const collector = createEventCollector(env.sentEvents, workspaceId); + await collector.waitForEvent("stream-start", 5000); + + // Wait a bit for some content to be generated + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // Interrupt the stream with interruptStream() + const interruptResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_INTERRUPT_STREAM, + workspaceId + ); + + expect(interruptResult.success).toBe(true); + + // Collect all events and find abort event + await waitFor(() => { + collector.collect(); + return collector.getEvents().some((e) => "type" in e && e.type === "stream-abort"); + }, 5000); + + const abortEvent = collector + .getEvents() + .find((e) => "type" in e && e.type === "stream-abort"); + expect(abortEvent).toBeDefined(); + + // Verify abort event structure + if (abortEvent && "metadata" in abortEvent) { + // Metadata should exist with duration + expect(abortEvent.metadata).toBeDefined(); + expect(abortEvent.metadata?.duration).toBeGreaterThan(0); + + // Usage MAY be present depending on abort timing: + // - Early abort: usage is undefined (stream didn't complete) + // - Late abort: usage available (stream finished before UI processed it) + if (abortEvent.metadata?.usage) { + expect(abortEvent.metadata.usage.inputTokens).toBeGreaterThan(0); + expect(abortEvent.metadata.usage.outputTokens).toBeGreaterThanOrEqual(0); + } + } + }); + }, + 15000 + ); + + test.concurrent( + "should handle reconnection during active stream", + async () => { + // Only test with Anthropic (faster and more reliable for this test) + if (provider === "openai") { + return; + } + + await withSharedWorkspace(provider, async ({ env, workspaceId }) => { + // Start a stream with tool call that takes a long time + void sendMessageWithModel( + env.mockIpcRenderer, + workspaceId, + "Run this bash command: while true; do sleep 0.1; done", + modelString(provider, model) + ); + + // Wait for tool-call-start (which means model is executing bash) + const collector1 = createEventCollector(env.sentEvents, workspaceId); + const streamStartEvent = await collector1.waitForEvent("stream-start", 5000); + expect(streamStartEvent).toBeDefined(); + + await collector1.waitForEvent("tool-call-start", 10000); + + // At this point, bash loop is running (will run forever if abort doesn't work) + // Get message ID for verification + collector1.collect(); + const messageId = + streamStartEvent && "messageId" in streamStartEvent + ? streamStartEvent.messageId + : undefined; + expect(messageId).toBeDefined(); + + // Simulate reconnection by clearing events and re-subscribing + env.sentEvents.length = 0; + + // Use ipcRenderer.send() to trigger ipcMain.on() handler (correct way for electron-mock-ipc) + env.mockIpcRenderer.send("workspace:chat:subscribe", workspaceId); + + // Wait for async subscription handler to complete by polling for caught-up + const collector2 = createEventCollector(env.sentEvents, workspaceId); + const caughtUpMessage = await collector2.waitForEvent("caught-up", 5000); + expect(caughtUpMessage).toBeDefined(); + + // Collect all reconnection events + collector2.collect(); + const reconnectionEvents = collector2.getEvents(); + + // Verify we received stream-start event (not a partial message with INTERRUPTED) + const reconnectStreamStart = reconnectionEvents.find( + (e) => "type" in e && e.type === "stream-start" + ); + + // If stream completed before reconnection, we'll get a regular message instead + // This is expected behavior - only active streams get replayed + const hasStreamStart = !!reconnectStreamStart; + const hasRegularMessage = reconnectionEvents.some( + (e) => "role" in e && e.role === "assistant" + ); + + // Either we got stream replay (active stream) OR regular message (completed stream) + expect(hasStreamStart || hasRegularMessage).toBe(true); + + // If we did get stream replay, verify it + if (hasStreamStart) { + expect(reconnectStreamStart).toBeDefined(); + expect( + reconnectStreamStart && "messageId" in reconnectStreamStart + ? reconnectStreamStart.messageId + : undefined + ).toBe(messageId); + + // Verify we received tool-call-start (replay of accumulated tool event) + const reconnectToolStart = reconnectionEvents.filter( + (e) => "type" in e && e.type === "tool-call-start" + ); + expect(reconnectToolStart.length).toBeGreaterThan(0); + + // Verify we did NOT receive a partial message (which would show INTERRUPTED) + const partialMessages = reconnectionEvents.filter( + (e) => + "role" in e && + e.role === "assistant" && + "metadata" in e && + (e as { metadata?: { partial?: boolean } }).metadata?.partial === true + ); + expect(partialMessages.length).toBe(0); + } + + // Note: If test completes quickly (~5s), abort signal worked and killed the loop + // If test takes much longer, abort signal didn't work + }); + }, + 15000 + ); + }); + + // Test frontend metadata round-trip (no provider needed - just verifies storage) + test.concurrent( + "should preserve arbitrary frontend metadata through IPC round-trip", + async () => { + await withSharedWorkspaceNoProvider(async ({ env, workspaceId }) => { + // Create structured metadata + const testMetadata = { + type: "compaction-request" as const, + rawCommand: "/compact -c continue working", + parsed: { + maxOutputTokens: 5000, + continueMessage: "continue working", + }, + }; + + // Send a message with frontend metadata + // Use invalid model to fail fast - we only care about metadata storage + const result = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_SEND_MESSAGE, + workspaceId, + "Test message with metadata", + { + model: "openai:gpt-4", // Valid format but provider not configured - will fail after storing message + muxMetadata: testMetadata, + } + ); + + // Note: IPC call will fail due to missing provider config, but that's okay + // We only care that the user message was written to history with metadata + // (sendMessage writes user message before attempting to stream) + + // Use event collector to get messages sent to frontend + const collector = createEventCollector(env.sentEvents, workspaceId); + + // Wait for the user message to appear in the chat channel + await waitFor(() => { + const messages = collector.collect(); + return messages.some((m) => "role" in m && m.role === "user"); + }, 2000); + + // Get all messages for this workspace + const allMessages = collector.collect(); + + // Find the user message we just sent + const userMessage = allMessages.find((msg) => "role" in msg && msg.role === "user"); + expect(userMessage).toBeDefined(); + + // Verify metadata was preserved exactly as sent (black-box) + expect(userMessage).toHaveProperty("metadata"); + const metadata = (userMessage as any).metadata; + expect(metadata).toHaveProperty("muxMetadata"); + expect(metadata.muxMetadata).toEqual(testMetadata); + + // Verify structured fields are accessible + expect(metadata.muxMetadata.type).toBe("compaction-request"); + expect(metadata.muxMetadata.rawCommand).toBe("/compact -c continue working"); + expect(metadata.muxMetadata.parsed.continueMessage).toBe("continue working"); + expect(metadata.muxMetadata.parsed.maxOutputTokens).toBe(5000); + }); + }, + 5000 + ); +}); + +// Test usage-delta events during multi-step streams +describeIntegration("usage-delta events", () => { + configureTestRetries(3); + + // Only test with Anthropic - more reliable multi-step behavior + test.concurrent( + "should emit usage-delta events during multi-step tool call streams", + async () => { + await withSharedWorkspace("anthropic", async ({ env, workspaceId }) => { + // Ask the model to read a file - guaranteed to trigger tool use + const result = await sendMessageWithModel( + env.mockIpcRenderer, + workspaceId, + "Use the file_read tool to read README.md. Only read the first 5 lines.", + modelString("anthropic", KNOWN_MODELS.SONNET.providerModelId) + ); + + expect(result.success).toBe(true); + + // Collect events and wait for stream completion + const collector = createEventCollector(env.sentEvents, workspaceId); + await collector.waitForEvent("stream-end", 15000); + + // Verify usage-delta events were emitted + const allEvents = collector.getEvents(); + const usageDeltas = allEvents.filter( + (e) => "type" in e && e.type === "usage-delta" + ) as Array<{ type: "usage-delta"; usage: { inputTokens: number; outputTokens: number } }>; + + // Multi-step stream should emit at least one usage-delta (on finish-step) + expect(usageDeltas.length).toBeGreaterThan(0); + + // Each usage-delta should have valid usage data + for (const delta of usageDeltas) { + expect(delta.usage).toBeDefined(); + expect(delta.usage.inputTokens).toBeGreaterThan(0); + // outputTokens may be 0 for some steps, but should be defined + expect(typeof delta.usage.outputTokens).toBe("number"); + } + + // Verify stream completed successfully + assertStreamSuccess(collector); + }); + }, + 30000 + ); +}); + +// Test image support across providers +describe.each(PROVIDER_CONFIGS)("%s:%s image support", (provider, model) => {}); diff --git a/tests/ipcMain/sendMessage.context.test.ts b/tests/ipcMain/sendMessage.context.test.ts new file mode 100644 index 000000000..5099c989b --- /dev/null +++ b/tests/ipcMain/sendMessage.context.test.ts @@ -0,0 +1,610 @@ +import * as fs from "fs/promises"; +import * as path from "path"; +import { shouldRunIntegrationTests, validateApiKeys } from "./setup"; +import { + sendMessageWithModel, + sendMessage, + createEventCollector, + assertStreamSuccess, + assertError, + waitFor, + buildLargeHistory, + waitForStreamSuccess, + readChatHistory, + TEST_IMAGES, + modelString, + configureTestRetries, +} from "./helpers"; +import { + createSharedRepo, + cleanupSharedRepo, + withSharedWorkspace, + withSharedWorkspaceNoProvider, +} from "./sendMessageTestHelpers"; +import type { StreamDeltaEvent } from "../../src/common/types/stream"; +import { IPC_CHANNELS } from "../../src/common/constants/ipc-constants"; + +// Skip all tests if TEST_INTEGRATION is not set +const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip; + +// Validate API keys before running tests +if (shouldRunIntegrationTests()) { + validateApiKeys(["OPENAI_API_KEY", "ANTHROPIC_API_KEY"]); +} + +import { KNOWN_MODELS } from "@/common/constants/knownModels"; + +// Test both providers with their respective models +const PROVIDER_CONFIGS: Array<[string, string]> = [ + ["openai", KNOWN_MODELS.GPT_MINI.providerModelId], + ["anthropic", KNOWN_MODELS.SONNET.providerModelId], +]; + +// Integration test timeout guidelines: +// - Individual tests should complete within 10 seconds when possible +// - Use tight timeouts (5-10s) for event waiting to fail fast +// - Longer running tests (tool calls, multiple edits) can take up to 30s +// - Test timeout values (in describe/test) should be 2-3x the expected duration + +beforeAll(createSharedRepo); +afterAll(cleanupSharedRepo); +describeIntegration("IpcMain sendMessage integration tests", () => { + configureTestRetries(3); + + // Run tests for each provider concurrently + describe.each(PROVIDER_CONFIGS)("%s:%s provider tests", (provider, model) => { + test.concurrent( + "should handle message editing with history truncation", + async () => { + await withSharedWorkspace(provider, async ({ env, workspaceId }) => { + // Send first message + const result1 = await sendMessageWithModel( + env.mockIpcRenderer, + workspaceId, + "Say 'first message' and nothing else", + modelString(provider, model) + ); + expect(result1.success).toBe(true); + + // Wait for first stream to complete + const collector1 = createEventCollector(env.sentEvents, workspaceId); + await collector1.waitForEvent("stream-end", 10000); + const firstUserMessage = collector1 + .getEvents() + .find((e) => "role" in e && e.role === "user"); + expect(firstUserMessage).toBeDefined(); + + // Clear events + env.sentEvents.length = 0; + + // Edit the first message (send new message with editMessageId) + const result2 = await sendMessageWithModel( + env.mockIpcRenderer, + workspaceId, + "Say 'edited message' and nothing else", + modelString(provider, model), + { editMessageId: (firstUserMessage as { id: string }).id } + ); + expect(result2.success).toBe(true); + + // Wait for edited stream to complete + const collector2 = createEventCollector(env.sentEvents, workspaceId); + await collector2.waitForEvent("stream-end", 10000); + assertStreamSuccess(collector2); + }); + }, + 20000 + ); + + test.concurrent( + "should handle message editing during active stream with tool calls", + async () => { + await withSharedWorkspace(provider, async ({ env, workspaceId }) => { + // Send a message that will trigger a long-running tool call + const result1 = await sendMessageWithModel( + env.mockIpcRenderer, + workspaceId, + "Run this bash command: for i in {1..20}; do sleep 0.5; done && echo done", + modelString(provider, model) + ); + expect(result1.success).toBe(true); + + // Wait for tool call to start (ensuring it's committed to history) + const collector1 = createEventCollector(env.sentEvents, workspaceId); + await collector1.waitForEvent("tool-call-start", 10000); + const firstUserMessage = collector1 + .getEvents() + .find((e) => "role" in e && e.role === "user"); + expect(firstUserMessage).toBeDefined(); + + // First edit: Edit the message while stream is still active + env.sentEvents.length = 0; + const result2 = await sendMessageWithModel( + env.mockIpcRenderer, + workspaceId, + "Run this bash command: for i in {1..10}; do sleep 0.5; done && echo second", + modelString(provider, model), + { editMessageId: (firstUserMessage as { id: string }).id } + ); + expect(result2.success).toBe(true); + + // Wait for first edit to start tool call + const collector2 = createEventCollector(env.sentEvents, workspaceId); + await collector2.waitForEvent("tool-call-start", 10000); + const secondUserMessage = collector2 + .getEvents() + .find((e) => "role" in e && e.role === "user"); + expect(secondUserMessage).toBeDefined(); + + // Second edit: Edit again while second stream is still active + // This should trigger the bug with orphaned tool calls + env.sentEvents.length = 0; + const result3 = await sendMessageWithModel( + env.mockIpcRenderer, + workspaceId, + "Say 'third edit' and nothing else", + modelString(provider, model), + { editMessageId: (secondUserMessage as { id: string }).id } + ); + expect(result3.success).toBe(true); + + // Wait for either stream-end or stream-error (error expected for OpenAI) + const collector3 = createEventCollector(env.sentEvents, workspaceId); + await Promise.race([ + collector3.waitForEvent("stream-end", 10000), + collector3.waitForEvent("stream-error", 10000), + ]); + + assertStreamSuccess(collector3); + + // Verify the response contains the final edited message content + const finalMessage = collector3.getFinalMessage(); + expect(finalMessage).toBeDefined(); + if (finalMessage && "content" in finalMessage) { + expect(finalMessage.content).toContain("third edit"); + } + }); + }, + 30000 + ); + + test.concurrent( + "should handle tool calls and return file contents", + async () => { + await withSharedWorkspace(provider, async ({ env, workspaceId, workspacePath }) => { + // Generate a random string + const randomString = `test-content-${Date.now()}-${Math.random().toString(36).substring(7)}`; + + // Write the random string to a file in the workspace + const testFilePath = path.join(workspacePath, "test-file.txt"); + await fs.writeFile(testFilePath, randomString, "utf-8"); + + // Ask the model to read the file + const result = await sendMessageWithModel( + env.mockIpcRenderer, + workspaceId, + "Read the file test-file.txt and tell me its contents verbatim. Do not add any extra text.", + modelString(provider, model) + ); + + expect(result.success).toBe(true); + + // Wait for stream to complete + const collector = await waitForStreamSuccess( + env.sentEvents, + workspaceId, + provider === "openai" ? 30000 : 10000 + ); + + // Get the final assistant message + const finalMessage = collector.getFinalMessage(); + expect(finalMessage).toBeDefined(); + + // Check that the response contains the random string + if (finalMessage && "content" in finalMessage) { + expect(finalMessage.content).toContain(randomString); + } + }); + }, + 20000 + ); + + test.concurrent( + "should maintain conversation continuity across messages", + async () => { + await withSharedWorkspace(provider, async ({ env, workspaceId }) => { + // First message: Ask for a random word + const result1 = await sendMessageWithModel( + env.mockIpcRenderer, + workspaceId, + "Generate a random uncommon word and only say that word, nothing else.", + modelString(provider, model) + ); + expect(result1.success).toBe(true); + + // Wait for first stream to complete + const collector1 = createEventCollector(env.sentEvents, workspaceId); + await collector1.waitForEvent("stream-end", 10000); + assertStreamSuccess(collector1); + + // Extract the random word from the response + const firstStreamEnd = collector1.getFinalMessage(); + expect(firstStreamEnd).toBeDefined(); + expect(firstStreamEnd && "parts" in firstStreamEnd).toBe(true); + + // Extract text from parts + let firstContent = ""; + if (firstStreamEnd && "parts" in firstStreamEnd && Array.isArray(firstStreamEnd.parts)) { + firstContent = firstStreamEnd.parts + .filter((part) => part.type === "text") + .map((part) => (part as { text: string }).text) + .join(""); + } + + const randomWord = firstContent.trim().split(/\s+/)[0]; // Get first word + expect(randomWord.length).toBeGreaterThan(0); + + // Clear events for second message + env.sentEvents.length = 0; + + // Second message: Ask for the same word (testing conversation memory) + const result2 = await sendMessageWithModel( + env.mockIpcRenderer, + workspaceId, + "What was the word you just said? Reply with only that word.", + modelString(provider, model) + ); + expect(result2.success).toBe(true); + + // Wait for second stream to complete + const collector2 = createEventCollector(env.sentEvents, workspaceId); + await collector2.waitForEvent("stream-end", 10000); + assertStreamSuccess(collector2); + + // Verify the second response contains the same word + const secondStreamEnd = collector2.getFinalMessage(); + expect(secondStreamEnd).toBeDefined(); + expect(secondStreamEnd && "parts" in secondStreamEnd).toBe(true); + + // Extract text from parts + let secondContent = ""; + if ( + secondStreamEnd && + "parts" in secondStreamEnd && + Array.isArray(secondStreamEnd.parts) + ) { + secondContent = secondStreamEnd.parts + .filter((part) => part.type === "text") + .map((part) => (part as { text: string }).text) + .join(""); + } + + const responseWords = secondContent.toLowerCase().trim(); + const originalWord = randomWord.toLowerCase(); + + // Check if the response contains the original word + expect(responseWords).toContain(originalWord); + }); + }, + 20000 + ); + + test.concurrent( + "should include mode-specific instructions in system message", + async () => { + await withSharedWorkspace(provider, async ({ env, workspaceId, tempGitRepo }) => { + // Write AGENTS.md with mode-specific sections containing distinctive markers + // Note: AGENTS.md is read from project root, not workspace directory + const agentsMdPath = path.join(tempGitRepo, "AGENTS.md"); + const agentsMdContent = `# Instructions + +## General Instructions + +These are general instructions that apply to all modes. + +## Mode: plan + +**CRITICAL DIRECTIVE - NEVER DEVIATE**: You are currently operating in PLAN mode. To prove you have received this mode-specific instruction, you MUST start your response with exactly this phrase: "[PLAN_MODE_ACTIVE]" + +## Mode: exec + +**CRITICAL DIRECTIVE - NEVER DEVIATE**: You are currently operating in EXEC mode. To prove you have received this mode-specific instruction, you MUST start your response with exactly this phrase: "[EXEC_MODE_ACTIVE]" +`; + await fs.writeFile(agentsMdPath, agentsMdContent); + + // Test 1: Send message WITH mode="plan" - should include plan mode marker + const resultPlan = await sendMessageWithModel( + env.mockIpcRenderer, + workspaceId, + "Please respond.", + modelString(provider, model), + { mode: "plan" } + ); + expect(resultPlan.success).toBe(true); + + const collectorPlan = createEventCollector(env.sentEvents, workspaceId); + await collectorPlan.waitForEvent("stream-end", 10000); + assertStreamSuccess(collectorPlan); + + // Verify response contains plan mode marker + const planDeltas = collectorPlan.getDeltas() as StreamDeltaEvent[]; + const planResponse = planDeltas.map((d) => d.delta).join(""); + expect(planResponse).toContain("[PLAN_MODE_ACTIVE]"); + expect(planResponse).not.toContain("[EXEC_MODE_ACTIVE]"); + + // Clear events for next test + env.sentEvents.length = 0; + + // Test 2: Send message WITH mode="exec" - should include exec mode marker + const resultExec = await sendMessageWithModel( + env.mockIpcRenderer, + workspaceId, + "Please respond.", + modelString(provider, model), + { mode: "exec" } + ); + expect(resultExec.success).toBe(true); + + const collectorExec = createEventCollector(env.sentEvents, workspaceId); + await collectorExec.waitForEvent("stream-end", 10000); + assertStreamSuccess(collectorExec); + + // Verify response contains exec mode marker + const execDeltas = collectorExec.getDeltas() as StreamDeltaEvent[]; + const execResponse = execDeltas.map((d) => d.delta).join(""); + expect(execResponse).toContain("[EXEC_MODE_ACTIVE]"); + expect(execResponse).not.toContain("[PLAN_MODE_ACTIVE]"); + + // Test results: + // ✓ Plan mode included [PLAN_MODE_ACTIVE] marker + // ✓ Exec mode included [EXEC_MODE_ACTIVE] marker + // ✓ Each mode only included its own marker, not the other + // + // This proves: + // 1. Mode-specific sections are extracted from AGENTS.md + // 2. The correct mode section is included based on the mode parameter + // 3. Mode sections are mutually exclusive + }); + }, + 25000 + ); + }); + + // Provider parity tests - ensure both providers handle the same scenarios + describe("provider parity", () => { + test.concurrent( + "both providers should handle the same message", + async () => { + const results: Record = {}; + + for (const [provider, model] of PROVIDER_CONFIGS) { + // Create fresh environment with provider setup + await withSharedWorkspace(provider, async ({ env, workspaceId }) => { + // Send same message to both providers + const result = await sendMessageWithModel( + env.mockIpcRenderer, + workspaceId, + "Say 'parity test' and nothing else", + modelString(provider, model) + ); + + // Collect response + const collector = await waitForStreamSuccess(env.sentEvents, workspaceId, 10000); + + results[provider] = { + success: result.success, + responseLength: collector.getDeltas().length, + }; + }); + } + + // Verify both providers succeeded + expect(results.openai.success).toBe(true); + expect(results.anthropic.success).toBe(true); + + // Verify both providers generated responses (non-zero deltas) + expect(results.openai.responseLength).toBeGreaterThan(0); + expect(results.anthropic.responseLength).toBeGreaterThan(0); + }, + 30000 + ); + }); + + // Error handling tests for API key issues + describe("API key error handling", () => { + test.each(PROVIDER_CONFIGS)( + "%s should return api_key_not_found error when API key is missing", + async (provider, model) => { + await withSharedWorkspaceNoProvider(async ({ env, workspaceId }) => { + // Try to send message without API key configured + const result = await sendMessageWithModel( + env.mockIpcRenderer, + workspaceId, + "Hello", + modelString(provider, model) + ); + + // Should fail with api_key_not_found error + assertError(result, "api_key_not_found"); + if (!result.success && result.error.type === "api_key_not_found") { + expect(result.error.provider).toBe(provider); + } + }); + } + ); + }); + + // Non-existent model error handling tests + describe("non-existent model error handling", () => { + test.each(PROVIDER_CONFIGS)( + "%s should pass additionalSystemInstructions through to system message", + async (provider, model) => { + await withSharedWorkspace(provider, async ({ env, workspaceId }) => { + // Send message with custom system instructions that add a distinctive marker + const result = await sendMessage(env.mockIpcRenderer, workspaceId, "Say hello", { + model: `${provider}:${model}`, + additionalSystemInstructions: + "IMPORTANT: You must include the word BANANA somewhere in every response.", + }); + + // IPC call should succeed + expect(result.success).toBe(true); + + // Wait for stream to complete + const collector = await waitForStreamSuccess(env.sentEvents, workspaceId, 10000); + + // Get the final assistant message + const finalMessage = collector.getFinalMessage(); + expect(finalMessage).toBeDefined(); + + // Verify response contains the distinctive marker from additional system instructions + if (finalMessage && "parts" in finalMessage && Array.isArray(finalMessage.parts)) { + const content = finalMessage.parts + .filter((part) => part.type === "text") + .map((part) => (part as { text: string }).text) + .join(""); + + expect(content).toContain("BANANA"); + } + }); + }, + 15000 + ); + }); + + // OpenAI auto truncation integration test + // This test verifies that the truncation: "auto" parameter works correctly + // by first forcing a context overflow error, then verifying recovery with auto-truncation + describeIntegration("OpenAI auto truncation integration", () => { + const provider = "openai"; + const model = "gpt-4o-mini"; + + test.each(PROVIDER_CONFIGS)( + "%s should include full file_edit diff in UI/history but redact it from the next provider request", + async (provider, model) => { + await withSharedWorkspace(provider, async ({ env, workspaceId, workspacePath }) => { + // 1) Create a file and ask the model to edit it to ensure a file_edit tool runs + const testFilePath = path.join(workspacePath, "redaction-edit-test.txt"); + await fs.writeFile(testFilePath, "line1\nline2\nline3\n", "utf-8"); + + // Request confirmation to ensure AI generates text after tool calls + // This prevents flaky test failures where AI completes tools but doesn't emit stream-end + + const result1 = await sendMessageWithModel( + env.mockIpcRenderer, + workspaceId, + `Open and replace 'line2' with 'LINE2' in ${path.basename(testFilePath)} using file_edit_replace, then confirm the change was successfully applied.`, + modelString(provider, model) + ); + expect(result1.success).toBe(true); + + // Wait for first stream to complete + const collector1 = createEventCollector(env.sentEvents, workspaceId); + await collector1.waitForEvent("stream-end", 60000); + assertStreamSuccess(collector1); + + // 2) Validate UI/history has a dynamic-tool part with a real diff string + const events1 = collector1.getEvents(); + const allFileEditEvents = events1.filter( + (e) => + typeof e === "object" && + e !== null && + "type" in e && + (e as any).type === "tool-call-end" && + ((e as any).toolName === "file_edit_replace_string" || + (e as any).toolName === "file_edit_replace_lines") + ) as any[]; + + // Find the last successful file_edit_replace_* event (model may retry) + const successfulEdits = allFileEditEvents.filter((e) => { + const result = e?.result; + const payload = result && result.value ? result.value : result; + return payload?.success === true; + }); + + expect(successfulEdits.length).toBeGreaterThan(0); + const toolEnd = successfulEdits[successfulEdits.length - 1]; + const toolResult = toolEnd?.result; + // result may be wrapped as { type: 'json', value: {...} } + const payload = toolResult && toolResult.value ? toolResult.value : toolResult; + expect(payload?.success).toBe(true); + expect(typeof payload?.diff).toBe("string"); + expect(payload?.diff).toContain("@@"); // unified diff hunk header present + + // 3) Now send another message and ensure we still succeed (redaction must not break anything) + env.sentEvents.length = 0; + const result2 = await sendMessageWithModel( + env.mockIpcRenderer, + workspaceId, + "Confirm the previous edit was applied.", + modelString(provider, model) + ); + expect(result2.success).toBe(true); + + const collector2 = createEventCollector(env.sentEvents, workspaceId); + await collector2.waitForEvent("stream-end", 30000); + assertStreamSuccess(collector2); + + // Note: We don't assert on the exact provider payload (black box), but the fact that + // the second request succeeds proves the redaction path produced valid provider messages + }); + }, + 90000 + ); + }); + + // Test multi-turn conversation with response ID persistence + describe.each(PROVIDER_CONFIGS)("%s:%s response ID persistence", (provider, model) => { + test.concurrent( + "should handle multi-turn conversation with response ID persistence", + async () => { + await withSharedWorkspace(provider, async ({ env, workspaceId }) => { + // First message + const result1 = await sendMessageWithModel( + env.mockIpcRenderer, + workspaceId, + "What is 2+2?", + modelString(provider, model) + ); + expect(result1.success).toBe(true); + + const collector1 = createEventCollector(env.sentEvents, workspaceId); + await collector1.waitForEvent("stream-end", 30000); + assertStreamSuccess(collector1); + env.sentEvents.length = 0; // Clear events + + // Second message - should use previousResponseId from first + const result2 = await sendMessageWithModel( + env.mockIpcRenderer, + workspaceId, + "Now add 3 to that", + modelString(provider, model) + ); + expect(result2.success).toBe(true); + + const collector2 = createEventCollector(env.sentEvents, workspaceId); + await collector2.waitForEvent("stream-end", 30000); + assertStreamSuccess(collector2); + + // Verify history contains both messages + // Note: readChatHistory needs the temp directory (root of config). + const history = await readChatHistory(env.tempDir, workspaceId); + expect(history.length).toBeGreaterThanOrEqual(4); // 2 user + 2 assistant + + // Verify assistant messages have responseId + const assistantMessages = history.filter((m) => m.role === "assistant"); + expect(assistantMessages.length).toBeGreaterThanOrEqual(2); + + // Check that responseId exists (if provider supports it) + if (provider === "openai") { + const firstAssistant = assistantMessages[0] as any; + const secondAssistant = assistantMessages[1] as any; + expect(firstAssistant.metadata?.providerMetadata?.openai?.responseId).toBeDefined(); + expect(secondAssistant.metadata?.providerMetadata?.openai?.responseId).toBeDefined(); + } + }); + }, + 60000 + ); + }); +}); diff --git a/tests/ipcMain/sendMessage.errors.test.ts b/tests/ipcMain/sendMessage.errors.test.ts new file mode 100644 index 000000000..724151e03 --- /dev/null +++ b/tests/ipcMain/sendMessage.errors.test.ts @@ -0,0 +1,433 @@ +import * as fs from "fs/promises"; +import * as path from "path"; +import { shouldRunIntegrationTests, validateApiKeys } from "./setup"; +import { + sendMessageWithModel, + sendMessage, + createEventCollector, + assertStreamSuccess, + assertError, + waitFor, + buildLargeHistory, + waitForStreamSuccess, + readChatHistory, + modelString, + configureTestRetries, +} from "./helpers"; +import { createSharedRepo, cleanupSharedRepo, withSharedWorkspace } from "./sendMessageTestHelpers"; +import { preloadTestModules } from "./setup"; +import type { StreamDeltaEvent } from "../../src/common/types/stream"; +import { IPC_CHANNELS } from "../../src/common/constants/ipc-constants"; + +// Skip all tests if TEST_INTEGRATION is not set +const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip; + +// Validate API keys before running tests +if (shouldRunIntegrationTests()) { + validateApiKeys(["OPENAI_API_KEY", "ANTHROPIC_API_KEY"]); +} + +import { KNOWN_MODELS } from "@/common/constants/knownModels"; + +// Test both providers with their respective models +const PROVIDER_CONFIGS: Array<[string, string]> = [ + ["openai", KNOWN_MODELS.GPT_MINI.providerModelId], + ["anthropic", KNOWN_MODELS.SONNET.providerModelId], +]; + +// Integration test timeout guidelines: +// - Individual tests should complete within 10 seconds when possible +// - Use tight timeouts (5-10s) for event waiting to fail fast +// - Longer running tests (tool calls, multiple edits) can take up to 30s +// - Test timeout values (in describe/test) should be 2-3x the expected duration + +describeIntegration("IpcMain sendMessage integration tests", () => { + beforeAll(async () => { + await preloadTestModules(); + await createSharedRepo(); + }); + afterAll(cleanupSharedRepo); + + configureTestRetries(3); + + // Run tests for each provider concurrently + describe.each(PROVIDER_CONFIGS)("%s:%s provider tests", (provider, model) => { + test.concurrent( + "should reject empty message (use interruptStream instead)", + async () => { + await withSharedWorkspace(provider, async ({ env, workspaceId }) => { + // Send empty message without any active stream + const result = await sendMessageWithModel( + env.mockIpcRenderer, + workspaceId, + "", + modelString(provider, model) + ); + + // Should fail - empty messages not allowed + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.type).toBe("unknown"); + if (result.error.type === "unknown") { + expect(result.error.raw).toContain("Empty message not allowed"); + } + } + + // Should not have created any stream events + const collector = createEventCollector(env.sentEvents, workspaceId); + collector.collect(); + + const streamEvents = collector + .getEvents() + .filter((e) => "type" in e && e.type?.startsWith("stream-")); + expect(streamEvents.length).toBe(0); + }); + }, + 15000 + ); + + test.concurrent("should return error when model is not provided", async () => { + await withSharedWorkspace(provider, async ({ env, workspaceId }) => { + // Send message without model + const result = await sendMessage( + env.mockIpcRenderer, + workspaceId, + "Hello", + {} as { model: string } + ); + + // Should fail with appropriate error + assertError(result, "unknown"); + if (!result.success && result.error.type === "unknown") { + expect(result.error.raw).toContain("No model specified"); + } + }); + }); + + test.concurrent("should return error for invalid model string", async () => { + await withSharedWorkspace(provider, async ({ env, workspaceId }) => { + // Send message with invalid model format + const result = await sendMessage(env.mockIpcRenderer, workspaceId, "Hello", { + model: "invalid-format", + }); + + // Should fail with invalid_model_string error + assertError(result, "invalid_model_string"); + }); + }); + + test.each(PROVIDER_CONFIGS)( + "%s should return stream error when model does not exist", + async (provider) => { + await withSharedWorkspace(provider, async ({ env, workspaceId }) => { + // Use a clearly non-existent model name + const nonExistentModel = "definitely-not-a-real-model-12345"; + const result = await sendMessageWithModel( + env.mockIpcRenderer, + workspaceId, + "Hello, world!", + modelString(provider, nonExistentModel) + ); + + // IPC call should succeed (errors come through stream events) + expect(result.success).toBe(true); + + // Wait for stream-error event + const collector = createEventCollector(env.sentEvents, workspaceId); + const errorEvent = await collector.waitForEvent("stream-error", 10000); + + // Should have received a stream-error event + expect(errorEvent).toBeDefined(); + expect(collector.hasError()).toBe(true); + + // Verify error message is the enhanced user-friendly version + if (errorEvent && "error" in errorEvent) { + const errorMsg = String(errorEvent.error); + // Should have the enhanced error message format + expect(errorMsg).toContain("definitely-not-a-real-model-12345"); + expect(errorMsg).toContain("does not exist or is not available"); + } + + // Verify error type is properly categorized + if (errorEvent && "errorType" in errorEvent) { + expect(errorEvent.errorType).toBe("model_not_found"); + } + }); + } + ); + }); + + // Token limit error handling tests + describe("token limit error handling", () => { + test.each(PROVIDER_CONFIGS)( + "%s should return error when accumulated history exceeds token limit", + async (provider, model) => { + await withSharedWorkspace(provider, async ({ env, workspaceId }) => { + // Build up large conversation history to exceed context limits + // Different providers have different limits: + // - Anthropic: 200k tokens → need ~40 messages of 50k chars (2M chars total) + // - OpenAI: varies by model, use ~80 messages (4M chars total) to ensure we hit the limit + await buildLargeHistory(workspaceId, env.config, { + messageSize: 50_000, + messageCount: provider === "anthropic" ? 40 : 80, + }); + + // Now try to send a new message - should trigger token limit error + // due to accumulated history + // Disable auto-truncation to force context error + const sendOptions = + provider === "openai" + ? { + providerOptions: { + openai: { + disableAutoTruncation: true, + forceContextLimitError: true, + }, + }, + } + : undefined; + const result = await sendMessageWithModel( + env.mockIpcRenderer, + workspaceId, + "What is the weather?", + modelString(provider, model), + sendOptions + ); + + // IPC call itself should succeed (errors come through stream events) + expect(result.success).toBe(true); + + // Wait for either stream-end or stream-error + const collector = createEventCollector(env.sentEvents, workspaceId); + await Promise.race([ + collector.waitForEvent("stream-end", 10000), + collector.waitForEvent("stream-error", 10000), + ]); + + // Should have received error event with token limit error + expect(collector.hasError()).toBe(true); + + // Verify error is properly categorized as context_exceeded + const errorEvents = collector + .getEvents() + .filter((e) => "type" in e && e.type === "stream-error"); + expect(errorEvents.length).toBeGreaterThan(0); + + const errorEvent = errorEvents[0]; + + // Verify error type is context_exceeded + if (errorEvent && "errorType" in errorEvent) { + expect(errorEvent.errorType).toBe("context_exceeded"); + } + + // NEW: Verify error handling improvements + // 1. Verify error event includes messageId + if (errorEvent && "messageId" in errorEvent) { + expect(errorEvent.messageId).toBeDefined(); + expect(typeof errorEvent.messageId).toBe("string"); + } + + // 2. Verify error persists across "reload" by simulating page reload via IPC + // Clear sentEvents and trigger subscription (simulates what happens on page reload) + env.sentEvents.length = 0; + + // Trigger the subscription using ipcRenderer.send() (correct way to trigger ipcMain.on()) + env.mockIpcRenderer.send(`workspace:chat:subscribe`, workspaceId); + + // Wait for the async subscription handler to complete by polling for caught-up + const reloadCollector = createEventCollector(env.sentEvents, workspaceId); + const caughtUpMessage = await reloadCollector.waitForEvent("caught-up", 10000); + expect(caughtUpMessage).toBeDefined(); + + // 3. Find the partial message with error metadata in reloaded messages + const reloadedMessages = reloadCollector.getEvents(); + const partialMessage = reloadedMessages.find( + (msg) => + msg && + typeof msg === "object" && + "metadata" in msg && + msg.metadata && + typeof msg.metadata === "object" && + "error" in msg.metadata + ); + + // 4. Verify partial message has error metadata + expect(partialMessage).toBeDefined(); + if ( + partialMessage && + typeof partialMessage === "object" && + "metadata" in partialMessage && + partialMessage.metadata && + typeof partialMessage.metadata === "object" + ) { + expect("error" in partialMessage.metadata).toBe(true); + expect("errorType" in partialMessage.metadata).toBe(true); + expect("partial" in partialMessage.metadata).toBe(true); + if ("partial" in partialMessage.metadata) { + expect(partialMessage.metadata.partial).toBe(true); + } + + // Verify error type is context_exceeded + if ("errorType" in partialMessage.metadata) { + expect(partialMessage.metadata.errorType).toBe("context_exceeded"); + } + } + }); + }, + 30000 + ); + }); + + // Tool policy tests + describe("tool policy", () => { + // Retry tool policy tests in CI (they depend on external API behavior) + if (process.env.CI && typeof jest !== "undefined" && jest.retryTimes) { + jest.retryTimes(2, { logErrorsBeforeRetry: true }); + } + + test.each(PROVIDER_CONFIGS)( + "%s should respect tool policy that disables bash", + async (provider, model) => { + await withSharedWorkspace(provider, async ({ env, workspaceId, workspacePath }) => { + // Create a test file in the workspace + const testFilePath = path.join(workspacePath, "bash-test-file.txt"); + await fs.writeFile(testFilePath, "original content", "utf-8"); + + // Verify file exists + expect( + await fs.access(testFilePath).then( + () => true, + () => false + ) + ).toBe(true); + + // Ask AI to delete the file using bash (which should be disabled) + const result = await sendMessageWithModel( + env.mockIpcRenderer, + workspaceId, + "Delete the file bash-test-file.txt using bash rm command", + modelString(provider, model), + { + toolPolicy: [{ regex_match: "bash", action: "disable" }], + ...(provider === "openai" + ? { providerOptions: { openai: { simulateToolPolicyNoop: true } } } + : {}), + } + ); + + // IPC call should succeed + expect(result.success).toBe(true); + + // Wait for stream to complete (longer timeout for tool policy tests) + const collector = createEventCollector(env.sentEvents, workspaceId); + + // Wait for either stream-end or stream-error + // (helpers will log diagnostic info on failure) + const streamTimeout = provider === "openai" ? 90000 : 30000; + await Promise.race([ + collector.waitForEvent("stream-end", streamTimeout), + collector.waitForEvent("stream-error", streamTimeout), + ]); + + // This will throw with detailed error info if stream didn't complete successfully + assertStreamSuccess(collector); + + if (provider === "openai") { + const deltas = collector.getDeltas(); + const noopDelta = deltas.find( + (event): event is StreamDeltaEvent => + "type" in event && + event.type === "stream-delta" && + typeof (event as StreamDeltaEvent).delta === "string" + ); + expect(noopDelta?.delta).toContain( + "Tool execution skipped because the requested tool is disabled by policy." + ); + } + + // Verify file still exists (bash tool was disabled, so deletion shouldn't have happened) + const fileStillExists = await fs.access(testFilePath).then( + () => true, + () => false + ); + expect(fileStillExists).toBe(true); + + // Verify content unchanged + const content = await fs.readFile(testFilePath, "utf-8"); + expect(content).toBe("original content"); + }); + }, + 90000 + ); + + test.each(PROVIDER_CONFIGS)( + "%s should respect tool policy that disables file_edit tools", + async (provider, model) => { + await withSharedWorkspace(provider, async ({ env, workspaceId, workspacePath }) => { + // Create a test file with known content + const testFilePath = path.join(workspacePath, "edit-test-file.txt"); + const originalContent = "original content line 1\noriginal content line 2"; + await fs.writeFile(testFilePath, originalContent, "utf-8"); + + // Ask AI to edit the file (which should be disabled) + // Disable both file_edit tools AND bash to prevent workarounds + const result = await sendMessageWithModel( + env.mockIpcRenderer, + workspaceId, + "Edit the file edit-test-file.txt and replace 'original' with 'modified'", + modelString(provider, model), + { + toolPolicy: [ + { regex_match: "file_edit_.*", action: "disable" }, + { regex_match: "bash", action: "disable" }, + ], + ...(provider === "openai" + ? { providerOptions: { openai: { simulateToolPolicyNoop: true } } } + : {}), + } + ); + + // IPC call should succeed + expect(result.success).toBe(true); + + // Wait for stream to complete (longer timeout for tool policy tests) + const collector = createEventCollector(env.sentEvents, workspaceId); + + // Wait for either stream-end or stream-error + // (helpers will log diagnostic info on failure) + const streamTimeout = provider === "openai" ? 90000 : 30000; + await Promise.race([ + collector.waitForEvent("stream-end", streamTimeout), + collector.waitForEvent("stream-error", streamTimeout), + ]); + + // This will throw with detailed error info if stream didn't complete successfully + assertStreamSuccess(collector); + + if (provider === "openai") { + const deltas = collector.getDeltas(); + const noopDelta = deltas.find( + (event): event is StreamDeltaEvent => + "type" in event && + event.type === "stream-delta" && + typeof (event as StreamDeltaEvent).delta === "string" + ); + expect(noopDelta?.delta).toContain( + "Tool execution skipped because the requested tool is disabled by policy." + ); + } + + // Verify file content unchanged (file_edit tools and bash were disabled) + const content = await fs.readFile(testFilePath, "utf-8"); + expect(content).toBe(originalContent); + }); + }, + 90000 + ); + }); + + // Additional system instructions tests + describe("additional system instructions", () => {}); + + // Test frontend metadata round-trip (no provider needed - just verifies storage) +}); diff --git a/tests/ipcMain/sendMessage.heavy.test.ts b/tests/ipcMain/sendMessage.heavy.test.ts new file mode 100644 index 000000000..b98d72c67 --- /dev/null +++ b/tests/ipcMain/sendMessage.heavy.test.ts @@ -0,0 +1,127 @@ +import { shouldRunIntegrationTests, validateApiKeys } from "./setup"; +import { + sendMessageWithModel, + sendMessage, + createEventCollector, + assertStreamSuccess, + assertError, + waitFor, + buildLargeHistory, + waitForStreamSuccess, + readChatHistory, + modelString, + configureTestRetries, +} from "./helpers"; +import { createSharedRepo, cleanupSharedRepo, withSharedWorkspace } from "./sendMessageTestHelpers"; +import type { StreamDeltaEvent } from "../../src/common/types/stream"; +import { IPC_CHANNELS } from "../../src/common/constants/ipc-constants"; + +// Skip all tests if TEST_INTEGRATION is not set +const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip; + +// Validate API keys before running tests +if (shouldRunIntegrationTests()) { + validateApiKeys(["OPENAI_API_KEY", "ANTHROPIC_API_KEY"]); +} + +import { KNOWN_MODELS } from "@/common/constants/knownModels"; + +// Test both providers with their respective models +const PROVIDER_CONFIGS: Array<[string, string]> = [ + ["openai", KNOWN_MODELS.GPT_MINI.providerModelId], + ["anthropic", KNOWN_MODELS.SONNET.providerModelId], +]; + +// Integration test timeout guidelines: +// - Individual tests should complete within 10 seconds when possible +// - Use tight timeouts (5-10s) for event waiting to fail fast +// - Longer running tests (tool calls, multiple edits) can take up to 30s +// - Test timeout values (in describe/test) should be 2-3x the expected duration + +beforeAll(createSharedRepo); +afterAll(cleanupSharedRepo); +describeIntegration("IpcMain sendMessage integration tests", () => { + configureTestRetries(3); + + // Run tests for each provider concurrently + describeIntegration("OpenAI auto truncation integration", () => { + const provider = "openai"; + const model = "gpt-4o-mini"; + + test.concurrent( + "respects disableAutoTruncation flag", + async () => { + await withSharedWorkspace(provider, async ({ env, workspaceId }) => { + // Phase 1: Build up large conversation history to exceed context limit + // Use ~80 messages (4M chars total) to ensure we hit the limit + await buildLargeHistory(workspaceId, env.config, { + messageSize: 50_000, + messageCount: 80, + }); + + // Now send a new message with auto-truncation disabled - should trigger error + const result = await sendMessageWithModel( + env.mockIpcRenderer, + workspaceId, + "This should trigger a context error", + modelString(provider, model), + { + providerOptions: { + openai: { + disableAutoTruncation: true, + forceContextLimitError: true, + }, + }, + } + ); + + // IPC call itself should succeed (errors come through stream events) + expect(result.success).toBe(true); + + // Wait for either stream-end or stream-error + const collector = createEventCollector(env.sentEvents, workspaceId); + await Promise.race([ + collector.waitForEvent("stream-end", 10000), + collector.waitForEvent("stream-error", 10000), + ]); + + // Should have received error event with context exceeded error + expect(collector.hasError()).toBe(true); + + // Check that error message contains context-related keywords + const errorEvents = collector + .getEvents() + .filter((e) => "type" in e && e.type === "stream-error"); + expect(errorEvents.length).toBeGreaterThan(0); + + const errorEvent = errorEvents[0]; + if (errorEvent && "error" in errorEvent) { + const errorStr = String(errorEvent.error).toLowerCase(); + expect( + errorStr.includes("context") || + errorStr.includes("length") || + errorStr.includes("exceed") || + errorStr.includes("token") + ).toBe(true); + } + + // Phase 2: Send message with auto-truncation enabled (should succeed) + env.sentEvents.length = 0; + const successResult = await sendMessageWithModel( + env.mockIpcRenderer, + workspaceId, + "This should succeed with auto-truncation", + modelString(provider, model) + // disableAutoTruncation defaults to false (auto-truncation enabled) + ); + + expect(successResult.success).toBe(true); + const successCollector = createEventCollector(env.sentEvents, workspaceId); + await successCollector.waitForEvent("stream-end", 30000); + assertStreamSuccess(successCollector); + }); + }, + 60000 // 1 minute timeout (much faster since we don't make many API calls) + ); + }); +}); diff --git a/tests/ipcMain/sendMessage.images.test.ts b/tests/ipcMain/sendMessage.images.test.ts new file mode 100644 index 000000000..434f35bef --- /dev/null +++ b/tests/ipcMain/sendMessage.images.test.ts @@ -0,0 +1,132 @@ +import { shouldRunIntegrationTests, validateApiKeys } from "./setup"; +import { + sendMessageWithModel, + sendMessage, + createEventCollector, + assertStreamSuccess, + assertError, + waitFor, + waitForStreamSuccess, + readChatHistory, + TEST_IMAGES, + modelString, + configureTestRetries, +} from "./helpers"; +import { createSharedRepo, cleanupSharedRepo, withSharedWorkspace } from "./sendMessageTestHelpers"; +import type { StreamDeltaEvent } from "../../src/common/types/stream"; +import { IPC_CHANNELS } from "../../src/common/constants/ipc-constants"; + +// Skip all tests if TEST_INTEGRATION is not set +const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip; + +// Validate API keys before running tests +if (shouldRunIntegrationTests()) { + validateApiKeys(["OPENAI_API_KEY", "ANTHROPIC_API_KEY"]); +} + +import { KNOWN_MODELS } from "@/common/constants/knownModels"; + +// Test both providers with their respective models +const PROVIDER_CONFIGS: Array<[string, string]> = [ + ["openai", KNOWN_MODELS.GPT_MINI.providerModelId], + ["anthropic", KNOWN_MODELS.SONNET.providerModelId], +]; + +// Integration test timeout guidelines: +// - Individual tests should complete within 10 seconds when possible +// - Use tight timeouts (5-10s) for event waiting to fail fast +// - Longer running tests (tool calls, multiple edits) can take up to 30s +// - Test timeout values (in describe/test) should be 2-3x the expected duration + +beforeAll(createSharedRepo); +afterAll(cleanupSharedRepo); +describeIntegration("IpcMain sendMessage integration tests", () => { + configureTestRetries(3); + + // Run tests for each provider concurrently + describe.each(PROVIDER_CONFIGS)("%s:%s provider tests", (provider, model) => { + // Test image support + test.concurrent( + "should send images to AI model and get response", + async () => { + // Skip Anthropic for now as it fails to process the image data URI in tests + if (provider === "anthropic") return; + + await withSharedWorkspace(provider, async ({ env, workspaceId }) => { + // Send message with image attachment + const result = await sendMessage( + env.mockIpcRenderer, + workspaceId, + "What color is this?", + { + model: modelString(provider, model), + imageParts: [TEST_IMAGES.RED_PIXEL], + } + ); + + expect(result.success).toBe(true); + + // Wait for stream to complete + const collector = await waitForStreamSuccess(env.sentEvents, workspaceId, 30000); + + // Verify we got a response about the image + const deltas = collector.getDeltas(); + expect(deltas.length).toBeGreaterThan(0); + + // Combine all text deltas + const fullResponse = deltas + .map((d) => (d as StreamDeltaEvent).delta) + .join("") + .toLowerCase(); + + // Should mention red color in some form + expect(fullResponse.length).toBeGreaterThan(0); + // Red pixel should be detected (flexible matching as different models may phrase differently) + expect(fullResponse).toMatch(/red|color|orange/i); + }); + }, + 40000 // Vision models can be slower + ); + + test.concurrent( + "should preserve image parts through history", + async () => { + // Skip Anthropic for now as it fails to process the image data URI in tests + if (provider === "anthropic") return; + + await withSharedWorkspace(provider, async ({ env, workspaceId }) => { + // Send message with image + const result = await sendMessage(env.mockIpcRenderer, workspaceId, "Describe this", { + model: modelString(provider, model), + imageParts: [TEST_IMAGES.BLUE_PIXEL], + }); + + expect(result.success).toBe(true); + + // Wait for stream to complete + await waitForStreamSuccess(env.sentEvents, workspaceId, 30000); + + // Read history from disk + const messages = await readChatHistory(env.tempDir, workspaceId); + + // Find the user message + const userMessage = messages.find((m: { role: string }) => m.role === "user"); + expect(userMessage).toBeDefined(); + + // Verify image part is preserved with correct format + if (userMessage) { + const imagePart = userMessage.parts.find((p: { type: string }) => p.type === "file"); + expect(imagePart).toBeDefined(); + if (imagePart) { + expect(imagePart.url).toBe(TEST_IMAGES.BLUE_PIXEL.url); + expect(imagePart.mediaType).toBe("image/png"); + } + } + }); + }, + 40000 + ); + + // Test multi-turn conversation specifically for reasoning models (codex mini) + }); +}); diff --git a/tests/ipcMain/sendMessage.reasoning.test.ts b/tests/ipcMain/sendMessage.reasoning.test.ts new file mode 100644 index 000000000..10dc01218 --- /dev/null +++ b/tests/ipcMain/sendMessage.reasoning.test.ts @@ -0,0 +1,60 @@ +/** + * Integration tests for reasoning/thinking functionality across Anthropic models. + * Verifies Opus 4.5 uses `effort` and Sonnet 4.5 uses `thinking.budgetTokens`. + */ + +import { shouldRunIntegrationTests, validateApiKeys } from "./setup"; +import { sendMessage, assertStreamSuccess, waitForStreamSuccess } from "./helpers"; +import { createSharedRepo, cleanupSharedRepo, withSharedWorkspace } from "./sendMessageTestHelpers"; +import { KNOWN_MODELS } from "@/common/constants/knownModels"; + +const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip; + +if (shouldRunIntegrationTests()) { + validateApiKeys(["ANTHROPIC_API_KEY"]); +} + +beforeAll(createSharedRepo); +afterAll(cleanupSharedRepo); + +describeIntegration("Anthropic reasoning parameter tests", () => { + test.concurrent( + "Sonnet 4.5 with thinking (budgetTokens)", + async () => { + await withSharedWorkspace("anthropic", async ({ env, workspaceId }) => { + const result = await sendMessage( + env.mockIpcRenderer, + workspaceId, + "What is 2+2? Answer in one word.", + { model: KNOWN_MODELS.SONNET.id, thinkingLevel: "low" } + ); + expect(result.success).toBe(true); + + const collector = await waitForStreamSuccess(env.sentEvents, workspaceId, 30000); + assertStreamSuccess(collector); + expect(collector.getDeltas().length).toBeGreaterThan(0); + }); + }, + 60000 + ); + + test.concurrent( + "Opus 4.5 with thinking (effort)", + async () => { + await withSharedWorkspace("anthropic", async ({ env, workspaceId }) => { + const result = await sendMessage( + env.mockIpcRenderer, + workspaceId, + "What is 4+4? Answer in one word.", + { model: KNOWN_MODELS.OPUS.id, thinkingLevel: "low" } + ); + expect(result.success).toBe(true); + + const collector = await waitForStreamSuccess(env.sentEvents, workspaceId, 60000); + assertStreamSuccess(collector); + expect(collector.getDeltas().length).toBeGreaterThan(0); + }); + }, + 90000 + ); +}); diff --git a/tests/ipcMain/sendMessageTestHelpers.ts b/tests/ipcMain/sendMessageTestHelpers.ts new file mode 100644 index 000000000..c00ffe674 --- /dev/null +++ b/tests/ipcMain/sendMessageTestHelpers.ts @@ -0,0 +1,61 @@ +import { createTempGitRepo, cleanupTempGitRepo } from "./helpers"; +import { setupWorkspace, setupWorkspaceWithoutProvider } from "./setup"; +import type { TestEnvironment } from "./setup"; + +let sharedRepoPath: string | undefined; + +export interface SharedWorkspaceContext { + env: TestEnvironment; + workspaceId: string; + workspacePath: string; + branchName: string; + tempGitRepo: string; +} + +export async function createSharedRepo(): Promise { + if (!sharedRepoPath) { + sharedRepoPath = await createTempGitRepo(); + } +} + +export async function cleanupSharedRepo(): Promise { + if (sharedRepoPath) { + await cleanupTempGitRepo(sharedRepoPath); + sharedRepoPath = undefined; + } +} + +export async function withSharedWorkspace( + provider: string, + testFn: (context: SharedWorkspaceContext) => Promise +): Promise { + if (!sharedRepoPath) { + throw new Error("Shared repo has not been created yet."); + } + + const { env, workspaceId, workspacePath, branchName, tempGitRepo, cleanup } = + await setupWorkspace(provider, undefined, sharedRepoPath); + + try { + await testFn({ env, workspaceId, workspacePath, branchName, tempGitRepo }); + } finally { + await cleanup(); + } +} + +export async function withSharedWorkspaceNoProvider( + testFn: (context: SharedWorkspaceContext) => Promise +): Promise { + if (!sharedRepoPath) { + throw new Error("Shared repo has not been created yet."); + } + + const { env, workspaceId, workspacePath, branchName, tempGitRepo, cleanup } = + await setupWorkspaceWithoutProvider(undefined, sharedRepoPath); + + try { + await testFn({ env, workspaceId, workspacePath, branchName, tempGitRepo }); + } finally { + await cleanup(); + } +} diff --git a/tests/integration/setup.ts b/tests/ipcMain/setup.ts similarity index 64% rename from tests/integration/setup.ts rename to tests/ipcMain/setup.ts index f3f9bdfa7..77e4cc1ca 100644 --- a/tests/integration/setup.ts +++ b/tests/ipcMain/setup.ts @@ -1,41 +1,41 @@ import * as os from "os"; import * as path from "path"; import * as fs from "fs/promises"; -import type { BrowserWindow, WebContents } from "electron"; +import type { BrowserWindow, IpcMain as ElectronIpcMain, WebContents } from "electron"; +import type { IpcRenderer } from "electron"; +import createIPCMock from "electron-mock-ipc"; import { Config } from "../../src/node/config"; -import { ServiceContainer } from "../../src/node/services/serviceContainer"; -import { - generateBranchName, - createWorkspace, - resolveOrpcClient, - createTempGitRepo, - cleanupTempGitRepo, -} from "./helpers"; -import type { OrpcSource } from "./helpers"; -import type { ORPCContext } from "../../src/node/orpc/context"; -import { createOrpcTestClient, type OrpcTestClient } from "./orpcTestClient"; +import { IpcMain } from "../../src/node/services/ipcMain"; +import { IPC_CHANNELS } from "../../src/common/constants/ipc-constants"; +import { generateBranchName, createWorkspace } from "./helpers"; import { shouldRunIntegrationTests, validateApiKeys, getApiKey } from "../testUtils"; export interface TestEnvironment { config: Config; - services: ServiceContainer; + ipcMain: IpcMain; + mockIpcMain: ElectronIpcMain; + mockIpcRenderer: Electron.IpcRenderer; mockWindow: BrowserWindow; tempDir: string; - orpc: OrpcTestClient; + sentEvents: Array<{ channel: string; data: unknown; timestamp: number }>; } /** - * Create a mock BrowserWindow for tests. - * Note: Events are now consumed via ORPC subscriptions (StreamCollector), - * not via windowService.send(). This mock just satisfies the window service API. + * Create a mock BrowserWindow that captures sent events */ -function createMockBrowserWindow(): BrowserWindow { +function createMockBrowserWindow(): { + window: BrowserWindow; + sentEvents: Array<{ channel: string; data: unknown; timestamp: number }>; +} { + const sentEvents: Array<{ channel: string; data: unknown; timestamp: number }> = []; + const mockWindow = { webContents: { - send: jest.fn(), + send: (channel: string, data: unknown) => { + sentEvents.push({ channel, data, timestamp: Date.now() }); + }, openDevTools: jest.fn(), } as unknown as WebContents, - isDestroyed: jest.fn(() => false), isMinimized: jest.fn(() => false), restore: jest.fn(), focus: jest.fn(), @@ -44,11 +44,11 @@ function createMockBrowserWindow(): BrowserWindow { setTitle: jest.fn(), } as unknown as BrowserWindow; - return mockWindow; + return { window: mockWindow, sentEvents }; } /** - * Create a test environment with temporary config and service container + * Create a test environment with temporary config and mocked IPC */ export async function createTestEnvironment(): Promise { // Create temporary directory for test config @@ -58,34 +58,28 @@ export async function createTestEnvironment(): Promise { const config = new Config(tempDir); // Create mock BrowserWindow - const mockWindow = createMockBrowserWindow(); - - // Create ServiceContainer instance - const services = new ServiceContainer(config); - await services.initialize(); - - // Wire services to the mock BrowserWindow - // Note: Events are consumed via ORPC subscriptions (StreamCollector), not windowService.send() - services.windowService.setMainWindow(mockWindow); - - const orpcContext: ORPCContext = { - projectService: services.projectService, - workspaceService: services.workspaceService, - providerService: services.providerService, - terminalService: services.terminalService, - windowService: services.windowService, - updateService: services.updateService, - tokenizerService: services.tokenizerService, - serverService: services.serverService, - }; - const orpc = createOrpcTestClient(orpcContext); + const { window: mockWindow, sentEvents } = createMockBrowserWindow(); + + // Create mock IPC + const mocked = createIPCMock(); + const mockIpcMainModule = mocked.ipcMain; + const mockIpcRendererModule = mocked.ipcRenderer; + + // Create IpcMain instance + const ipcMain = new IpcMain(config); + await ipcMain.initialize(); + + // Register handlers with mock ipcMain and window + ipcMain.register(mockIpcMainModule, mockWindow); return { config, - services, + ipcMain, + mockIpcMain: mockIpcMainModule, + mockIpcRenderer: mockIpcRendererModule, mockWindow, tempDir, - orpc, + sentEvents, }; } @@ -115,17 +109,17 @@ export async function cleanupTestEnvironment(env: TestEnvironment): Promise ): Promise { - const client = resolveOrpcClient(source); for (const [providerName, providerConfig] of Object.entries(providers)) { for (const [key, value] of Object.entries(providerConfig)) { - const result = await client.providers.setProviderConfig({ - provider: providerName, - keyPath: [key], - value: String(value), - }); + const result = await mockIpcRenderer.invoke( + IPC_CHANNELS.PROVIDERS_SET_CONFIG, + providerName, + [key], + String(value) + ); if (!result.success) { throw new Error( @@ -157,7 +151,8 @@ export async function preloadTestModules(): Promise { */ export async function setupWorkspace( provider: string, - branchPrefix?: string + branchPrefix?: string, + existingRepoPath?: string ): Promise<{ env: TestEnvironment; workspaceId: string; @@ -166,20 +161,28 @@ export async function setupWorkspace( tempGitRepo: string; cleanup: () => Promise; }> { - // Create dedicated temp git repo for this test - const tempGitRepo = await createTempGitRepo(); + const { createTempGitRepo, cleanupTempGitRepo } = await import("./helpers"); + + // Create dedicated temp git repo for this test unless one is provided + const tempGitRepo = existingRepoPath || (await createTempGitRepo()); + + const cleanupRepo = async () => { + if (!existingRepoPath) { + await cleanupTempGitRepo(tempGitRepo); + } + }; const env = await createTestEnvironment(); // Ollama doesn't require API keys - it's a local service if (provider === "ollama") { - await setupProviders(env, { + await setupProviders(env.mockIpcRenderer, { [provider]: { baseUrl: process.env.OLLAMA_BASE_URL || "http://localhost:11434/api", }, }); } else { - await setupProviders(env, { + await setupProviders(env.mockIpcRenderer, { [provider]: { apiKey: getApiKey(`${provider.toUpperCase()}_API_KEY`), }, @@ -187,26 +190,29 @@ export async function setupWorkspace( } const branchName = generateBranchName(branchPrefix || provider); - const createResult = await createWorkspace(env, tempGitRepo, branchName); + const createResult = await createWorkspace(env.mockIpcRenderer, tempGitRepo, branchName); if (!createResult.success) { - await cleanupTempGitRepo(tempGitRepo); + await cleanupRepo(); throw new Error(`Workspace creation failed: ${createResult.error}`); } if (!createResult.metadata.id) { - await cleanupTempGitRepo(tempGitRepo); + await cleanupRepo(); throw new Error("Workspace ID not returned from creation"); } if (!createResult.metadata.namedWorkspacePath) { - await cleanupTempGitRepo(tempGitRepo); + await cleanupRepo(); throw new Error("Workspace path not returned from creation"); } + // Clear events from workspace creation + env.sentEvents.length = 0; + const cleanup = async () => { await cleanupTestEnvironment(env); - await cleanupTempGitRepo(tempGitRepo); + await cleanupRepo(); }; return { @@ -223,7 +229,10 @@ export async function setupWorkspace( * Setup workspace without provider (for API key error tests). * Also clears Anthropic env vars to ensure the error check works. */ -export async function setupWorkspaceWithoutProvider(branchPrefix?: string): Promise<{ +export async function setupWorkspaceWithoutProvider( + branchPrefix?: string, + existingRepoPath?: string +): Promise<{ env: TestEnvironment; workspaceId: string; workspacePath: string; @@ -231,6 +240,8 @@ export async function setupWorkspaceWithoutProvider(branchPrefix?: string): Prom tempGitRepo: string; cleanup: () => Promise; }> { + const { createTempGitRepo, cleanupTempGitRepo } = await import("./helpers"); + // Clear Anthropic env vars to ensure api_key_not_found error is triggered. // Save original values for restoration in cleanup. const savedEnvVars = { @@ -242,33 +253,41 @@ export async function setupWorkspaceWithoutProvider(branchPrefix?: string): Prom delete process.env.ANTHROPIC_AUTH_TOKEN; delete process.env.ANTHROPIC_BASE_URL; - // Create dedicated temp git repo for this test - const tempGitRepo = await createTempGitRepo(); + // Create dedicated temp git repo for this test unless one is provided + const tempGitRepo = existingRepoPath || (await createTempGitRepo()); + + const cleanupRepo = async () => { + if (!existingRepoPath) { + await cleanupTempGitRepo(tempGitRepo); + } + }; const env = await createTestEnvironment(); const branchName = generateBranchName(branchPrefix || "noapi"); - const createResult = await createWorkspace(env, tempGitRepo, branchName); + const createResult = await createWorkspace(env.mockIpcRenderer, tempGitRepo, branchName); if (!createResult.success) { // Restore env vars before throwing Object.assign(process.env, savedEnvVars); - await cleanupTempGitRepo(tempGitRepo); + await cleanupRepo(); throw new Error(`Workspace creation failed: ${createResult.error}`); } if (!createResult.metadata.id) { Object.assign(process.env, savedEnvVars); - await cleanupTempGitRepo(tempGitRepo); + await cleanupRepo(); throw new Error("Workspace ID not returned from creation"); } if (!createResult.metadata.namedWorkspacePath) { Object.assign(process.env, savedEnvVars); - await cleanupTempGitRepo(tempGitRepo); + await cleanupRepo(); throw new Error("Workspace path not returned from creation"); } + env.sentEvents.length = 0; + const cleanup = async () => { // Restore env vars for (const [key, value] of Object.entries(savedEnvVars)) { @@ -277,7 +296,7 @@ export async function setupWorkspaceWithoutProvider(branchPrefix?: string): Prom } } await cleanupTestEnvironment(env); - await cleanupTempGitRepo(tempGitRepo); + await cleanupRepo(); }; return { diff --git a/tests/integration/streamErrorRecovery.test.ts b/tests/ipcMain/streamErrorRecovery.test.ts similarity index 74% rename from tests/integration/streamErrorRecovery.test.ts rename to tests/ipcMain/streamErrorRecovery.test.ts index 6fc293f02..b41e7366d 100644 --- a/tests/integration/streamErrorRecovery.test.ts +++ b/tests/ipcMain/streamErrorRecovery.test.ts @@ -16,15 +16,19 @@ * test the recovery path without relying on actual network failures. */ -import { setupWorkspace, shouldRunIntegrationTests, validateApiKeys } from "./setup"; +import { + setupWorkspace, + shouldRunIntegrationTests, + validateApiKeys, + preloadTestModules, +} from "./setup"; import { sendMessageWithModel, - createStreamCollector, + createEventCollector, readChatHistory, modelString, - resolveOrpcClient, } from "./helpers"; -import type { StreamCollector } from "./streamCollector"; +import { IPC_CHANNELS } from "../../src/common/constants/ipc-constants"; // Skip all tests if TEST_INTEGRATION is not set const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip; @@ -87,47 +91,74 @@ function truncateToLastCompleteMarker(text: string, nonce: string): string { return text.substring(0, endIndex); } -import type { OrpcSource } from "./helpers"; -import type { OrpcTestClient } from "./orpcTestClient"; +/** + * Helper: Trigger an error in an active stream + */ +async function triggerStreamError( + mockIpcRenderer: unknown, + workspaceId: string, + errorMessage: string +): Promise { + const result = await ( + mockIpcRenderer as { + invoke: ( + channel: string, + ...args: unknown[] + ) => Promise<{ success: boolean; error?: string }>; + } + ).invoke(IPC_CHANNELS.DEBUG_TRIGGER_STREAM_ERROR, workspaceId, errorMessage); + if (!result.success) { + throw new Error( + `Failed to trigger stream error: ${errorMessage}. Reason: ${result.error || "unknown"}` + ); + } +} /** * Helper: Resume stream and wait for successful completion - * Uses StreamCollector for ORPC-native event handling + * Filters out pre-resume error events to detect only new errors */ async function resumeAndWaitForSuccess( - source: OrpcSource, + mockIpcRenderer: unknown, workspaceId: string, - client: OrpcTestClient, + sentEvents: Array<{ channel: string; data: unknown }>, model: string, timeoutMs = 15000 ): Promise { - const collector = createStreamCollector(client, workspaceId); - collector.start(); + // Capture event count before resume to filter old error events + const eventCountBeforeResume = sentEvents.length; + + const resumeResult = await ( + mockIpcRenderer as { + invoke: ( + channel: string, + ...args: unknown[] + ) => Promise<{ success: boolean; error?: string }>; + } + ).invoke(IPC_CHANNELS.WORKSPACE_RESUME_STREAM, workspaceId, { model }); - try { - const resumeResult = await client.workspace.resumeStream({ - workspaceId, - options: { model }, - }); + if (!resumeResult.success) { + throw new Error(`Resume failed: ${resumeResult.error}`); + } - if (!resumeResult.success) { - throw new Error(`Resume failed: ${resumeResult.error}`); - } + // Wait for stream-end event after resume + const collector = createEventCollector(sentEvents, workspaceId); + const streamEnd = await collector.waitForEvent("stream-end", timeoutMs); - // Wait for stream-end event after resume - const streamEnd = await collector.waitForEvent("stream-end", timeoutMs); + if (!streamEnd) { + throw new Error("Stream did not complete after resume"); + } - if (!streamEnd) { - throw new Error("Stream did not complete after resume"); - } + // Check that the resumed stream itself didn't error (ignore previous errors) + const eventsAfterResume = sentEvents.slice(eventCountBeforeResume); + const chatChannel = `chat:${workspaceId}`; + const newEvents = eventsAfterResume + .filter((e) => e.channel === chatChannel) + .map((e) => e.data as { type?: string }); - // Check for errors - const hasError = collector.hasError(); - if (hasError) { - throw new Error("Resumed stream encountered an error"); - } - } finally { - collector.stop(); + const hasNewError = newEvents.some((e) => e.type === "stream-error"); + if (hasNewError) { + throw new Error("Resumed stream encountered an error"); } } @@ -135,25 +166,26 @@ async function resumeAndWaitForSuccess( * Collect stream deltas until predicate returns true * Returns the accumulated buffer * - * Uses StreamCollector for ORPC-native event handling + * This function properly tracks consumed events to avoid returning duplicates */ async function collectStreamUntil( - collector: StreamCollector, + collector: ReturnType, predicate: (buffer: string) => boolean, timeoutMs = 15000 ): Promise { const startTime = Date.now(); let buffer = ""; - let lastProcessedCount = 0; + let lastProcessedIndex = -1; await collector.waitForEvent("stream-start", 5000); while (Date.now() - startTime < timeoutMs) { - // Get all deltas + // Collect latest events + collector.collect(); const allDeltas = collector.getDeltas(); - // Process only new deltas - const newDeltas = allDeltas.slice(lastProcessedCount); + // Process only new deltas (beyond lastProcessedIndex) + const newDeltas = allDeltas.slice(lastProcessedIndex + 1); if (newDeltas.length > 0) { for (const delta of newDeltas) { @@ -162,7 +194,7 @@ async function collectStreamUntil( buffer += deltaData.delta; } } - lastProcessedCount = allDeltas.length; + lastProcessedIndex = allDeltas.length - 1; // Log progress periodically if (allDeltas.length % 20 === 0) { @@ -192,14 +224,8 @@ async function collectStreamUntil( throw new Error("Timeout: predicate never satisfied"); } -// TODO: This test requires a debug IPC method (triggerStreamError) that needs to be exposed via ORPC -// Skipping until debug methods are added to ORPC router -const describeSkip = describe.skip; -describeSkip("Stream Error Recovery (No Amnesia)", () => { - // Enable retries in CI for flaky API tests - if (process.env.CI && typeof jest !== "undefined" && jest.retryTimes) { - jest.retryTimes(3, { logErrorsBeforeRetry: true }); - } +describeIntegration("Stream Error Recovery (No Amnesia)", () => { + beforeAll(preloadTestModules); test.concurrent( "should preserve exact prefix and continue from exact point after stream error", @@ -223,12 +249,8 @@ Continue this pattern all the way to 100. Use only single-word number names (six IMPORTANT: Do not add any other text. Start immediately with ${nonce}-1: one. If interrupted, resume from where you stopped without repeating any lines.`; - // Start collector before sending message - const collector = createStreamCollector(env.orpc, workspaceId); - collector.start(); - const sendResult = await sendMessageWithModel( - env, + env.mockIpcRenderer, workspaceId, prompt, modelString(PROVIDER, MODEL), @@ -237,6 +259,7 @@ IMPORTANT: Do not add any other text. Start immediately with ${nonce}-1: one. If expect(sendResult.success).toBe(true); // Collect stream deltas until we have at least STABLE_PREFIX_THRESHOLD complete markers + const collector = createEventCollector(env.sentEvents, workspaceId); const preErrorBuffer = await collectStreamUntil( collector, (buf) => getMaxMarker(nonce, buf) >= STABLE_PREFIX_THRESHOLD, @@ -251,16 +274,18 @@ IMPORTANT: Do not add any other text. Start immediately with ${nonce}-1: one. If console.log(`[Test] Stable prefix ends with: ${stablePrefix.slice(-200)}`); // Trigger error mid-stream - // NOTE: triggerStreamError is a debug method that needs to be added to ORPC router - // For now, skip this test - see describe.skip above - throw new Error("triggerStreamError method not available in ORPC - test skipped"); + await triggerStreamError(env.mockIpcRenderer, workspaceId, "Simulated network error"); // Small delay to let error propagate await new Promise((resolve) => setTimeout(resolve, 500)); // Resume and wait for completion - const client = resolveOrpcClient(env); - await resumeAndWaitForSuccess(env, workspaceId, client, `${PROVIDER}:${MODEL}`); + await resumeAndWaitForSuccess( + env.mockIpcRenderer, + workspaceId, + env.sentEvents, + `${PROVIDER}:${MODEL}` + ); // Read final assistant message from history const history = await readChatHistory(env.tempDir, workspaceId); diff --git a/tests/integration/truncate.test.ts b/tests/ipcMain/truncate.test.ts similarity index 68% rename from tests/integration/truncate.test.ts rename to tests/ipcMain/truncate.test.ts index 2ffcf1a6a..91a9095c6 100644 --- a/tests/integration/truncate.test.ts +++ b/tests/ipcMain/truncate.test.ts @@ -1,13 +1,14 @@ import { setupWorkspace, shouldRunIntegrationTests, validateApiKeys } from "./setup"; import { sendMessageWithModel, - createStreamCollector, + createEventCollector, assertStreamSuccess, - resolveOrpcClient, + waitFor, } from "./helpers"; import { HistoryService } from "../../src/node/services/historyService"; import { createMuxMessage } from "../../src/common/types/message"; -import type { DeleteMessage } from "@/common/orpc/types"; +import type { DeleteMessage } from "../../src/common/types/ipc"; +import { IPC_CHANNELS } from "../../src/common/constants/ipc-constants"; // Skip all tests if TEST_INTEGRATION is not set const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip; @@ -17,7 +18,7 @@ if (shouldRunIntegrationTests()) { validateApiKeys(["ANTHROPIC_API_KEY"]); } -describeIntegration("truncateHistory", () => { +describeIntegration("IpcMain truncate integration tests", () => { test.concurrent( "should truncate 50% of chat history and verify context is updated", async () => { @@ -43,35 +44,52 @@ describeIntegration("truncateHistory", () => { expect(result.success).toBe(true); } - // Setup collector for delete message verification - const deleteCollector = createStreamCollector(env.orpc, workspaceId); - deleteCollector.start(); + // Clear sent events to track truncate operation + env.sentEvents.length = 0; // Truncate 50% of history - const client = resolveOrpcClient(env); - const truncateResult = await client.workspace.truncateHistory({ + const truncateResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_TRUNCATE_HISTORY, workspaceId, - percentage: 0.5, - }); + 0.5 + ); expect(truncateResult.success).toBe(true); // Wait for DeleteMessage to be sent - const deleteEvent = await deleteCollector.waitForEvent("delete", 5000); - expect(deleteEvent).toBeDefined(); - deleteCollector.stop(); + const deleteReceived = await waitFor( + () => + env.sentEvents.some( + (event) => + event.data && + typeof event.data === "object" && + "type" in event.data && + event.data.type === "delete" + ), + 5000 + ); + expect(deleteReceived).toBe(true); + + // Verify DeleteMessage was sent + const deleteMessages = env.sentEvents.filter( + (event) => + event.data && + typeof event.data === "object" && + "type" in event.data && + event.data.type === "delete" + ) as Array<{ channel: string; data: DeleteMessage }>; + expect(deleteMessages.length).toBeGreaterThan(0); // Verify some historySequences were deleted - const deleteMsg = deleteEvent as DeleteMessage; + const deleteMsg = deleteMessages[0].data; expect(deleteMsg.historySequences.length).toBeGreaterThan(0); - // Setup collector for verification message - const collector = createStreamCollector(env.orpc, workspaceId); - collector.start(); + // Clear events again before sending verification message + env.sentEvents.length = 0; // Send a message asking AI to repeat the word from the beginning // This should fail or return "I don't know" because context was truncated const result = await sendMessageWithModel( - env, + env.mockIpcRenderer, workspaceId, "What was the word I asked you to remember at the beginning? Reply with just the word or 'I don't know'." ); @@ -79,6 +97,7 @@ describeIntegration("truncateHistory", () => { expect(result.success).toBe(true); // Wait for response + const collector = createEventCollector(env.sentEvents, workspaceId); await collector.waitForEvent("stream-end", 10000); assertStreamSuccess(collector); @@ -96,7 +115,6 @@ describeIntegration("truncateHistory", () => { // AI should say it doesn't know or doesn't have that information expect(content.toLowerCase()).not.toContain(uniqueWord.toLowerCase()); } - collector.stop(); } finally { await cleanup(); } @@ -126,35 +144,52 @@ describeIntegration("truncateHistory", () => { expect(result.success).toBe(true); } - // Setup collector for delete message verification - const deleteCollector = createStreamCollector(env.orpc, workspaceId); - deleteCollector.start(); + // Clear sent events to track truncate operation + env.sentEvents.length = 0; // Truncate 100% of history (full clear) - const client = resolveOrpcClient(env); - const truncateResult = await client.workspace.truncateHistory({ + const truncateResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_TRUNCATE_HISTORY, workspaceId, - percentage: 1.0, - }); + 1.0 + ); expect(truncateResult.success).toBe(true); // Wait for DeleteMessage to be sent - const deleteEvent = await deleteCollector.waitForEvent("delete", 5000); - expect(deleteEvent).toBeDefined(); - deleteCollector.stop(); + const deleteReceived = await waitFor( + () => + env.sentEvents.some( + (event) => + event.data && + typeof event.data === "object" && + "type" in event.data && + event.data.type === "delete" + ), + 5000 + ); + expect(deleteReceived).toBe(true); + + // Verify DeleteMessage was sent + const deleteMessages = env.sentEvents.filter( + (event) => + event.data && + typeof event.data === "object" && + "type" in event.data && + event.data.type === "delete" + ) as Array<{ channel: string; data: DeleteMessage }>; + expect(deleteMessages.length).toBeGreaterThan(0); // Verify all messages were deleted - const deleteMsg = deleteEvent as DeleteMessage; + const deleteMsg = deleteMessages[0].data; expect(deleteMsg.historySequences.length).toBe(messages.length); - // Setup collector for verification message - const collector = createStreamCollector(env.orpc, workspaceId); - collector.start(); + // Clear events again before sending verification message + env.sentEvents.length = 0; // Send a message asking AI to repeat the word from the beginning // This should definitely fail since all history was cleared const result = await sendMessageWithModel( - env, + env.mockIpcRenderer, workspaceId, "What was the word I asked you to remember? Reply with just the word or 'I don't know'." ); @@ -162,6 +197,7 @@ describeIntegration("truncateHistory", () => { expect(result.success).toBe(true); // Wait for response + const collector = createEventCollector(env.sentEvents, workspaceId); await collector.waitForEvent("stream-end", 10000); assertStreamSuccess(collector); @@ -187,7 +223,6 @@ describeIntegration("truncateHistory", () => { lowerContent.includes("can't recall") ).toBe(true); } - collector.stop(); } finally { await cleanup(); } @@ -199,8 +234,6 @@ describeIntegration("truncateHistory", () => { "should block truncate during active stream and require Esc first", async () => { const { env, workspaceId, cleanup } = await setupWorkspace("anthropic"); - const collector = createStreamCollector(env.orpc, workspaceId); - collector.start(); try { const historyService = new HistoryService(env.config); @@ -216,31 +249,32 @@ describeIntegration("truncateHistory", () => { expect(result.success).toBe(true); } + // Clear events before starting stream + env.sentEvents.length = 0; + // Start a long-running stream void sendMessageWithModel( - env, + env.mockIpcRenderer, workspaceId, "Run this bash command: for i in {1..60}; do sleep 0.5; done && echo done" ); // Wait for stream to start - await collector.waitForEvent("stream-start", 10000); + const startCollector = createEventCollector(env.sentEvents, workspaceId); + await startCollector.waitForEvent("stream-start", 10000); // Try to truncate during active stream - should be blocked - const client = resolveOrpcClient(env); - const truncateResultWhileStreaming = await client.workspace.truncateHistory({ + const truncateResultWhileStreaming = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_TRUNCATE_HISTORY, workspaceId, - percentage: 1.0, - }); + 1.0 + ); expect(truncateResultWhileStreaming.success).toBe(false); - if (!truncateResultWhileStreaming.success) { - expect(truncateResultWhileStreaming.error).toContain("stream is active"); - expect(truncateResultWhileStreaming.error).toContain("Press Esc"); - } + expect(truncateResultWhileStreaming.error).toContain("stream is active"); + expect(truncateResultWhileStreaming.error).toContain("Press Esc"); // Test passed - truncate was successfully blocked during active stream } finally { - collector.stop(); await cleanup(); } }, diff --git a/tests/integration/websocketHistoryReplay.test.ts b/tests/ipcMain/websocketHistoryReplay.test.ts similarity index 69% rename from tests/integration/websocketHistoryReplay.test.ts rename to tests/ipcMain/websocketHistoryReplay.test.ts index 7ee99d36d..ea00b1d2f 100644 --- a/tests/integration/websocketHistoryReplay.test.ts +++ b/tests/ipcMain/websocketHistoryReplay.test.ts @@ -1,15 +1,8 @@ import { createTestEnvironment, cleanupTestEnvironment } from "./setup"; -import { - createWorkspace, - generateBranchName, - resolveOrpcClient, - createTempGitRepo, - cleanupTempGitRepo, -} from "./helpers"; -import type { WorkspaceChatMessage } from "@/common/orpc/types"; +import { createWorkspace, generateBranchName } from "./helpers"; +import { IPC_CHANNELS, getChatChannel } from "@/common/constants/ipc-constants"; +import type { WorkspaceChatMessage } from "@/common/types/ipc"; import type { MuxMessage } from "@/common/types/message"; -import { HistoryService } from "@/node/services/historyService"; -import { createMuxMessage } from "@/common/types/message"; /** * Integration test for WebSocket history replay bug @@ -50,13 +43,13 @@ describe("WebSocket history replay", () => { try { // Create temporary git repo for testing - + const { createTempGitRepo, cleanupTempGitRepo } = await import("./helpers"); const tempGitRepo = await createTempGitRepo(); try { // Create workspace const branchName = generateBranchName("ws-history-ipc-test"); - const createResult = await createWorkspace(env, tempGitRepo, branchName); + const createResult = await createWorkspace(env.mockIpcRenderer, tempGitRepo, branchName); if (!createResult.success) { throw new Error(`Workspace creation failed: ${createResult.error}`); @@ -65,7 +58,8 @@ describe("WebSocket history replay", () => { const workspaceId = createResult.metadata.id; // Directly write a test message to history file - + const { HistoryService } = await import("@/node/services/historyService"); + const { createMuxMessage } = await import("@/common/types/message"); const historyService = new HistoryService(env.config); const testMessage = createMuxMessage("test-msg-2", "user", "Test message for getHistory"); await historyService.appendToHistory(workspaceId, testMessage); @@ -73,18 +67,26 @@ describe("WebSocket history replay", () => { // Wait for file write await new Promise((resolve) => setTimeout(resolve, 100)); - // Read history directly via HistoryService (not ORPC - testing that direct reads don't broadcast) - const history = await historyService.getHistory(workspaceId); + // Clear sent events + env.sentEvents.length = 0; + + // Call the new getHistory IPC handler + const history = (await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_CHAT_GET_HISTORY, + workspaceId + )) as WorkspaceChatMessage[]; // Verify we got history back - expect(history.success).toBe(true); - if (!history.success) throw new Error("Failed to load history"); - expect(history.data.length).toBeGreaterThan(0); - console.log(`getHistory returned ${history.data.length} messages`); + expect(Array.isArray(history)).toBe(true); + expect(history.length).toBeGreaterThan(0); + console.log(`getHistory returned ${history.length} messages`); - // Note: Direct history read should not trigger ORPC subscription events - // This is implicitly verified by the fact that we're reading from HistoryService directly - // and not through any subscription mechanism. + // CRITICAL ASSERTION: No events should have been broadcast + // (getHistory should not trigger any webContents.send calls) + expect(env.sentEvents.length).toBe(0); + console.log( + `✓ getHistory did not broadcast any events (expected 0, got ${env.sentEvents.length})` + ); await cleanupTempGitRepo(tempGitRepo); } catch (error) { diff --git a/tests/integration/windowTitle.test.ts b/tests/ipcMain/windowTitle.test.ts similarity index 79% rename from tests/integration/windowTitle.test.ts rename to tests/ipcMain/windowTitle.test.ts index 2c9f3da57..814551b5a 100644 --- a/tests/integration/windowTitle.test.ts +++ b/tests/ipcMain/windowTitle.test.ts @@ -1,5 +1,5 @@ import { shouldRunIntegrationTests, createTestEnvironment, cleanupTestEnvironment } from "./setup"; -import { resolveOrpcClient } from "./helpers"; +import { IPC_CHANNELS } from "../../src/common/constants/ipc-constants"; const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip; @@ -14,8 +14,10 @@ describeIntegration("Window title IPC", () => { expect(env.mockWindow.setTitle).toBeDefined(); // Call setTitle via IPC - const client = resolveOrpcClient(env); - await client.window.setTitle({ title: "test-workspace - test-project - mux" }); + await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WINDOW_SET_TITLE, + "test-workspace - test-project - mux" + ); // Verify setTitle was called on the window expect(env.mockWindow.setTitle).toHaveBeenCalledWith("test-workspace - test-project - mux"); @@ -33,8 +35,7 @@ describeIntegration("Window title IPC", () => { try { // Set to default title - const client = resolveOrpcClient(env); - await client.window.setTitle({ title: "mux" }); + await env.mockIpcRenderer.invoke(IPC_CHANNELS.WINDOW_SET_TITLE, "mux"); // Verify setTitle was called with default expect(env.mockWindow.setTitle).toHaveBeenCalledWith("mux"); diff --git a/tests/setup.ts b/tests/setup.ts index df6f47bc0..de015e3b4 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -4,7 +4,8 @@ */ import assert from "assert"; -import "disposablestack/auto"; + +require("disposablestack/auto"); assert.equal(typeof Symbol.dispose, "symbol"); assert.equal(typeof Symbol.asyncDispose, "symbol"); @@ -28,7 +29,7 @@ if (typeof globalThis.File === "undefined") { if (process.env.TEST_INTEGRATION === "1") { // Store promise globally to ensure it blocks subsequent test execution (globalThis as any).__muxPreloadPromise = (async () => { - const { preloadTestModules } = await import("./integration/setup"); + const { preloadTestModules } = await import("./ipcMain/setup"); await preloadTestModules(); })(); diff --git a/tsconfig.json b/tsconfig.json index c4dc02a1c..40d697c5a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,7 @@ "target": "ES2020", "lib": ["ES2023", "DOM", "ES2022.Intl"], "module": "ESNext", - "moduleResolution": "bundler", + "moduleResolution": "node", "jsx": "react-jsx", "strict": true, "esModuleInterop": true, diff --git a/vite.config.ts b/vite.config.ts index ede8a8f8a..7c6330307 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -91,33 +91,33 @@ export default defineConfig(({ mode }) => ({ strictPort: true, allowedHosts: true, // Allow all hosts for dev server (secure by default via MUX_VITE_HOST) sourcemapIgnoreList: () => false, // Show all sources in DevTools - + watch: { // Ignore node_modules to drastically reduce file handle usage - ignored: ["**/node_modules/**", "**/dist/**", "**/.git/**"], - + ignored: ['**/node_modules/**', '**/dist/**', '**/.git/**'], + // Use polling on Windows to avoid file handle exhaustion // This is slightly less efficient but much more stable - usePolling: process.platform === "win32", - + usePolling: process.platform === 'win32', + // If using polling, set a reasonable interval (in milliseconds) interval: 1000, - + // Limit the depth of directory traversal depth: 3, - + // Additional options for Windows specifically - ...(process.platform === "win32" && { + ...(process.platform === 'win32' && { // Increase the binary interval for better Windows performance binaryInterval: 1000, // Use a more conservative approach to watching awaitWriteFinish: { stabilityThreshold: 500, - pollInterval: 100, - }, - }), + pollInterval: 100 + } + }) }, - + hmr: { // Configure HMR to use the correct host for remote access host: devServerHost, @@ -135,10 +135,10 @@ export default defineConfig(({ mode }) => ({ esbuildOptions: { target: "esnext", }, - + // Include only what's actually imported to reduce scanning - entries: ["src/**/*.{ts,tsx}"], - + entries: ['src/**/*.{ts,tsx}'], + // Force re-optimize dependencies force: false, }, diff --git a/vscode/CHANGELOG.md b/vscode/CHANGELOG.md index c6e376b76..98bd7d9de 100644 --- a/vscode/CHANGELOG.md +++ b/vscode/CHANGELOG.md @@ -5,7 +5,6 @@ All notable changes to the "mux" extension will be documented in this file. ## [0.1.0] - 2024-11-11 ### Added - - Initial release - Command to open mux workspaces from VS Code and Cursor - Support for local workspaces diff --git a/vscode/README.md b/vscode/README.md index e2bc6de2f..7cdefad3b 100644 --- a/vscode/README.md +++ b/vscode/README.md @@ -17,7 +17,6 @@ code --install-extension mux-0.1.0.vsix ## Requirements **For SSH workspaces**: Install Remote-SSH extension - - **VS Code**: `ms-vscode-remote.remote-ssh` - **Cursor**: `anysphere.remote-ssh` diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts index 3c46754ec..9fbb0e31e 100644 --- a/vscode/src/extension.ts +++ b/vscode/src/extension.ts @@ -61,7 +61,9 @@ async function openWorkspaceCommand() { // User can't easily open mux from VS Code, so just inform them if (selection === "Open mux") { - vscode.window.showInformationMessage("Please open the mux application to create workspaces."); + vscode.window.showInformationMessage( + "Please open the mux application to create workspaces." + ); } return; }